文章目录

Java HashMap在高并发场景下的死循环问题排查

发布于 2026-04-04 13:06:53 · 浏览 17 次 · 评论 0 条

Java HashMap在高并发场景下的死循环问题排查

在高并发环境中使用 HashMap 可能导致应用出现 CPU 飙升、服务假死等严重问题。这类问题的根源在于 HashMap 本身并非线程安全,却在多线程场景下被错误使用。本篇文章将深入剖析问题成因,并提供系统化的排查思路与解决方案。


一、问题现象与诊断

HashMap 在高并发场景下发生死循环时,通常会表现出以下特征:

CPU 异常:单个或多个 CPU 核心使用率直接飙升至 100%,且持续不降。运维人员通过 top 命令或监控面板可以明显看到这一异常。应用对外表现为请求超时、响应缓慢甚至完全无响应。

线程阻塞:通过 jstack 命令导出线程堆栈,会发现大量线程处于 RUNNABLE 状态,且堆栈信息中频繁出现与 HashMapgetput 相关的方法调用。部分线程可能显示处于 WAITINGTIMED_WAITING 状态,等待获取某个被死循环线程占用的资源。

日志无明显异常:业务日志中可能看不到任何 error 级别的输出,因为死循环本身不会抛出异常,而是导致线程在内部无限循环消耗 CPU 资源。


二、根本原因:JDK 7 及之前的头插法机制

理解 HashMap 死循环问题,必须从其数据结构与扩容机制说起。

2.1 HashMap 的数据结构

HashMap 内部采用数组加链表的结构( JDK 8 之后在链表过长时转为红黑树)。当发生哈希冲突时,元素以链表形式存储在对应桶位上。数组的每个位置称为一个桶(Bucket),每个桶指向一条链表的头节点。

2.2 头插法的问题所在

在 JDK 7 及之前的版本中,HashMap 在扩容时采用头插法处理链表。具体而言,当数组需要扩容(容量翻倍)时,会重新计算每个元素的桶位置,并将旧链表中的元素移动到新数组。头插法的逻辑是:将新元素的 next 指针指向当前链表的头节点,然后将头节点更新为新元素。

问题就出在这个看似简单的操作上。假设当前线程 T1 正在执行扩容操作,已经完成了部分元素的重hash工作,此时线程 T2 介入并执行 put 操作,向链表中添加新元素。由于没有锁保护,两个线程会同时操作同一条链表。

考虑一条链表 A -> B -> null,线程 T1 需要将其插入新数组。头插法的执行过程如下:

  1. 取出链表头节点 A
  2. 创建新节点 temp = A
  3. 遍历链表:e = A,计算新桶位置,执行 e.next = newTable[index]newTable[index] = ee = e.next

问题的关键在于,当 T1 只完成到一半(比如刚刚把 A 插入新链表),T2 同时执行 put 并向原链表中添加了新元素 C。此时原链表变为 C -> A -> B,而 T1 仍然认为链表是 A -> B,它继续使用 A.next(此时 A.next 已经被 T2 修改为 B)进行遍历。

更危险的情况发生在多线程同时扩容时。假设两条线程都在执行扩容,都在使用头插法处理同一条链表。线程 T1 和 T2 可能会互相修改对方的链表指针,最终导致链表形成环形结构:A.next = BB.next = A。当后续有线程调用 get 方法遍历到这个桶时,就会陷入无限循环,因为链表尾节点的 next 指针指向了链表中的某个节点,形成闭环。

2.3 关键代码位置

问题集中在 HashMaptransfer 方法中(JDK 7 源码):

void transfer(Entry[] newTable, boolean rehash) {
    int newCapacity = newTable.length;
    for (Entry<K,V> e : table) {
        while(null != e) {
            Entry<K,V> next = e.next;
            if (rehash) {
                e.hash = null == e.key ? 0 : hash(e.key);
            }
            int i = indexFor(e.hash, newCapacity);
            e.next = newTable[i];
            newTable[i] = e;
            e = next;
        }
    }
}

注意这行 e.next = newTable[i];,它将当前元素的 next 指向了新数组的目标位置。如果在执行这行代码时发生了线程切换,另一个线程可能会修改 e.next 的指向,导致环形链表的产生。


三、排查步骤指南

步骤一:确认 CPU 异常

使用 top 命令查看 CPU 使用情况。登录服务器后,执行以下命令查看所有进程的 CPU 使用率:

top -bn1 | head -50

重点关注 CPU 使用率接近 100% 的进程,记录其 PID。通常这个 PID 就是你的 Java 进程。

步骤二:导出线程堆栈

使用 jstack 命令导出线程快照。将第 一步记录下的 PID 替换 YOUR_PID,执行:

jstack YOUR_PID > thread_dump.txt

