本文共 3927 字,大约阅读时间需要 13 分钟。
HashMap这个数据结构,不管是在实际应用中还是面试中,都是很重要的知识点。例如:在实际应用中,我们需要在O(1)时间内查找到相关的元素,假如遍历数据找其相关元素,那时间复杂度就是O(n)了,不符合我们需要在O(1)时间复杂度里找到其元素,这时我们就想到用HashMap这个数据结构来进行查找,通过匹配key和value值,可以在O(1)时间内快速找到。再比如,在面试中,面试官对HashMap底层原理会进行全方位的考察,如:HashMap 1.7,1.8底层原理有什么区别?扩容机制?线程是否安全?等等。。。
所以,下面我会对HashMap的底层原理,做一个全方位的解析。
1.HashMap底层的数据结构是什么样的?
Java7底层的数据结构是:数组+链表,Java8之后底层的数据结构是:数组+链表+红黑树。
数组里面存储的是key-value这样的实例,在Java7中叫Entry,在Java8中叫Node。大概如下:
因为本身所有的位置都为null,在put插入的时候会根据key的hash算出一个index。
比如:put一个(“小周”,20),就是插入“小周”这个元素,通过hash算出对应的位置,比如是index=1,如下:
2.那都有数组了,为啥还要用链表呢?
我们知道数组的长度是有限的,在有限的长度里使用hash,就有概率会发生哈希冲突,比如说”小周“和”周小“就有一定概率在hash后,得到相同的index值,这样就会形成链表,如下:
每一个节点都会保存自身的hash、key、value、以及下一个节点,我们来看下Node的源码:
static class Nodeimplements Map.Entry { final int hash; final K key; V value; Node next; ...}
3.说到链表,那新的Entry节点是怎么插入到链表中的呢?
Java8之前是头插法,也就是新来的节点会取代原有的值,原有的值就顺序推到链表中去,就像上面的例子一样,因为写代码的作者认为后来的值被查找到的概率大一点,提升查找的效率。
但是Java8之后,都用了尾部插入发。
4.为什么要用尾插法?
首先在解释这个问题前,我们需要先了解下HashMap扩容机制,也就是在数组容量到达一定数量时,会进行resize。
5.什么是resize?
有两个因素:
Capacity:HashMap当前长度
LoadFactor:负载因子,默认值0.75f
怎么理解呢,就比如当前容量大小为100,当你存入第76个时,判断发现要进行resize,那就进行扩容,但是HashMap扩大容量也不是这么简单的。
6.怎么扩容?
分两步:
7.为什么要重新hash,直接复制过去不行嘛?
因为扩大长度后,Hash规则也发生了变化。
Hash公式:index = hashcode & (length - 1),原来的长度(length)是8,假如得到的index是1,但是长度增大到16,得到的index可就不是原先的值了。
8.回到之前说的,Java8为什么改为尾插法?
Java7在多线程操作hashMap时可能会引起死循环,原因是在扩容转移后,前后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系。
Java8在同样的条件下不会引起死循环,因为扩容后,前后链表的顺序没有改变,保持了之前节点的引用关系。
9.HashMap初始长度是多少?
从jdk1.8源码可以看到,初始化大小是1 << 4 ,也就是16。
10.为什么初始化大小是16?
因为16是2的4次幂,也就是是2的n次幂形式。之所以要是2的n次幂的形式,是因为:
(1) 因为扩容公式是:hashcode & (length - 1),其实他就是取模运算,Java使用位运算(&)代替取模运算(%),最主要的是考虑效率问题。
位运算(&)要比取模运算(%)效率高很多,主要因为位运算是对内存操作,不需要转化为十进制,因此处理速度快。
(2) 只要保证长度(length)是2的n次幂,然后通过扩容公式是:hashcode & (length - 1),就可以实现取模运算。
11.为什么在重写equals的时候,要重写hashcode? 能用hashMap举个栗子嘛?
因为在Java中,所有的对象都是继承Object类,Object类有两个方法equals和hashcode,这个两个方法用来比较两个对象是否相等。
在未重写equals方法时,我们继承的是Object的equals方法,那里的equals比较的是两个对象的内存地址,显然new了两个对象的内存地址是不一样的。
大家还记得我之前说的hashMap是通过key的hashcode去寻找index的,那index就形成一个链表,也就是说”小周“和”周小“的index都可能是2,在一个链表上。
当我们去get的时候,他就根据key的hash计算出index,那我们如何确定是”小周“还是”周小“呢?
equals!是的,所以如果对equals方法重写,建议一定要重写hashcode,以保证相同的对象返回相同的hash值,不同的对象返回不同的hash值。
12.那如何重写equals和hashcode方法呢?
这里举个栗子:
比如有一个Article类,其中包含一个url属性。
下面重写url的equals()和hashcode()方法:
@Overridepublic boolean equals(Object o) { if (this == o) return true; if (o == null || getClass() != o.getClass()) return false; Article article = (Article)o; return url != null ? url.equals(article.url) : article.url == null;}
@Overridepublic int hashCode() { return url != null ? url.hashCode() : 0;}
13.hashMap是否线程安全?
线程不安全。通常我们会使用HashTable或者CurrentHashMap,但是前者由于并发度问题,基本没有什么使用场景,所以在线程不安全的场景中我们会使用CurrentHashMap。
HashTable我看过它的源码,很简单粗暴,直接在其方法中加锁,并发度很低,最多同时允许一个线程访问,currentHashMap就好很多了,1.7和1.8有较大的不同,不过并发度比前者好很多,currentHashMap源码分析会在之后介绍。
14.HashMap 1.8什么时候会把链表转为红黑树?
当链表的长度大于等于8时,会自动转为红黑树。为什么?
因为这是基于统计的结果(源码中有描述),根据泊松分布,在负载因子默认为0.75时,单个hash槽内元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转化,小于等于6的时候就转化为链表。
15.为什么一开始不使用红黑树?反而要经历一个转化的过程?
通过查看源码可以发现,默认链表长度到达8的时候就转为红黑树,而当长度降到6时,转会链表,这体现了时间和空间平衡的思想。最开始使用链表的时候,由于链表比较短,空间占用比较少,所以查询时间没有太大问题。当链表越来越长的时候,用红黑树可以保证查询效率。通过源码可以,这个阀门值是8,当大于等于8的时候转为红黑树,小于等于6的时候转会链表。
16.HashMap、TreeMap、LinkedHashMap区别?
在对这三个集合遍历时:
(1)HashMap里面存入的键值在取出的时候是随机的,它根据键的hashcode值存储数据,根据键可以指直接获取它的值,具有很快的访问速度。在Map中插入、删除和定位元素,HashMap是最好的选择。
(2)TreeMap取出的是按自然顺序(比如:a、b、c…)排序后的键值对。
(3)LinkedHashMap是HashMap的一个子类,取出的顺序和输入的顺序是相同的。
三、总结:
HashMap不管是在实际开发中还是在面试的时候,都是很常用的数据结构,我们不能光只是会调用API,而是应该知道其底层原理,应知其然知其所以然,学习其源码的设计思想,这会潜移默化的改变我们以后设计代码的思路,让我设计出更好更高效的代码,并且在遇到问题的时候快速定位问题所在。不要只做CRUD boy or girl啊,这样又如何提升我们自己呢。
最后引用我很佩服的一个人经常说的话:你知道的越多,你不知道的越多!
文章参考:
https://mp.weixin.qq.com/s/0Gf2DzuzgEx0i3mHVvhKNQ
https://mp.weixin.qq.com/s/ktre8-C-cP_2HZxVW5fomQ
转载地址:http://tbsuk.baihongyu.com/