孤舟笔记 Java 集合篇六 为什么HashMap会产生死循环?JDK7的这个Bug你踩过吗
文章目录
个人网站
线上 CPU 100%,查了半天发现是 HashMap 死循环——这个经典 Bug 很多人都听过,但面试官追问"为什么会产生死循环"、“JDK 8 修复了吗”、“HashMap 线程安全了吗”,你可能答不全。
今天咱们把 HashMap 死循环的根因从源码层面彻底讲透。
一、先说结论:死循环核心事实
| 维度 | 说明 |
|---|---|
| 根因 | 多线程扩容时,头插法导致链表成环 |
| 发生版本 | JDK 7 及之前 |
| 触发条件 | 多线程并发 put 触发 resize |
| 表现 | CPU 100%,get 操作死循环 |
| JDK 8 修复 | 改为尾插法,链表不会成环 |
| HashMap 线程安全 | ❌ 仍然不安全(只是不会死循环了) |
一句话记住:JDK 7 的头插法像插队——后到的人站前面,多线程同时插队可能排成一个圈,永远走不出去。
二、头插法:死循环的根源
JDK 7 的扩容迁移使用头插法——新元素插到链表头部:
// JDK 7 transfer 方法(简化)
void transfer(Entry[] newTable) {
for (Entry<K,V> e : table) {
while (e != null) {
Entry<K,V> next = e.next; // 记住下一个
int i = indexFor(e.hash, newCapacity); // 新位置
e.next = newTable[i]; // 头插:新节点指向桶头 👈
newTable[i] = e; // 新节点成为桶头
e = next; // 处理下一个
}
}
}
头插法的效果——链表顺序反转:
扩容前:A → B → C(A 是链表头)
扩容后:C → B → A(C 是链表头,顺序反了)👈
为什么用头插法? JDK 7 的设计者认为最近插入的元素更可能被访问,放在头部缓存友好。但这个设计在多线程下是灾难。
三、死循环推演:一步步复现
前提: 两个线程同时扩容,链表 A → B。
线程 1 执行到一半被挂起:
线程 1 处理完 A,准备处理 B 时被挂起
此时线程 1 的状态:
e = B
next = null(B.next 在原链表中是 null)
新表:A → null
线程 2 完整执行扩容:
线程 2 头插法迁移:
插入 A:A → null
插入 B:B → A → null (头插,B 在前面)
线程 2 执行完毕,新表中链表:B → A → null
线程 1 恢复执行:
线程 1 继续处理 e = B:
1. next = B.next → 现在是 A!(线程 2 修改了链表)
2. B.next = newTable[i] → B.next = A(头插)
3. newTable[i] = B → 桶头改为 B
4. e = next → e = A
线程 1 处理 e = A:
5. next = A.next → 现在 A.next = B!(步骤 2 设置的)
6. A.next = newTable[i] → A.next = B(头插)
7. newTable[i] = A → 桶头改为 A
8. e = next → e = B
线程 1 处理 e = B:
9. 又回到 B → 形成环!💥 A ⇄ B
成环后的链表:A → B → A → B → A → … (无限循环)
任何 get 操作遍历这条链表都会死循环,CPU 飙到 100%。
四、JDK 8 的修复:尾插法
JDK 8 改为尾插法——新元素插到链表尾部,顺序不变:
// JDK 8 扩容(简化)
// 高低位拆分,尾插法
Node<K,V> loHead = null, loTail = null;
do {
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e; // 👈 尾插:接到尾部
loTail = e;
}
// ... 高位同理
} while ((e = e.next) != null);
尾插法的效果——链表顺序不变:
扩容前:A → B → C
扩容后:A → B → C(顺序不变)👈
为什么尾插法不会成环? 扩容时链表顺序不变,即使多线程同时扩容,也不会出现"后处理的节点指向前面已处理节点"的情况。
但请注意: JDK 8 只是修复了死循环,HashMap 仍然不是线程安全的!
五、HashMap 在多线程下还有哪些问题?
| 问题 | JDK 7 | JDK 8 |
|---|---|---|
| 链表成环死循环 | ✅ 存在 | ❌ 已修复 |
| 数据丢失 | ✅ 存在 | ✅ 仍然存在 |
| size 不准确 | ✅ 存在 | ✅ 仍然存在 |
| put 覆盖 | ✅ 存在 | ✅ 仍然存在 |
数据丢失示例:
线程 A 和 B 同时 put 到同一个空桶
1. 线程 A:newNode → 准备放到桶中
2. 线程 B:newNode → 也准备放到桶中
3. 线程 A 先写,线程 B 后写 → A 的数据被覆盖!💥
正确做法: 多线程场景使用 ConcurrentHashMap。
HashMap 死循环 全景
HashMap 死循环 全景
根因分析
├── 触发条件 ── 多线程并发 put 触发 resize
├── 头插法 ── 链表顺序反转
├── 竞态条件 ── 线程切换导致链表指针错乱
└── 结果 ── 链表成环,get 死循环
JDK 7 头插法
├── 新元素插头部 ── 链表反转
├── 多线程迁移 ── 互相覆盖 next 指针
└── 成环条件 ── 后处理的节点指向前处理的节点
JDK 8 尾插法修复
├── 新元素插尾部 ── 顺序不变
├── 高低位拆分 ── 更高效的迁移
└── 不会成环 ── 但仍有数据丢失风险
HashMap 仍然不安全
├── 数据丢失 ── 并发 put 覆盖
├── size 不准确 ── 并发修改
└── 正确做法 ── 使用 ConcurrentHashMap
口诀:头插法链表反转,多线程扩容会成环,
JDK8 改尾插法,顺序不变环不生,
但 HashMap 仍不安全,并发场景换 CHM。
回答技巧与点评
标准回答
HashMap 死循环发生在 JDK 7 及之前,根因是多线程并发扩容时头插法导致链表成环。头插法在迁移元素时会把链表顺序反转,当两个线程同时扩容并交替执行时,一个线程可能将另一个线程已迁移的节点重新链接,形成 A → B → A 的环形链表,后续 get 操作遍历链表时会死循环,CPU 飙到 100%。JDK 8 改为尾插法,链表顺序保持不变,不会成环。但 HashMap 在多线程下仍然不安全,可能出现数据丢失和 size 不准确,正确做法是使用 ConcurrentHashMap。
加分回答
- 为什么头插法会反转而尾插法不会:头插法每次把新节点插到头部,相当于栈的 LIFO 顺序,自然反转。尾插法保持 FIFO 顺序,迁移后和原链表一致。这个细微差别在单线程下无影响,但在多线程下就是致命的
- CPU 100% 的排查思路:线上遇到 CPU 100%,先用
top -H找到高 CPU 线程,再用jstack导出线程栈,搜索 RUNNABLE 状态的线程。如果看到 HashMap 的 get/put 方法在循环中,基本可以确认是链表成环。解决方案:重启 + 换 ConcurrentHashMap - ConcurrentHashMap 的安全保证:ConcurrentHashMap 通过 CAS + synchronized(JDK 8)或分段锁(JDK 7)保证线程安全。它的扩容是渐进式的——多个线程可以协助扩容,通过 transferIndex 分配迁移任务,避免了单线程扩容的性能瓶颈
面试官点评
这道题考的是你对并发 Bug 的分析能力。能说出"头插法成环"是基本要求,能一步步推演成环的过程、解释 JDK 8 尾插法为什么修复了这个问题,才算及格。如果你能强调"JDK 8 只是修复了死循环,HashMap 仍然不安全"、给出线上排查思路、分析 ConcurrentHashMap 的安全保证,面试官会认为你对并发问题的理解不仅停留在理论,还有实战经验。
内容有帮助?点赞、收藏、关注三连!评论区等你 💪
更多推荐


所有评论(0)