打开 thread_dump.txt 文件,搜索与 HashMapgetputtransfer 相关的线程堆栈。如果发现大量线程处于 java.util.HashMap.getjava.util.HashMap.put 方法的调用栈中,且堆栈信息显示线程处于 RUNNABLE 状态,可以初步判定为死循环问题。

步骤三:定位具体循环位置

在导出的线程堆栈中,寻找带有 at java.util.HashMap.getat java.util.HashMap.put 字样的堆栈。观察堆栈信息中的代码行号,对照项目中的 HashMap 源代码(如果是 JDK 源码,需要确认使用的 JDK 版本),确定具体是哪个方法中的循环导致了死循环。JDK 7 中通常是 get 方法遍历链表时的 while 循环。

步骤四:分析循环原因

通过堆栈信息判断循环发生的场景:

  • 如果堆栈显示在 get 方法中,通常是因为某个桶位上的链表形成了环形结构。
  • 如果堆栈显示在 put 方法的 addEntrycreateEntry 中,可能是因为扩容时的 transfer 操作导致了环形链表。
  • 如果堆栈显示在 resizetransfer 中,说明扩容过程中发生了线程安全问题。

步骤五:确认代码调用链路

根据线程堆栈中的方法调用链,逆向追踪到业务代码。检查代码中是否有以下违规使用场景:

  • 多个线程共享同一个 HashMap 实例执行写操作。
  • 在 Spring 容器中将 HashMap 定义为单例 bean 并注入到多线程环境中使用。
  • 使用 HashMap 作为缓存,且更新和读取操作没有同步措施。

四、解决方案

方案一:使用线程安全的 Map 实现(推荐)

直接替换为 ConcurrentHashMap,这是最简单、最有效的解决方案。ConcurrentHashMap 采用分段锁机制,在 JDK 8 之后更是优化为 CAS + synchronized 实现,能够在保证线程安全的同时提供较好的并发性能。

// 替换前
Map<String, Object> cache = new HashMap<>();

// 替换后
Map<String, Object> cache = new ConcurrentHashMap<>();

方案二:使用 Collections.synchronizedMap 包装

如果因为某些原因无法使用 ConcurrentHashMap,可以使用 Collections.synchronizedMap 进行包装。该方法会对所有操作加锁,虽然性能不如 ConcurrentHashMap,但能够保证线程安全。

Map<String, Object> map = Collections.synchronizedMap(new HashMap<>());

使用这种方式时,需要注意所有操作都会串行执行,高并发场景下可能出现性能瓶颈。

方案三:使用 JDK 8 及之后的版本

如果项目仍然使用 JDK 7,应尽快升级到 JDK 8 或更高版本。JDK 8 对 HashMap 进行了重大改进:

  • 将头插法改为尾插法,避免了扩容时环形链表的产生。
  • 引入红黑树结构,当链表长度超过 8 时自动转换为红黑树,降低了极端情况下的查找复杂度。

需要注意的是,虽然 JDK 8 解决了环形链表问题,但 HashMap 本身仍然不是线程安全的,多线程同时写入仍可能导致数据丢失等不一致问题。因此,即使升级到 JDK 8,在并发场景下仍应使用 ConcurrentHashMap


五、预防措施与最佳实践

明确 Map 的使用场景。在编写代码时,如果一个 Map 实例可能被多个线程访问,必须明确其线程安全需求。将 Map 声明为方法内的局部变量通常是安全的,但如果 Map 作为类的成员变量存在,就需要格外小心。

代码审查时关注共享状态。在代码审查过程中,重点关注那些作为共享资源被多个线程访问的集合类对象。如果发现 HashMap 或其他非线程安全集合被定义为类的成员变量,且类实例可能被多线程共享,应立即提出质疑并要求整改。

单元测试覆盖并发场景。为涉及共享 Map 的代码编写并发单元测试,使用 ExecutorService 模拟多线程环境,验证在高并发情况下是否会出现预期外的问题。可以在测试中检查最终 Map 的大小是否符合预期,验证是否存在数据丢失。

记录 Map 的创建位置。在代码中明确记录每个 Map 实例的创建位置和使用范围,使用注释说明其线程安全特性。这有助于后续维护人员理解代码意图,避免误用。


六、总结

HashMap 在高并发场景下的死循环问题,本质上是线程安全问题。JDK 7 及之前版本的头插法扩容机制,在多线程同时执行时容易产生环形链表,导致调用 get 方法的线程陷入死循环。排查这类问题需要从 CPU 异常入手,通过 jstack 分析线程堆栈,定位循环发生的具体位置。

解决问题的核心思路是避免在多线程环境下直接使用非线程安全的 HashMap。推荐使用 ConcurrentHashMap 作为替代方案,它能够在保证线程安全的同时提供良好的并发性能。同时,团队应建立代码审查机制,确保涉及共享状态的集合使用符合线程安全规范。

评论 (0)

暂无评论,快来抢沙发吧!

扫一扫,手机查看

扫描上方二维码,在手机上查看本文