并发场景下使用 HashMap 可能出现死循环的原因?
在并发场景下使用 HashMap
可能出现死循环,主要原因是 JDK 1.7 及之前版本中 HashMap
的扩容机制(transfer
方法)采用「头插法」迁移链表节点,且未考虑线程安全,导致多线程并发扩容时链表形成环形结构,进而在后续操作(如 get
或 put
)中触发无限循环。
具体原因分析(以 JDK 1.7 为例)
HashMap
的底层结构是「数组 + 链表」,当元素数量超过阈值(容量 * 负载因子
)时,会触发扩容(数组长度翻倍),并通过 transfer
方法将旧数组的元素迁移到新数组。死循环的根源就在 transfer
方法的并发执行中。
1. 扩容的核心步骤(transfer
方法)
迁移链表节点时,JDK 1.7 采用「头插法」:将旧链表的节点按逆序插入新链表(新节点插入到链表头部)。伪代码如下:
void transfer(Entry[] newTable) {Entry[] src = table;int newCapacity = newTable.length;for (int j = 0; j < src.length; j++) {Entry<K,V> e = src[j];if (e != null) {src[j] = null; // 释放旧数组的引用// 遍历旧链表,迁移每个节点到新数组do {Entry<K,V> next = e.next; // 记录下一个节点int i = indexFor(e.hash, newCapacity); // 计算新索引e.next = newTable[i]; // 头插法:新节点的next指向新链表的头newTable[i] = e; // 将新节点设为新链表的头e = next; // 处理下一个节点} while (e != null);}}
}
2. 并发扩容导致链表成环
当两个线程同时对同一个 HashMap
进行扩容时,可能因「头插法」和线程调度顺序导致链表节点的 next
指针形成环:
- 步骤 1:线程 A 和线程 B 同时开始扩容,都读取到旧数组中某条链表(如
e -> a -> b
,e
是头节点)。 - 步骤 2:线程 A 执行到
Entry<K,V> next = e.next
后暂停(此时next
指向a
)。 - 步骤 3:线程 B 正常完成扩容,将旧链表
e -> a -> b
迁移为新链表b -> a -> e
(头插法逆序)。 - 步骤 4:线程 A 恢复执行,此时它持有的
e
仍指向旧的e
,但e.next
已被线程 B 修改为null
(因为线程 B 已迁移完e
并释放了旧引用)。 - 步骤 5:线程 A 继续迁移
e
:e.next
指向新链表的头(此时新链表在 A 看来是空,所以e.next = null
),并将e
设为新链表头。 - 步骤 6:线程 A 处理
next
(即a
),计算新索引后,a.next
指向当前新链表头e
,并将a
设为新链表头。此时链表变为a -> e
。 - 步骤 7:线程 A 处理
a
的下一个节点(即e
,因为线程 B 中a.next
已改为e
),e.next
指向当前新链表头a
,导致e -> a
形成环(a.next = e
且e.next = a
)。
3. 死循环的触发
当链表形成环后,后续对该链表的操作(如 get
查找不存在的元素)会陷入无限循环(遍历链表时永远无法到达尾部),导致 CPU 占用率飙升至 100%。
JDK 1.8 的改进
JDK 1.8 中 HashMap
的扩容机制进行了优化:
- 改用「尾插法」迁移链表节点(保持原链表顺序),避免了逆序插入导致的环。
扩容逻辑的代码重构(resize
方法)
JDK 1.8 将扩容逻辑从 transfer
方法整合到 resize
方法中,代码更简洁且避免了旧版本的潜在风险:
- 迁移节点时,通过
next
指针记录下一个节点,确保遍历顺序稳定。 - 对于每个链表,先找到迁移后新链表的头节点和尾节点,再将整个链表批量迁移到新数组,减少指针操作的并发风险。
核心代码片段(简化):
final Node<K,V>[] resize() {// ... 计算新容量、新阈值等省略 ...Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab; // 直接替换旧数组引用for (int j = 0; j < oldCap; ++j) {Node<K,V> e;if ((e = oldTab[j]) != null) {oldTab[j] = null; // 释放旧数组引用if (e.next == null) {// 单个节点直接迁移newTab[e.hash & (newCap - 1)] = e;} else if (e instanceof TreeNode) {// 红黑树迁移(单独处理)((TreeNode<K,V>)e).split(this, newTab, j, oldCap);} else {// 链表迁移(尾插法核心)Node<K,V> loHead = null, loTail = null; // 原索引位置的链表Node<K,V> hiHead = null, hiTail = null; // 新索引位置的链表Node<K,V> next;do {next = e.next;// 判断节点应留在原索引还是迁移到新索引if ((e.hash & oldCap) == 0) {// 尾插法:更新尾节点指针if (loTail == null) loHead = e;else loTail.next = e;loTail = e;} else {if (hiTail == null) hiHead = e;else hiTail.next = e;hiTail = e;}} while ((e = next) != null);// 将两个链表放入新数组的对应位置if (loTail != null) {loTail.next = null;newTab[j] = loHead;}if (hiTail != null) {hiTail.next = null;newTab[j + oldCap] = hiHead;}}}}return newTab;
}
关键改进:
- 通过
loTail
和hiTail
指针记录链表尾部,迁移时保持节点顺序(尾插法)。 - 链表被拆分为两个子链表(原索引和新索引),批量迁移而非逐个节点插入,减少指针操作次数。
引入红黑树优化,减少长链表的遍历风险
JDK 1.8 中,当链表长度超过阈值(默认 8)且数组容量 ≥ 64 时,链表会转换为红黑树。
- 红黑树的遍历是有序的,即使在极端情况下出现并发问题,也不会像链表那样陷入无限循环(红黑树有自平衡机制,遍历有明确终止条件)。
总结
并发场景下 HashMap
出现死循环的根本原因是 JDK 1.7 及之前的「头插法」扩容机制在多线程并发时导致链表成环。解决办法是:
- 并发场景下使用线程安全的
ConcurrentHashMap
(推荐)。 - 或通过
Collections.synchronizedMap
包装HashMap
(性能较差)。
但 HashMap
仍不是线程安全的(并发 put
可能导致数据覆盖、丢失等问题),只是解决了死循环问题。避免在多线程环境中直接使用 HashMap
。