细嗦HashMap
hashmap 是一个非常重要的数据结构,在各种主流技术的底层经常会出现它的身影,今天我们就对Java 的hashmap 来展开说说;
基本知识
数据结构
在JDK 1.7中,采用 数组+链表 的方式(这里的链表就是解决hash冲突的方式了)
在JDK1.8 中,由 数组 + 链表/红黑树的方式
因为当链表过长,则会严重影响 HashMap 的性能,红黑树搜索时间复杂度是 O(logn),而链表是糟糕的 O(n)。因此,JDK1.8 对数据结构做了进一步的优化,引入了红黑树,链表和红黑树在达到一定条件会进行转换:
● 当链表超过 8 且 数据总量超过 64 才会转红黑树。
● 将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树,以减少搜索时间。
(数组中的节点在java7中叫做Entry,在java8当中叫做Node)
红黑树在特定场景下也会退化:
Put 方法的流程
1. 首先根据 key 的值计算 hash 值,找到该元素在数组中存储的下标;
2. 如果数组是空的,则调用 resize 进行初始化;
3. 如果没有哈希冲突直接放在对应的数组下标里;
4. 如果冲突了且 key 已经存在,就覆盖掉 value;
5. 如果冲突后,发现该节点是红黑树,就将这个节点挂在树上;
6. 如果冲突后是链表,判断该链表是否大于 8 ,如果大于 8 并且数组容量小于 64,就进行扩容;如果链表节点大于 8 并且数组的容量大于 64,则将这个结构转换为红黑树;否则,链表插入键值对,若 key 存在,就覆盖掉 value
Get 方法的流程
1. 对key的hashCode()做hash运算,计算index;
2. 如果在bucket⾥的第⼀个节点⾥直接命中,则直接返回;
3. 如果有冲突,则通过key.equals(k)去查找对应的节点;
4. 若为树,则在树中通过key.equals(k)查找,O(logn);
5. 若为链表,则在链表中通过key.equals(k)查找,O(n)
数组扩容(resize)
HashMap的扩容机制主要和负载因子和当前数组长度有关
resize为两步:
● 扩容:创建一个新的Entry空数组,长度是原数组的2倍。
● ReHash:遍历原Entry数组,把所有的Entry重新Hash到新数组
**<注意>**这里的rehash 是必要的,因为数组长度已经变了,根据
Hash的公式---> index = HashCode(Key) & (Length - 1)
这里很明显,索引的计算方式已经发生了改变了
深入探究
为什么扩容是2的次幂?
HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法; 这个算法实际就是取模,hash%length。
但是,大家都知道这种运算不如位移运算快。
因此,源码中做了优化hash&(length-1)
也就是说hash%length==hash&(length-1)
符号&是按位与的计算,这是位运算,计算机能直接运算,特别高效,按位与&的计算方法是,只有当对应位置的数据都为1时,运算结果也为1;
那为什么是2的n次方呢?
因为2的n次方实际就是1后面n个0,2的n次方-1,实际就是n个1。
上面四种情况我们可以看出,不同的hash值,和(n-1)进行位运算后,能够得出不同的值,使得添加的元素能够均匀分布在集合中不同的位置上,避免hash碰撞
可以看出,有三个不同的元素经过&运算得出了同样的结果,严重的hash碰撞了
为什么说hashmap 是线程不安全的?
● 多线程下扩容死循环。JDK1.7中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
● 多线程的put可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在JDK 1.7和 JDK 1.8 中都存在。
● put和get并发时,可能导致get为null。线程1执行put时,因为元素个数超出threshold而导致rehash,线程2此时执行get,有可能导致这个问题。此问题在JDK 1.7和 JDK 1.8 中都存在