个人网站

线上 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。

加分回答
  1. 为什么头插法会反转而尾插法不会:头插法每次把新节点插到头部,相当于栈的 LIFO 顺序,自然反转。尾插法保持 FIFO 顺序,迁移后和原链表一致。这个细微差别在单线程下无影响,但在多线程下就是致命的
  2. CPU 100% 的排查思路:线上遇到 CPU 100%,先用 top -H 找到高 CPU 线程,再用 jstack 导出线程栈,搜索 RUNNABLE 状态的线程。如果看到 HashMap 的 get/put 方法在循环中,基本可以确认是链表成环。解决方案:重启 + 换 ConcurrentHashMap
  3. ConcurrentHashMap 的安全保证:ConcurrentHashMap 通过 CAS + synchronized(JDK 8)或分段锁(JDK 7)保证线程安全。它的扩容是渐进式的——多个线程可以协助扩容,通过 transferIndex 分配迁移任务,避免了单线程扩容的性能瓶颈
面试官点评

这道题考的是你对并发 Bug 的分析能力。能说出"头插法成环"是基本要求,能一步步推演成环的过程、解释 JDK 8 尾插法为什么修复了这个问题,才算及格。如果你能强调"JDK 8 只是修复了死循环,HashMap 仍然不安全"、给出线上排查思路、分析 ConcurrentHashMap 的安全保证,面试官会认为你对并发问题的理解不仅停留在理论,还有实战经验。

原文阅读


内容有帮助?点赞、收藏、关注三连!评论区等你 💪

更多推荐