HashMap的底层是Hash表结构,元素的排列是根据哈希算法和哈希函数排序的,且不可重复。
JDK8以前,Hash表的底层是【数组】+【链表】
JDK8及之后,变成了【数组】+【链表】+【红黑树】
存入新键值对时,如果出现哈希冲突,会先判断键是否相同,如果键相同,会比较值,值相同则不放入,值不同则修改原值;如果键不相同,则会以链表形式挂下来,并且1.7版本中是头插法,1.8版本是尾插法。
5.1、什么是哈希冲突?
哈希冲突就是两个元素在通过哈希函数后,得到的角标是相同的,在同一个哈希槽中。哈希冲突的四种解决思路分别是:重哈希法,开放地址法,建立公共溢出,链地址法。
5.2、HashMap的扩容机制是怎么样的?它什么时候会转化为红黑树?
Hash表中数组的分手手动初始化,和自动初始化,自动初初始会在第一次插入元素时开辟空间,默认长度为16,扩容因子为0.75,每次扩容量为自身的2倍长度,扩容之后存入数组的新索引位置就会改变。手动初始化的话,可以在创建对象时自定义初始数组长度,但HashMap不一定会自主设置的数值初始化数组,而按2的n次方创建。
HashMap1.7版本的的扩容时机是先判断是否达到阈值,达到先扩容,再添加元素,并且采用的是头插法,也就是旧元素挂在新元素下。
而HashMap1.8的扩容时机是先添加元素是否达到阈值,达到直接扩容,且使用的是尾插法,即新元素挂在旧元素下面。
初始化后,当存入新的键值对时,会先判断数组长度是否大于64,再判断链表元素是否大于等于8时,如果两者都成立,链表会自动转换成红黑树,如果数组小于64,会从第9个开始先扩容,直到数组大于等于64时,链表长度再增加,就会转为红黑树。
细节:
在添加第一个元素的时候是直接添加进数组的,而不会进入到红黑树转化的判断的,所以里面的binCount并没有创建。添加第二元素并发生了哈希冲突时,才进入红黑树转化的判断,同时初始化binCount=0,它判断的是binCount>=7,也就是0至7,有8个元素时,再加上没有进行判断的1个元素,即第9个元素时,才会转化为红黑树。
5.2.1、为什么要转为红黑树呢?
链表取一个数需要遍历链表,而红黑树相对效率要高。
5.2.2、为什么不直接使用红黑树呢?
HashMap源码中有相关描述: “因为树节点的大小是链表节点大小的两倍,所以只有在容器中包含足够的节点保证使用才用它”,显然尽管转为树使得查找的速度更快,但是在节点数比较小的时候,此时对于红黑树来说内存上的劣势会超过查找等操作的优势,自然使用链表更加好,但是在节点数比较多的时候,综合考虑,红黑树比链表要好。
5.2.3、为什么转为红黑树的条件是8而不是第9第10个呢?
源码中有对这个进行计算,正常的随机哈希码下,哈希冲突多到超过8个的概率不到千万分之一,几乎可以忽略不计了,再往后调整并没有很大意义。
如果哈希冲突有那么多,说明极大可能是人为设置,故意制造哈希冲突导致,这时候就转为化红黑树,来保证查询效率。
5.2.3.1、那什么时候退出红黑树呢?
当哈希冲突个数从第8个变到第6个时,红黑树转化为链表。
5.2.3.1、6与8之间的第7个冲突时,会是什么状态?
分情况看。8退6,是红黑树转链表,6进8,是链表转红黑树,中间的7是防止频繁变动做的一个预留位,如果是8退6,中间的7就是红黑树;如果是6进8,中间的7就是链表。
5.2.4、为什么1.7是头插法,1.8是尾插法?
1.7版本使用头插法是因为头插法是操作速度最快的,找到数组位置就直接找到插入位置了,但这样插入方法在并发场景下会因为多个线程同时扩容出现循环列表,也就是Hashmap的死锁问题。
1.8版本加入了红黑树来优化哈希桶中的遍历效率,相比头插法而言,尾插法在操作额外的遍历消耗(指遍历哈希桶)已经小很多,也可以避免之前的循环列表问题,同时如果已经变成红黑树了,也不能再用头插法了,而是按红黑树自己的规则排列了。
5.2.4.1、如果是头插法,怎么才能获取之前的旧元素呢?
因为1.7版本的头插法,是新元素在上面,旧元素挂新元素后面,所以新元素始终是在数组上的,可以通过在对象上重写toString方法,加上对象的HashCode值,这样只要打印出来相同的HashCode说明发生了哈希冲突,这时候只需要遍历即可,要取哪个就指定那个HashCode,相同就取出,而上一个老元素就是第二个获取的元素。
5.2.4.2、什么是HashMap双链循环/死锁?
双链循环是JDK1.7及更早的版本之前才有的问题。在多线程扩容的情况下,一个线程执行到一半,还未扩容,而另一个线程却抢走先行扩容了,这时候可能出现第一个线程的元素与第二个线程中的元素相互引用的情况,相互引用就会造成死锁。
比如一个数线长度为4,有两个数,一个为2,一个为10,那么这两个数都会在索引2上形成哈希桶结构,此时进行扩容,本来在新数组中是2指向10的,结果但之前那个前程正好断在10指向新数组的中间,这就会导至10又重新指向2,最终导while判断中的e永远不会等于null,造成死循环。
JDK1.8版本避免了双链循环,但不是完全避免,看过一些测试文章,红黑树之间也可能出现死循环,只是比较1.7版本,几率降低。
5.2.5、为什么1.7是先扩容再添加,1.8却改成先添加再扩容?
因为1.7版本中的扩容机制有两个条件:
1、 存放新值的时候当前已有元素的个数必须大于等于阈值(数组长度*0.75)。
2、 存放新值的时候当前存放数据发生hash碰撞(当前key计算的hash值换算出来的数组下标位置已经存在值)
要满足以上两个条件,很可能出现数组16个元素都填满的情况(正好无碰撞填满数组),如果是先添加再扩容,就会导致第17个元素必然发生哈希冲突,这不是我们要的结果,我们要的是尽量减少哈希冲突,所以需要先扩容,再放入元素。
而在1.8版本中,扩容的条件改成了实际数量大于等于阈值就扩容,所以允许了先添加再扩容这种情况,也可能是作者认为没有1.7那么强制性需要先扩容了,为了更符合思考逻辑,改成了先添加,再扩容。
5.2.6、1.8版本是否完全避免死循环问题?
不能。1.8版本中引进了红黑树,可以降低死循环出现的机率,但不能完全避免,红黑树之间也可能出现死循环。
5.3、HashMap为什么数组长度始终是2的n次方?
在HashMap的底层对于数组的操作其实是(n-1)&hash,当数组的长度为2的n次时,减1转为二进制后,他被任何数字&上都不会超过这个数字,比如数组长度为8,减1后为7,那么它的数组长度就是0-7,共8个,即元素可以在这个数组上全部排满,而如果是奇数,或者不是2的n次的偶数,一定会有一个二进制为0,也就是无论另一个数是什么,都不会被存入数组,会浪费掉的位置。
5.4、HashMap的扩容为每次是原来的2倍?
首先,HashMap的初始化的数组长度一定是2的n次的,每次扩容仍是原来的2倍的话,就不会破坏这个规律,每次扩容后,原数据都会进行数据迁移,根据二进制的计算,扩容后数据要么在原来位置,要么在【原来位置+扩容长度】,这样就不需要重新hash,效率上更高。
5.4、HashMap是怎么让存取效率最高的?
如果元素都是均匀存储在数据的角标位而不产生冲突,就是最好的。
1、尽可能少的产生hash冲突
hash函数计算出来的角标尽可能做到均匀。
2、确实产生了hash冲突——数据结构来解决
数组+链表+红黑树
3、HashMap的底层通过扰动函数(即高低位运算)来让数组更均匀的被分配。
(h = key.hashCode()) ^ (h >>> 16)
扰动函数将自己的前即低16位与高16位运算,可以让数组在65535(int)范围内更均匀的分配。高低位运算:16刚好在32位的中间,前16位和自己的后16位对比,然后再把对比值和数组的对比。
5.5、多线程下的HashMap线程安全吗?为什么?
HashMap本身就是不安全的,多线程下,在添加元素时容易出现数据覆盖情况而丢失数据,也可能在扩容时,迁移数据出现数据覆盖情况而丢失数据。