HashMap在多线程下put元素为什么会死循环?

文章导读
排查这类问题,我会先确认 CPU 使用率的异常表现。如果发现某个 Java 进程 CPU 持续 100%,而 jstack 无法正常打印线程栈,或者强制打印后线程卡在 HashMap 的 get 或 put 方法内部,并且代码行号指向 transfer、resize 或 getEntry 等涉及链表遍历的逻辑,就可以高度怀疑是 HashMap 的环形链表问题。这种现象大多发生在多线程并发 put
📋 目录
  1. 先确认现象
  2. 为什么会出现环形链表
  3. 风险边界与常见误判
  4. JDK 版本的差异与选择
  5. 建议的处理顺序
  6. 验证与后续维护
A A

排查这类问题,我会先确认 CPU 使用率的异常表现。如果发现某个 Java 进程 CPU 持续 100%,而 jstack 无法正常打印线程栈,或者强制打印后线程卡在 HashMap 的 get 或 put 方法内部,并且代码行号指向 transfer、resize 或 getEntry 等涉及链表遍历的逻辑,就可以高度怀疑是 HashMap 的环形链表问题。这种现象大多发生在多线程并发 put 且触发扩容的场景下。

先确认现象

死循环不会凭空出现。你需要先确认两个条件是否同时满足:一是多线程向同一个 HashMap 实例写数据,二是确实触发了扩容。如果只是单线程操作,或者虽然多线程但从未扩容,死循环不会发生。观察线程堆栈时,注意死循环线程通常处于 Runnable 状态,长时间不释放 CPU。一个简单的检查点是:在 CPU 占满的情况下执行 top -Hp [pid] 找到占用最高的线程,再通过 jstack [pid] | grep -A 30 [thread-id-hex] 查看该线程堆栈。如果堆栈反复出现 at java.util.HashMap.transferat java.util.HashMap.getEntry,基本能定位。

为什么会出现环形链表

下面这段分析能帮助理解根因。素材1原样保留:HashMap在JDK 7及之前版本中,扩容时采用头插法迁移节点。当多个线程同时触发扩容,线程A执行到一半时被挂起,线程B完成扩容后,线程A恢复执行时可能形成环形链表。例如,原始链表为A→B→C,线程A读取到A.next=B,此时线程B将链表反转后变为C→B→A。线程A继续处理时,会尝试将A插入到B之前,但B的下一个节点已经指向A,导致A和B互相引用,形成死循环。后续遍历或get操作时会陷入无限循环,CPU飙升。

HashMap在多线程下put元素为什么会死循环?

简单说,头插法在并发扩容时破坏链表结构,造成节点相互引用。JDK 8 改用尾插法,从设计上规避了这个问题。但即使使用 JDK 8,HashMap 依然有 put 覆盖、size 不准确等并发问题,只是不容易死循环而已。

风险边界与常见误判

素材2原样保留:死循环只发生在多线程并发put且触发扩容的场景。如果HashMap初始化时指定了足够大的初始容量,使得扩容从未发生,或者put操作的数量远小于扩容阈值,则风险较低。但需要注意的是,即使不触发扩容,多个线程同时put也可能导致数据覆盖或丢失,只是不会形成死循环。死循环的触发需要多个线程同时执行到扩容的transfer方法中的同一段代码,时间窗口极窄,但一旦发生就将导致线程永远卡死。

很多开发人员误以为只要避免扩容就能安全使用 HashMap,但数据覆盖问题同样不可忽视。另一个常见误解是:用了 ConcurrentHashMap 就万事大吉。实际上,如果在遍历过程中调用 ConcurrentHashMap 的 put(例如使用迭代器时执行修改),会抛出 ConcurrentModificationException,而不是死循环。这个异常是 Fail-Fast 机制,虽然打断了遍历,但至少不会造成线程卡死,属于可接受的错误。

HashMap在多线程下put元素为什么会死循环?

JDK 版本的差异与选择

JDK 7 及之前的 HashMap 在并发扩容时必然有死循环风险,绝对不要放在多线程环境中使用。JDK 8 虽然用尾插法解决了扩容死循环,但官方文档仍然明确说明 HashMap 不是线程安全的。实际使用中,JDK 8 的 HashMap 在多线程下可能出现元素覆盖、get 返回错误值等问题,甚至在某些极端并发下容量数组可能出现 null 引用导致空指针。因此,无论哪个版本,如果有多线程访问同一个 HashMap 实例,都应该换成线程安全的容器。

建议的处理顺序

第一步,确认问题来源。如果线上已经出现 CPU 飙升,首先通过线程堆栈定位,确认是否是 HashMap 死循环。若是,则需要紧急重启恢复服务,同时将代码中的 HashMap 替换为 ConcurrentHashMap。第二步,替换代码时要关注 ConcurrentHashMap 的迭代器弱一致性——它不会抛出 ConcurrentModificationException,但遍历时看到的数据可能是过时的。如果业务要求强一致性,需要加同步锁。第三步,如果无法修改代码(例如依赖第三方库),可以在 HashMap 的所有 put 操作前后加 synchronized 块,但性能会显著下降。最后一招,使用 ThreadLocal 为每个线程分配独立的 HashMap,彻底避免共享。

HashMap在多线程下put元素为什么会死循环?

验证与后续维护

替换后如何验证问题已解决?观察一段时间内 CPU 占用是否恢复正常,同时检查应用日志中是否还有与 HashMap 相关的异常。如果之前有线程卡死,重启后应该不再复现。更严谨的做法是:在测试环境模拟并发 put 场景,用 JVM 参数 -XX:+PrintGCDetails-XX:+PrintHeapAtGC 观察扩容行为,或者通过 jvisualvm 监控线程。后续维护时,建议在代码审查中重点检查此类并发集合的使用,并在 CI 中加入静态检查规则,禁止在非安全容器上执行多线程写操作。

另外,如果线上环境还保留着 JDK 7,务必将所有 HashMap 替换掉,或者升级到 JDK 8。升级后虽然解决了死循环,依然要警惕数据覆盖问题。一个实用的做法是:在高并发写入的场景下,直接采用 ConcurrentHashMap 并指定初始容量,避免扩容影响性能。ConcurrentHashMap 在 JDK 8 中引入了红黑树优化,可以进一步降低冲突代价。