HashMap 是 Java 开发者最常用的集合类之一,但 JDK 1.7 和 1.8 两个版本在底层实现上有革命性的变化。这些差异不仅仅是代码重构,更是对性能、安全性和扩展性的深度思考。本文将带你深入源码,逐一剖析两者的核心区别。
一、数据结构:从“数组+链表”到“数组+链表+红黑树”
这是两者最直观、最核心的区别。
JDK 1.7 采用经典的 数组 + 单链表 结构(拉链法)。当发生哈希冲突时,冲突的元素以链表形式存储在数组的同一个槽位上。数组元素和链表节点由 Entry 类实现,包含 key、value、hash 和指向下一个节点的 next 指针。
JDK 1.8 将 Entry 更名为 Node,但在链表长度超过阈值(默认为 8) 且数组长度 ≥ 64 时,会将链表树化为 红黑树(TreeNode)。这个设计的核心目的是:当大量元素落在同一个桶导致链表很长时,查找复杂度从 O(n) 优化为 O(log n),避免极端情况下的性能退化。
二、插入方式:头插法 vs 尾插法 —— 死循环的根源
JDK 1.7 采用头插法:新元素插入链表头部,即 newEntry.next = table[index]; table[index] = newEntry;。
JDK 1.8 改为尾插法:新元素插入链表尾部,保持原有顺序。
这个看似细微的改动,直接决定了 HashMap 在多线程下是否会“死循环”。
JDK 1.7 死循环复现
JDK 1.7 的 transfer() 方法在扩容时,会遍历旧数组的每个链表,将元素按头插法迁移到新数组,导致链表顺序反转。在多线程并发扩容时,这种反转可能引发环形链表,导致后续 get() 操作陷入死循环,CPU 飙升至 100%。
简化的死循环流程如下:
线程 T1 挂起:T1 刚执行到
Entry<K,V> next = e.next;(记录下一个节点 B)后被挂起,此时 T1 的e指向节点 A。线程 T2 完成扩容:T2 独自完成整个扩容过程。由于头插法,原链表
A -> B在新表中变成了B -> A。T1 恢复执行:T1 继续处理它之前保存的引用。此时它看到的 A 和 B 之间的关系已被 T2 改变(B 指向了 A)。
环形链表诞生:T1 按头插法将 A 插入新表,接着处理 B,发现 B 指向 A,于是将 B 也插入……最终形成了
A -> B且B -> A的循环引用。
JDK 1.8 采用尾插法,在扩容时保持了链表的原有顺序,从根本上杜绝了头插法带来的环形链表问题。但请注意,这并不意味着 HashMap 变得线程安全,只是避免了最致命的一种死循环场景。数据覆盖等问题在 1.8 中依然存在。
三、扩容机制:全量 Rehash vs 高低位映射
JDK 1.7 的扩容:当 size >= threshold 时,容量扩容为原来的 2 倍,然后对所有元素重新计算哈希值(Rehash),再按头插法迁移到新数组。这是一个全量操作,在元素量大时开销较高。
JDK 1.8 的扩容优化:由于容量始终是 2 的幂次方,扩容后元素的新索引只有两种可能:原位置 或 原位置 + 旧容量。判断依据是 (e.hash & oldCap) == 0。
JDK 1.8 采用 高低位分流 的优雅设计,使用 loHead/loTail(低位链表)和 hiHead/hiTail(高位链表)两组指针分别处理留在原位的元素和需要移动的元素。无需重新计算每个元素的 hash 值,大大提升了扩容效率。如果桶内是红黑树,split() 方法同样按高低位拆分,拆分后长度 ≤ 6 时还会退化为链表。
四、哈希扰动函数:4次异或 vs 1次异或
哈希算法的目标是让 hashCode 的高位也能参与数组下标的计算,使元素分布更均匀,减少碰撞。
JDK 1.7 的
hash()方法进行了 4 次位运算(h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4);),充分“扰动”高地位。JDK 1.8 简化为 1 次:
(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);。
JDK 1.8 在保持良好离散性的前提下,减少了计算开销。性能的优化往往就在这些细节里。
五、扩容时机:先扩再插 vs 先插再扩
这是一个容易被忽略但很有意思的细节差异:
JDK 1.7:先检查是否需要扩容(
if (size >= threshold)),确认不需要或扩容完成后,再进行插入操作。JDK 1.8:先执行
putVal()插入新元素,插入完成后才判断size > threshold,决定是否扩容。
这个顺序调整本身对最终结果影响不大,但结合插入方式的变更,是整体重构的一部分。
六、总结对比
为了便于查阅,将核心差异汇总如下:
写在最后
从 JDK 1.7 到 1.8,HashMap 的演进体现了 Java 开发者对性能和安全的不懈追求。红黑树的引入解决了哈希冲突严重时的性能瓶颈,尾插法和高低位映射的设计不仅提高了扩容效率,更彻底消除了臭名昭著的死循环问题。
然而,HashMap 从来都不是线程安全的容器。在并发场景下,请优先使用 ConcurrentHashMap。理解这些底层差异,不仅能帮你写出更高效的代码,也能让你在遇到诡异问题(比如 CPU 突然飙升)时,更快地定位根源。