JDK 1.8 将 HashMap 的链表插入方式从头插法改为尾插法,主要是为了解决头插法在多线程扩容时可能导致的环形链表死循环问题,同时为红黑树优化提供支持。
一、头插法在 JDK 1.7 中的问题(环形链表形成机制)
1. 头插法的操作逻辑
-
当发生哈希冲突时,新节点插入到链表的头部(即
table[i]
位置)。 -
代码示例(JDK 1.7 源码):
void transfer(Entry[] newTable) { for (Entry<K,V> e : table) { while (e != null) { Entry<K,V> next = e.next; // 记录下一个节点 int i = indexFor(e.hash, newTable.length); e.next = newTable[i]; // 新节点指向链表头部 newTable[i] = e; // 更新链表头为新节点 e = next; // 移动到下一个节点 } } }
2. 多线程扩容导致死循环(图示)
假设两个线程同时触发扩容,且操作同一个链表(例如链表 A → B
):
-
线程1:执行到
Entry<K,V> next = e.next;
后挂起(此时e = A, next = B
)。 -
线程2:完成扩容,新链表变为
B → A
(头插法反转了顺序)。 -
线程1恢复执行:
- 将
A
插入新链表头部,此时newTable[i] = A
。 A.next
指向newTable[i]
(即B
),形成A → B
。- 但线程2扩容后
B.next
指向A
(因为原链表已反转),形成A ⇄ B
的环形链表(见下图)。
- 将
graph LR
subgraph 线程2扩容后
B[B] --> A[A]
end
subgraph 线程1恢复后
A[A] --> B[B]
B[B] --> A[A]
end
3. 后果
-
A 和 B 形成了环形后,线程1 的扩容就已经陷入了死循环
-
就算完成了扩容,后续调用
get()
或put()
时,若命中此链表,也会因环形引用导致死循环和 CPU 100% 问题。
🔄 二、JDK 1.8 尾插法的改进
1. 操作逻辑
-
新节点插入到链表尾部(遍历至链表末尾再插入)。
-
代码示例(JDK 1.8 源码):
for (Node<K,V> e : tab) { while (e != null) { Node<K,V> next = e.next; if ((e.hash & oldCap) == 0) { // 插入到低位链表尾部 } else { // 插入到高位链表尾部 } e = next; } }
2. 解决环形链表问题
- 尾插法保持链表顺序不变(例如
A → B
扩容后仍是A → B
),避免多线程反转链表。 - 即使多线程并发扩容,也不会形成环形结构(尾部插入不改变节点间的原有指向)。
⚙️ 三、尾插法的其他优势
-
支持红黑树转换
- JDK 1.8 引入红黑树优化长链表查询(链表 ≥8 且数组 ≥64 时树化)。
- 尾插法需遍历链表统计长度,便于触发树化检查;头插法无法直接获取长度。
-
扩容性能优化
- JDK 1.8 采用高低位拆分策略:通过
(e.hash & oldCap) == 0
判断节点位置(原索引或原索引+旧容量)。 - 尾插法结合此策略,只需按顺序拆分链表到高位或低位桶,无需重计算哈希值(见下图)。
- JDK 1.8 采用高低位拆分策略:通过
graph LR
subgraph 旧数组
A[A - 索引0] --> B[B - 索引0]
end
subgraph 新数组
A1[A - 索引0]
B1[B - 索引16]
end
-
数据顺序保留
- 尾插法保持插入顺序,便于调试和按序遍历(如
LinkedHashMap
的基础)。
- 尾插法保持插入顺序,便于调试和按序遍历(如
💎 四、总结
对比项 | 头插法(JDK 1.7) | 尾插法(JDK 1.8) |
---|---|---|
插入位置 | 链表头部 | 链表尾部 |
多线程安全 | 导致环形链表死循环 | 避免环形链表 |
扩容性能 | 需反转链表,开销大 | 高低位拆分,无需重哈希 |
红黑树支持 | 无法统计链表长度 | 便于触发树化检查 |
数据顺序 | 倒序插入,遍历混乱 | 保持插入顺序 |
💡 核心结论:JDK 1.8 改用尾插法,主要解决了头插法在并发扩容时的致命死循环问题,同时为红黑树优化和高效扩容提供了基础。但需注意:HashMap 仍是非线程安全的,多线程场景应改用 ConcurrentHashMap
。