开会员与付费前请必须阅读这篇文章,在首页置顶第一篇:(进站必看本站VIP介绍/购买须知)
本站所有源码均为自动秒发货,默认(百度网盘)
本站所有源码均为自动秒发货,默认(百度网盘)
在Java开发中,HashMap是我们日常最常用的集合类之一,凭借高效的查询和插入性能,成为业务开发中的“常客”。但很少有开发者注意到,在多线程环境下,HashMap的扩容机制可能会引发致命问题——死循环,最终导致CPU使用率飙升至100%,服务卡死、无法响应。
这种问题往往隐蔽性极强,开发环境中难以复现,一旦在线上爆发,会造成严重的生产事故,排查起来也十分棘手。今天,我们就从底层原理出发,一步步拆解HashMap扩容死循环的来龙去脉,结合源码、复现场景和解决方案,帮你彻底避开这个“坑”。
一、先看现象:线上CPU 100%的典型表现
首先,我们先明确这类问题的线上表现,方便大家快速定位:
-
服务突然卡顿,接口响应时间从毫秒级飙升至秒级,甚至直接超时;
-
服务器监控显示,单个或多个CPU核心使用率长期维持在100%,且无法通过重启以外的方式恢复;
-
查看线程栈(jstack命令),会发现多个线程卡在HashMap的get()或put()方法中,调用栈中频繁出现transfer()(JDK7)或resize()(JDK8)相关方法;
-
服务日志无明显报错,仅能看到线程阻塞相关的痕迹,排查难度极大。
曾经遇到过一次线上事故:某电商平台的订单查询服务,在高峰期突然卡死,CPU使用率直接拉满,排查后发现,正是多线程并发操作HashMap,触发扩容死循环导致的。接下来,我们就从HashMap的扩容机制入手,剖析问题根源。
二、核心原理:HashMap扩容机制与死循环根源
要理解死循环,首先要搞懂HashMap的扩容机制——当HashMap中的元素个数(size)超过阈值(threshold = 容量 × 负载因子)时,会触发扩容(resize),将容量扩大为原来的2倍,并将旧数组中的元素迁移到新数组中。
而死循环的核心诱因,在于JDK7与JDK8中HashMap的扩容实现差异,其中JDK7的问题最为典型,JDK8虽有优化,但仍存在潜在风险。
1. JDK7:头插法+多线程并发,直接触发环形链表
JDK7中,HashMap的底层结构是「数组+单向链表」,扩容时通过transfer()方法迁移元素,核心采用「头插法」插入节点——这种插入方式会导致链表反转,而多线程并发操作时,会破坏链表的引用关系,最终形成环形链表,引发死循环。
(1)JDK7 transfer()核心源码(关键部分)
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; // 步骤1:记录下一个节点 if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity); // 计算新数组索引 e.next = newTable[i]; // 步骤2:头插法,新节点指向新桶的头节点 newTable[i] = e; // 步骤3:新节点成为新桶的头节点 e = next; // 步骤4:继续处理下一个节点 } } }
(2)死循环形成过程(图解+步骤)
假设场景:两个线程(T1、T2)同时对一个HashMap进行put操作,触发扩容,旧数组中某个桶的链表为「A → B → null」,具体步骤如下:
-
初始状态:T1和T2都开始执行transfer()方法,遍历旧链表,T1先执行到「Entry next = e.next;」,此时e=A,next=B,随后T1被CPU调度挂起;
-
T2正常执行:完成整个扩容迁移,由于头插法,链表反转,新链表变为「B → A → null」,此时B的next指向A,A的next为null;
-
T1恢复执行:继续持有之前的旧引用(e=A,next=B),执行步骤2「e.next = newTable[i]」,此时newTable[i]指向T2迁移后的头节点B,所以A.next = B;
-
T1执行步骤3「newTable[i] = e」,将A设为新桶的头节点;
-
T1执行步骤4「e = next」,e变为B,继续循环:执行「Entry next = e.next」,此时B的next已被T2设为A,所以next=A;
-
重复步骤2-4:B.next指向A(当前新桶的头节点),新桶头节点变为B,e变为A;
-
环形链表形成:此时A.next = B,B.next = A,形成无限循环,后续对该桶的get()、put()操作会陷入死循环,持续占用CPU资源,最终导致CPU 100%。
核心根源:JDK7的头插法会反转链表顺序,多线程并发时,线程挂起导致引用错乱,最终形成环形链表,遍历链表时陷入无限循环。
2. JDK8:尾插法优化,但仍非绝对安全
JDK8对HashMap进行了重大优化,彻底解决了扩容死循环的核心问题,主要改进点如下:
-
底层结构:改为「数组+链表+红黑树」,链表长度超过8时转为红黑树,提升查询性能;
-
插入方式:将头插法改为尾插法,扩容时保持链表原有顺序,避免反转;
-
扩容计算:无需重新计算hash值,通过位运算判断元素在新数组的位置(原索引或原索引+旧容量),效率更高。
由于尾插法不会反转链表,多线程扩容时不会形成环形链表,因此JDK8不会出现JDK7那种典型的扩容死循环。但注意:JDK8的HashMap依然不是线程安全的,多线程环境下仍可能出现数据覆盖、size计数不准等问题,甚至在极端场景下(如红黑树旋转、节点转移时并发修改),可能出现新形态的死循环,只是概率极低。
三、实战复现:模拟HashMap扩容死循环(JDK7环境)
为了让大家更直观地感受问题,我们用JDK7环境编写代码,模拟多线程并发操作HashMap,触发扩容死循环,步骤如下:
1. 环境准备
-
JDK版本:1.7.0_80(必须JDK7,JDK8无法复现);
-
核心思路:创建多个线程,同时向HashMap中插入大量数据,触发扩容,观察CPU使用率和线程栈。
2. 复现代码
import java.util.HashMap; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; /** * JDK7 HashMap扩容死循环复现 */ public class HashMapDeadLoopDemo { // 初始化HashMap,指定初始容量为1,负载因子0.75,快速触发扩容 private static final Map<Integer, String> map = new HashMap<>(1, 0.75f); public static void main(String[] args) { // 创建10个线程,并发插入数据 ExecutorService executor = Executors.newFixedThreadPool(10); for (int i = 0; i < 10; i++) { final int num = i; executor.execute(() -> { // 插入大量数据,触发多次扩容 for (int j = 0; j < 10000; j++) { map.put(num * 10000 + j, “value-” + j); } System.out.println(Thread.currentThread().getName() + ” 插入完成”); }); } executor.shutdown(); } }
3. 现象观察
运行代码后,会发现:
-
程序无法正常结束,控制台输出断断续续,甚至停止;
-
通过任务管理器(Windows)或top命令(Linux)查看,Java进程的CPU使用率飙升至100%;
-
使用jstack命令查看线程栈,会发现多个线程卡在HashMap的transfer()方法中,陷入无限循环。
至此,我们成功复现了JDK7中HashMap扩容死循环导致CPU 100%的问题。
四、问题排查:线上CPU 100%如何定位到HashMap死循环?
线上出现CPU 100%时,排查思路如下,核心是通过工具定位到阻塞线程和具体代码:
1. 第一步:定位高CPU进程
使用top命令(Linux)查看进程CPU使用率,找到CPU占比最高的Java进程,记录进程ID(PID):
# top命令查看进程 top -c # 找到CPU使用率100%的Java进程,假设PID为12345
2. 第二步:定位高CPU线程
使用top -H -p 命令,查看该进程下所有线程的CPU使用率,找到占比最高的线程,记录线程ID(TID):
# 查看PID为12345的进程下所有线程 top -H -p 12345 # 找到CPU使用率最高的线程,假设TID为12346
3. 第三步:转换线程ID格式
jstack命令输出的线程ID是十六进制,需要将十进制的TID转换为十六进制(无需加0x前缀):
# 十进制转十六进制,假设TID为12346,转换后为303a printf “%x\n” 12346
4. 第四步:查看线程栈,定位问题
使用jstack命令打印进程的线程栈,搜索十六进制的线程ID,查看线程阻塞位置:
# 打印线程栈,输出到文件 jstack 12345 > jstack.log # 搜索线程ID(303a),查看阻塞位置
如果线程栈中出现「HashMap.transfer()」方法,且反复出现相同的调用栈,基本可以确定是HashMap扩容死循环导致的。
五、解决方案:彻底避免扩容死循环的3种方式
解决问题的核心原则:多线程环境下,禁止使用非线程安全的HashMap,改用线程安全的集合类,或通过同步机制保证线程安全。具体有3种方案,优先推荐方案1。
方案1:使用ConcurrentHashMap(推荐)
ConcurrentHashMap是Java提供的线程安全的哈希表,专门用于多线程环境,从根源上解决了HashMap的线程安全问题,包括扩容死循环、数据覆盖等。
JDK7中,ConcurrentHashMap采用「分段锁」机制,保证并发安全性;JDK8中,优化为「CAS+ synchronized」,性能更优,同时避免了死循环问题。
替换方式非常简单,只需将HashMap替换为ConcurrentHashMap:
// 替换前 Map<Integer, String> map = new HashMap<>(); // 替换后 Map<Integer, String> map = new ConcurrentHashMap<>();
优势:无需额外处理同步,性能优秀,支持高并发,是多线程环境下的首选。
方案2:使用Collections.synchronizedMap()
Collections.synchronizedMap()会对HashMap进行包装,返回一个线程安全的Map,本质是对所有方法加了synchronized锁,保证同一时刻只有一个线程操作Map。
// 包装HashMap,获得线程安全的Map Map<Integer, String> map = Collections.synchronizedMap(new HashMap<>());
注意:使用时需注意,遍历该Map时,需要手动加锁(否则可能出现ConcurrentModificationException异常):
synchronized (map) { for (Map.Entry<Integer, String> entry : map.entrySet()) { // 遍历操作 } }
优势:使用简单,无需修改原有代码逻辑;劣势:性能较差,全局加锁,并发度低,适合并发量较小的场景。
方案3:手动加锁(不推荐)
如果必须使用HashMap,可以在操作HashMap的代码块中手动加锁(如synchronized锁),保证同一时刻只有一个线程执行扩容和修改操作。
private final Object lock = new Object(); private Map<Integer, String> map = new HashMap<>(); // 操作HashMap时加锁 public void putData(Integer key, String value) { synchronized (lock) { map.put(key, value); } } public String getData(Integer key) { synchronized (lock) { return map.get(key); } }
优势:灵活可控;劣势:手动加锁容易遗漏,且锁的粒度难以控制,容易出现死锁或性能瓶颈,不推荐在高并发场景使用。
六、避坑总结与最佳实践
结合前面的分析,我们总结几个关键要点,帮你彻底避开HashMap扩容死循环的坑:
-
明确使用场景:单线程环境用HashMap,多线程环境用ConcurrentHashMap,这是最核心的原则,避免因图方便而使用非线程安全的集合;
-
规避JDK7风险:如果项目仍在使用JDK7,务必检查代码中是否有多个线程操作HashMap的场景,立即替换为ConcurrentHashMap;
-
不依赖JDK8的“安全”:即使使用JDK8,也不要认为HashMap可以在多线程环境下使用,它依然存在数据覆盖等线程安全问题;
-
线上排查技巧:记住“top定位进程→top -H定位线程→jstack查看线程栈”的排查流程,快速定位CPU 100%的根源;
-
初始容量优化:创建HashMap时,根据预期数据量设置合理的初始容量,减少扩容次数,既提升性能,也减少并发扩容的风险(即使是ConcurrentHashMap,也建议优化初始容量)。
七、最后总结
HashMap扩容死循环导致CPU 100%,本质是JDK7中头插法+多线程并发操作,破坏了链表引用关系,形成环形链表,导致遍历无限循环。JDK8虽通过尾插法解决了该问题,但HashMap依然不是线程安全的。
在实际开发中,我们无需过度纠结于死循环的底层细节,只需记住一个核心准则:多线程环境下,坚决使用ConcurrentHashMap,避免使用HashMap和Collections.synchronizedMap()(除非并发量极低)。