HashMap实现原理以及源码解析jdk1.8(4)--疑难杂症
1、为什么HashMap桶中链表长度个数超过8才转为红黑树?
首先,HashMap桶中, 并不是链表长度个数超过8一定会转为红黑树。在上文已演示过。
树化的条件是:桶中链表的长度达到了8,并且数组的长度大于等于64。
在极端情况下: 当连续存储的元素的 hash 相同, 个数达到 11时, 也就是说 table 中只有一个元素, 但是链表长度达到 11, 此时链表也会转树形;
大部分情况下,链表存储能节约存储空间同时有着良好的查找性能;极个别情况下,节点数达到8个,转为红黑树,能获得更好的查找性能,同时因为是个别情况,不需要大量的存储空间。
TreeNodes(红黑树)占用空间是普通Nodes(链表)的两倍,为了时间和空间的权衡。
节点的分布频率会遵循泊松分布,链表长度达到8个元素的概率为0.00000006,几乎是不可能事件。
当单链表中元素个数超过8个时,会进而转化为红黑树存储,巧妙地将遍历元素时时间复杂度从O(n)降低到了O(logn))。
2、为什么要转换,进行树化?
因为Map中桶的元素初始化是链表保存的,其查找性能是O(n),而树结构能将查找性能提升到O(log(n))。
当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。
当bin中节点数变少时,又会转成普通的bin(链表长度达到8就转成红黑树,当长度降到6就转成普通bin),上文也已演示。
3、为什么转化为红黑树的阈值8和转化为链表的阈值6不一样?
为了避免频繁来回转化。
4、HashMap为何从(1.7)头插入改为尾插入(1.8)
HashMap在jdk1.7中采用头插入法,在扩容时会改变链表中元素原本的顺序,以至于在并发场景下导致链表成环的问题。
jdk1.8中采用尾插入法。
HashMap在多线程下时不安全的,所以无论在那个版本,都会出现链表成环的问题,只是在1.8概率低了。
循环链表的产生本身就是在多线程并发时才会产生的。HashMap本身就不是线程安全的,去讨论HashMap在并发情况下所产生的行为纯属无稽之谈。
解决环形链表问题是因为1.8扩容机制的改变,导致形成环形链表的概率大幅度降低,而改为尾插,是因为红黑树的需要,如果头插,效率是比尾插低的。
在1.8以后Map接口增加了新方法putIfAbsent,要判断链表是否重复,除了遍历别无他法,既然都遍历了,那就换尾插法咯,既降低死循环概率,又顺便统计了链表长度。
5、聊一聊死循环问题
在 JDK 1.8 以前,Java 语言在并发情况下使用 HashMap 造成 Race Condition,从而导致死循环。程序经常占了 100% 的 CPU,查看堆栈,你会发现程序都 Hang 在了 “HashMap.get()” 这个方法上了,重启程序后问题消失。
我们知道,JDK 1.8 以前,导致死循环的主要原因是扩容后,节点的顺序会反掉,如下图:扩容前节点 A 在节点 C 前面,而扩容后节点 C 在节点 A 前面。
JDK 1.8 具体扩容过程:
结果:可以看出,扩容后,节点 A 和节点 C 的先后顺序跟扩容前是一样的。因此,即使此时有多个线程并发扩容,也不会出现死循环的情况。当然,这仍然改变不了 HashMap 仍是非并发安全,在并发下,还是要使用 ConcurrentHashMap 来代替。
6、HashMap 和 Hashtable 的区别
- HashMap 允许 key 和 value 为 null,Hashtable 不允许。
- HashMap 的默认初始容量为 16,Hashtable 为 11。
- HashMap 的扩容为原来的 2 倍,Hashtable 的扩容为原来的 2 倍加 1。
- HashMap 是非线程安全的,Hashtable是线程安全的。
- HashMap 的 hash 值重新计算过,Hashtable 直接使用 hashCode。
- HashMap 去掉了 Hashtable 中的 contains 方法。
- HashMap 继承自 AbstractMap 类,Hashtable 继承自 Dictionary 类。
7、总结
- HashMap 的底层是个 Node 数组(Node<K,V>[] table),在数组的具体索引位置,如果存在多个节点,则可能是以链表或红黑树的形式存在。
- 增加、删除、查找键值对时,定位到哈希桶数组的位置是很关键的一步,源码中是通过下面3个操作来完成这一步:1)拿到 key 的 hashCode 值;2)将 hashCode 的高位参与运算,重新计算 hash 值;3)将计算出来的 hash 值与 “table.length - 1” 进行 & 运算。
- HashMap 的默认初始容量(capacity)是 16,capacity 必须为 2 的幂次方;默认负载因子(load factor)是 0.75;实际能存放的节点个数(threshold,即触发扩容的阈值)= capacity * load factor。
- HashMap 在触发扩容后,阈值会变为原来的 2 倍,并且会对所有节点进行重 hash 分布,重 hash 分布后节点的新分布位置只可能有两个:“原索引位置” 或 “原索引+oldCap位置”。例如 capacity 为16,索引位置 5 的节点扩容后,只可能分布在新表 “索引位置5” 和 “索引位置21(5+16)”。
- 导致 HashMap 扩容后,同一个索引位置的节点重 hash 最多分布在两个位置的根本原因是:1)table的长度始终为 2 的 n 次方;2)索引位置的计算方法为 “(table.length - 1) & hash”。HashMap 扩容是一个比较耗时的操作,定义 HashMap 时尽量给个接近的初始容量值。
- HashMap 有 threshold 属性和 loadFactor 属性,但是没有 capacity 属性。初始化时,如果传了初始化容量值,该值是存在 threshold 变量,并且 Node 数组是在第一次 put 时才会进行初始化,初始化时会将此时的 threshold 值作为新表的 capacity 值,然后用 capacity 和 loadFactor 计算新表的真正 threshold 值。
- 当同一个索引位置的节点在增加后达到 9 个时,并且此时数组的长度大于等于 64,则会触发链表节点(Node)转红黑树节点(TreeNode),转成红黑树节点后,其实链表的结构还存在,通过 next 属性维持。链表节点转红黑树节点的具体方法为源码中的 treeifyBin 方法。而如果数组长度小于64,则不会触发链表转红黑树,而是会进行扩容。
- 当同一个索引位置的节点在移除后达到 6 个时,并且该索引位置的节点为红黑树节点,会触发红黑树节点转链表节点。红黑树节点转链表节点的具体方法为源码中的 untreeify 方法。
- HashMap 在 JDK 1.8 之后不再有死循环的问题,JDK 1.8 之前存在死循环的根本原因是在扩容后同一索引位置的节点顺序会反掉。
- HashMap 是非线程安全的,在并发场景下使用 ConcurrentHashMap 来代替。
参考资料:
http://blog.csdn.net/v123411739/article/details/78996181