JDK 1.7 HashMap 多线程扩容因头插法+无锁并发导致环形链表,引发 get/put 等操作无限循环、CPU 100%、线程状态为 RUNNABLE;JDK 1.8 改用尾插法避免成环,但仍非线程安全,推荐使用 ConcurrentHashMap。

HashMap 在多线程下不会导致真正意义上的“死锁”,而是引发无限循环(CPU 100%,线程状态为 RUNNABLE),根源在于 JDK 1.7 扩容时的头插法 + 无锁并发操作。掌握这一现象的成因,是实战排查并发问题的关键入口。
死循环不是死锁,但表现更隐蔽
很多工程师看到线程卡在 get/put 方法、CPU 拉满、jstack 显示大量线程停在 HashMap 内部,第一反应是“死锁”。但实际堆栈里看不到 BLOCKED 或 WAITING,全是 RUNNABLE —— 这说明线程没等锁,而是在疯狂遍历一个环形链表。
- 死锁需满足互斥、持有并等待、不可剥夺、循环等待四个条件;HashMap 环形链表不涉及锁竞争,不符合定义
- 真正的问题是:链表 next 指针被多线程交叉改写,形成 A→B→A 的闭环
- 只要后续有线程调用 get()、put()、size()、toString() 等需遍历桶内链表的方法,就会陷入无限跳转
聚焦 JDK 1.7 transfer() 头插法的竞态路径
实战排查必须回到源码级逻辑。JDK 1.7 的 resize() 调用 transfer() 迁移节点,核心是两行:
-
e.next = newTable[i](让当前节点指向新桶原头结点) -
newTable[i] = e(把当前节点设为新桶新头结点)
单线程下这会反转链表(A→B→C 变成 C→B→A),无害;但两个线程 T1、T2 同时处理同一旧桶 A→B:
- T1 读取 e=A,缓存 next=B,然后被挂起
- T2 完成迁移:先插 B,再插 A → 新桶变成 A→B(注意:此时 A.next = null,B.next = A)
- T1 恢复:仍用旧 next=B,执行 A.next = newTable[i] → A.next = A(因为 newTable[i] 此时是 A)
- 结果:A→A,或更常见 A↔B 循环
用 jstack 快速定位环形链表嫌疑点
线上发生 CPU 飙高时,第一时间执行 jstack -l <pid>,重点关注:
- 多个线程堆栈都停留在
HashMap.get(HashMap.java:...)或HashMap.put(HashMap.java:...),且行号集中在链表遍历循环(如 while(e != null) { ... e = e.next; }) - 线程状态全是 RUNNABLE,没有锁信息(区别于真实死锁的 blocked on lock)
- 若发现若干线程卡在相同方法、相同行号,基本可锁定是 HashMap 扩容环问题
配合 top -H -p <pid> 查看哪个线程 CPU 最高,再用 jstack 找到它对应的 nid(十六进制),能更快圈定问题线程。
验证与规避:从 JDK 版本和选型入手
确认是环形链表后,下一步是验证影响范围和落地解法:
- JDK 1.8 虽改用尾插法避免成环,但仍非线程安全:并发 put 可能丢数据、覆盖值,只是不会死循环
- ConcurrentHashMap 是首选替代:JDK 1.8 使用 CAS + synchronized 控制桶粒度,扩容也支持多线程协作,无环风险
- 若必须用 HashMap,须外加同步:如用 Collections.synchronizedMap() 包装,或对所有读写操作加同一把锁(注意 toString()、entrySet() 等方法也需同步)
不复杂但容易忽略

















