parasafi
parasafi
发布于 2026-09-09 / 20 阅读
0
0

1.7HashMap VS 1.8 HashMap

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),避免极端情况下的性能退化。

版本

底层数据结构

节点实现类

JDK 1.7

数组 + 单链表

Entry<K,V>

JDK 1.8

数组 + 链表 + 红黑树

Node<K,V>(链表)/ TreeNode<K,V>(红黑树)

二、插入方式:头插法 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%。

简化的死循环流程如下:

  1. 线程 T1 挂起:T1 刚执行到 Entry<K,V> next = e.next;(记录下一个节点 B)后被挂起,此时 T1 的 e 指向节点 A。

  2. 线程 T2 完成扩容:T2 独自完成整个扩容过程。由于头插法,原链表 A -> B 在新表中变成了 B -> A。

  3. T1 恢复执行:T1 继续处理它之前保存的引用。此时它看到的 A 和 B 之间的关系已被 T2 改变(B 指向了 A)。

  4. 环形链表诞生: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

JDK 1.8

数据结构

数组 + 单链表

数组 + 链表 + 红黑树

节点类

Entry

Node / TreeNode

插入方式

头插法

尾插法

扩容迁移

全量 Rehash,顺序反转

高低位映射,顺序不变

死循环风险

存在(多线程并发扩容)

已修复(但仍非线程安全)

hash 扰动

4 次位运算

1 次位运算

扩容时机

先扩容,后插入

先插入,后扩容

树化阈值

无

链表长度 ≥ 8 且数组 ≥ 64 时转红黑树

写在最后

从 JDK 1.7 到 1.8,HashMap 的演进体现了 Java 开发者对性能和安全的不懈追求。红黑树的引入解决了哈希冲突严重时的性能瓶颈,尾插法和高低位映射的设计不仅提高了扩容效率,更彻底消除了臭名昭著的死循环问题。

然而,HashMap 从来都不是线程安全的容器。在并发场景下,请优先使用 ConcurrentHashMap。理解这些底层差异,不仅能帮你写出更高效的代码,也能让你在遇到诡异问题(比如 CPU 突然飙升)时,更快地定位根源。


评论