我在多线程里共用一个 HashMap 做缓存,某天线上 CPU 突然飙到 100% 卡死、线程全堆在 HashMap.get 上,我抓着线程栈复盘了大半天的惨痛经历
这是一个让我对"线程安全"四个字,从此心存敬畏的故事。我有一个服务,内部用一个 HashMap 当本地缓存,多个线程都会往里读写。它平时跑得好好的,可某一天,线上监控突然告警:某台机器的 CPU,毫无征兆地飙到了 100%,而且降不下来;服务响应全部超时、近乎卡死。我登上去一看,机器没在做什么繁重的计算,可 CPU 就是满载。我赶紧用 jstack 抓了线程栈,结果头皮发麻:有好几个线程,都死死地卡在 HashMap.get() 这个方法里,而且是在一个 for 循环里,出不来——它们陷入了死循环,在疯狂地空转,把 CPU 烧满了。
我当时就懵了:一个最普通不过的 HashMap.get(),怎么会死循环?我顺着这个线索深挖,才终于揭开真相,也补上了我 Java 知识里一个致命的漏洞:HashMap,是线程不安全的。我却在多个线程里,不加任何保护地,并发读写同一个 HashMap。问题就出在它扩容(resize)的时候:当多个线程同时触发扩容,在(JDK 7 的)头插法迁移链表节点的过程中,会因为并发,把链表的指针搞乱、接成一个"环"——本该是 A→B→null 的链表,被并发操作弄成了 A→B→A→B→…… 的环形链表。而一旦链表成了环,后续任何一个线程,只要 get() 一个恰好落在这条链上、却不存在的 key,它就会沿着链表一个个找下去,可这条链没有尽头(永远遇不到 null),于是它就永远地、疯狂地循环下去,把一个 CPU 核心,彻底烧满。这,就是臭名昭著的"HashMap 并发扩容导致死循环"。我这才痛彻地明白:HashMap(以及 ArrayList 等大多数普通集合)从设计上,就只考虑了单线程使用,绝不能跨线程共享读写;在多线程下共享它,轻则数据错乱、丢失,重则(像我这次)直接死循环、打满 CPU、拖垮整个服务。线程安全,从来不是"碰巧没出事"就等于安全;那些非线程安全的容器,在并发下,是真的会以你意想不到的、极其凶险的方式炸给你看的。
故障现场:多线程共享 HashMap,扩容成环、get 死循环
我把这个"CPU 烧满"的现场,用代码还原给你看:
// ✗ 灾难: 多线程并发读写同一个非线程安全的 HashMap
public class Cache {
// ✗ 普通 HashMap, 线程不安全!
private static final Map<String, Object> cache = new HashMap<>();
public static Object get(String key) {
return cache.get(key); // ✗ 多线程并发调用
}
public static void put(String key, Object val) {
cache.put(key, val); // ✗ 多线程并发写 → 触发扩容时可能成环
}
}
// 死循环怎么发生(JDK 7 头插法):
// 1. 多个线程同时 put, 同时触发 resize(扩容)。
// 2. 迁移链表节点时, 头插法 + 并发 → 指针被搞乱, 链表接成环:
// 本该 A -> B -> null, 变成 A -> B -> A -> B -> ...(环形)。
// 3. 此后任何线程 get 一个落在这条链、却不存在的 key:
// 沿链表一个个找 -> 永远遇不到 null -> 无限循环! CPU 烧满。
// 现象:
// - jstack 显示多个线程卡在 HashMap.get / getEntry 的 for 循环里。
// - 一个核心 CPU 100%, 降不下来, 服务卡死。
// - JDK 8 改了尾插法, 不再成环, 但并发下仍会数据丢失/覆盖/出错!
// 根因: HashMap 线程不安全, 多线程并发读写(尤其扩容)→ 链表成环 → get 死循环。
// 线程不安全容器, 绝不能跨线程共享读写。
看着这段代码,我后背直冒冷汗。问题的根源,是我用了一个线程不安全的 HashMap 当共享缓存,却让多个线程,不加任何保护地,并发读写它。死循环,是这么发生的(以 JDK 7 的头插法为例):第一,多个线程同时 put,又同时触发了扩容(resize);第二,在迁移链表节点时,头插法叠加并发,把链表的指针搞乱了,本该是 A→B→null 的链,被接成了 A→B→A→B→…… 的环形链表;第三,此后,任何一个线程,只要 get() 一个恰好落在这条环形链、却不存在的 key,它就会沿着链表一个个找下去,可这条链永远遇不到 null,于是它就无限循环,把一个 CPU 核心彻底烧满。现象,正与我看到的吻合:jstack 显示多个线程卡在 HashMap.get / getEntry 的 for 循环里;一个核心 CPU 100%、降不下来、服务卡死。而且要特别注意:JDK 8 虽然把头插法改成了尾插法、不再"成环"了,但并发下,它依然会数据丢失、互相覆盖、出错——"不死循环",绝不等于"线程安全"!归根结底:HashMap 线程不安全,多线程并发读写(尤其撞上扩容)就会链表成环、get 死循环;线程不安全的容器,绝不能跨线程共享读写。
第一件事:搞懂"线程不安全"到底意味着什么
定位到根源,我必须把"线程安全"这件事,从根上彻底搞清楚:
线程安全: 多个线程同时访问, 行为依然正确, 才叫线程安全
# 为什么 HashMap 不是线程安全的?
# - 它的 put/get/resize 都不是"原子"的, 由多步操作组成。
# - 多线程交错执行这些步骤 → 中间状态被另一个线程看到/破坏。
# - 设计目标: 单线程下高性能; 并发安全要你自己保证。
# 并发读写 HashMap 会出什么事?
# - JDK 7: 扩容头插法 + 并发 → 链表成环 → get 死循环 → CPU 100%(本文)。
# - JDK 8: 改尾插法不成环, 但仍会: 数据丢失/覆盖、size 算错、读到脏数据。
# - 共同点: 行为不可预测, 平时"碰巧没事", 高并发某刻突然爆。
# 哪些常见容器"线程不安全"(不能跨线程共享写)?
# - HashMap / ArrayList / LinkedList / HashSet / StringBuilder ...
# - 绝大多数 java.util 下的普通集合, 都是线程不安全的。
# 线程安全的替代品:
# - Map: ConcurrentHashMap(首选, 高并发) / Collections.synchronizedMap
# - List: CopyOnWriteArrayList(读多写少) / Collections.synchronizedList
# - 计数: AtomicInteger / LongAdder
# - String 拼接: StringBuffer(或局部变量用 StringBuilder)
# 关键认知: "碰巧没出事" ≠ "线程安全"。
# 并发 bug 隐蔽, 测试难复现, 往往上了量、撞上时序才暴露。
# 核心: 非线程安全容器不能跨线程共享读写;
# 多线程共享就用并发容器(ConcurrentHashMap)或加锁保护。
原理终于清晰了。什么叫"线程安全"?——多个线程同时访问,它的行为依然正确,才叫线程安全。那 HashMap 为什么不是?因为它的 put/get/resize,都不是"原子"操作,而是由多步组成的;多个线程交错执行这些步骤,就会让中间状态被另一个线程看到、甚至破坏;它的设计目标,是单线程下的高性能,并发安全,需要你自己来保证。并发读写它会出什么事?JDK 7,扩容头插法叠加并发,链表成环、get 死循环、CPU 100%(本文);JDK 8,改尾插法不成环了,但依然会数据丢失/覆盖、size 算错、读到脏数据;共同点是——行为不可预测,平时"碰巧没事",高并发某一刻突然爆。哪些常见容器是线程不安全的?HashMap/ArrayList/LinkedList/HashSet/StringBuilder……绝大多数 java.util 下的普通集合,都是线程不安全的。而它们的线程安全替代品是:Map 用 ConcurrentHashMap(首选)或 synchronizedMap;List 用 CopyOnWriteArrayList(读多写少)或 synchronizedList;计数用 AtomicInteger/LongAdder;字符串拼接用 StringBuffer。由此,我刻下一个关键认知:"碰巧没出事"绝不等于"线程安全"——并发 bug 极其隐蔽、测试难复现,往往要上了量、撞上特定时序才暴露。非线程安全容器不能跨线程共享读写;多线程共享,就用并发容器(ConcurrentHashMap),或加锁保护。
第二件事:正解——用 ConcurrentHashMap 或加锁保护
搞懂了原理,正解就清晰了:多线程共享的 Map,要么换成天生线程安全的并发容器,要么自己加锁保护。
// ✓ 正解一: 用 ConcurrentHashMap(首选, 高并发下性能也好)
public class Cache {
// ✓ 并发安全, 分段/CAS 实现, 多线程读写都没问题
private static final Map<String, Object> cache = new ConcurrentHashMap<>();
public static Object get(String key) { return cache.get(key); } // ✓ 安全
public static void put(String key, Object val) { cache.put(key, val); } // ✓ 安全
// ✓ 复合操作要用原子方法, 别"先 get 再 put"(那不原子!)
public static Object getOrCompute(String key, Function<String,Object> f) {
return cache.computeIfAbsent(key, f); // ✓ 原子: 不存在才计算并放入
}
}
// ✓ 正解二: Collections.synchronizedMap(简单, 但全表锁, 并发度低)
Map<String,Object> m = Collections.synchronizedMap(new HashMap<>());
// 注意: 遍历时仍需手动 synchronized(m){ for(...) } , 否则迭代不安全。
// ✓ 正解三: 自己加锁(适合更复杂的复合逻辑)
private final Object lock = new Object();
public void update(String k, Object v) {
synchronized (lock) { // ✓ 临界区互斥, 一次只有一个线程进
// ... 多步复合操作 ...
}
}
// 核心: 多线程共享的 Map 用 ConcurrentHashMap(首选);
// 复合操作用 computeIfAbsent 等原子方法, 别用"先查再改"的非原子组合。
修复的方向,清晰而坚定。正解一(首选):把 HashMap 换成 ConcurrentHashMap——它通过分段锁 / CAS 实现,多线程并发读写都安全,而且高并发下性能也好,是共享 Map 的不二之选。但这里有个极易踩的坑:仅仅"换成 ConcurrentHashMap"还不够——如果你的逻辑是"先 get 判断,再 put"这种复合操作,那两步之间,依然不是原子的,中间可能被别的线程插队!复合操作,必须用它提供的原子方法,比如 computeIfAbsent(不存在才计算并放入,整个过程原子)。正解二:Collections.synchronizedMap,简单,但它是全表锁、并发度低,而且遍历时仍需手动 synchronized 包裹,否则迭代不安全。正解三:对更复杂的复合逻辑,自己用 synchronized 加锁,把临界区保护起来。归根结底:多线程共享的 Map,用 ConcurrentHashMap;复合操作用 computeIfAbsent 等原子方法,别用"先查再改"那种非原子的组合拳。
第三件事:并发问题怎么排查——从 CPU 100% 到线程栈
这次能定位到死循环,靠的是一套排查并发问题的"组合拳",我把它沉淀了下来:
并发问题排查: CPU 飙高 / 卡死 → top 定位线程 → jstack 看栈
# 1. top 找到 CPU 高的进程, 再用 top -Hp 找到 CPU 高的"线程"
top -Hp 12345
# 记下那个 CPU 100% 的线程 id(十进制, 比如 12377)
# 2. 把线程 id 转成 16 进制(jstack 里是 16 进制 nid)
printf "%x\n" 12377 # → 305d
# 3. jstack 抓进程的所有线程栈, 搜那个 16 进制 id
jstack 12345 > stack.txt
# 在 stack.txt 里找 "nid=0x305d" → 看它卡在哪个方法
# 本次就是: 卡在 java.util.HashMap.get / getEntry 的循环里 → 真相大白
# 其他并发排查利器:
# - jstack 多抓几次对比: 总卡在同一处 = 死循环/死锁; 死锁 jstack 会直接报 "deadlock"。
# - arthas: thread -n 3 看最忙线程; thread -b 看死锁。
# - jstack 看到一堆线程 BLOCKED 在同一把锁 → 锁竞争热点。
# 经验:
# - CPU 100% 且降不下来 → 优先怀疑死循环(常见: HashMap 并发成环、正则回溯)。
# - 服务卡死但 CPU 不高 → 优先怀疑死锁 / 都在等锁/等 IO。
# 核心: top -Hp 定位高 CPU 线程 → 转 16 进制 → jstack 看它卡在哪,
# 并发问题的真相, 都写在线程栈里。
这套排查法,是这次事故给我最实用的收获。第一步,top -Hp <pid>,从 CPU 高的进程,进一步定位到 CPU 高的那个线程,记下它的线程 id;第二步,printf "%x" 把线程 id 转成 16 进制(因为 jstack 里的 nid 是 16 进制);第三步,jstack <pid> 抓下所有线程栈,在里面搜那个 16 进制 id,就能看到它到底卡在哪个方法——这次,正是卡在 HashMap.get 的循环里,真相大白。还有些利器:多抓几次 jstack 对比,总卡在同一处就是死循环或死锁(死锁 jstack 会直接报 deadlock);arthas 的 thread -n 看最忙线程、thread -b 看死锁;一堆线程 BLOCKED 在同一把锁,就是锁竞争热点。我也总结出经验:CPU 100% 且降不下来,优先怀疑死循环(常见的是 HashMap 并发成环、正则灾难性回溯);服务卡死但 CPU 不高,优先怀疑死锁、或都在等锁/等 IO。归根结底:top -Hp 定位高 CPU 线程 → 转 16 进制 → jstack 看它卡在哪;并发问题的真相,都写在线程栈里。
下面这张图,是这次 HashMap 并发死循环的来龙去脉:
第四件事:几种线程安全 Map 方案的取舍
修复时我没有无脑选,而是把几种线程安全的 Map 方案,横向比了一遍,按场景来挑。
| 方案 | 原理 | 并发性能 | 适用场景 |
|---|---|---|---|
| HashMap | 无任何同步 | 单线程最快 | ✗ 绝不能多线程共享读写 |
| ConcurrentHashMap | CAS + 桶级锁(JDK8) | 高, 读几乎无锁 | ✓ 高并发共享 Map 的首选 |
| Collections.synchronizedMap | 每个方法全表 synchronized | 低, 全表互斥 | 并发不高 / 临时加固老代码 |
| Hashtable | 方法级 synchronized(古老) | 低 | 遗留代码, 新项目别用 |
| 不可变 Map(Map.of/copyOf) | 只读不可改 | 无锁, 读极快 | ✓ 配置类只读数据 |
把它们排在一起,选择就一目了然了。高并发共享、读写都多,无脑选 ConcurrentHashMap——它读几乎无锁、写只锁单个桶,性能和安全兼得;并发不高、或只是想临时给老代码加固,Collections.synchronizedMap 够用,但要知道它是全表互斥、并发上不去;Hashtable 是古董(方法级 synchronized),新项目别再用;而如果你的数据是只读的配置,那最好的"线程安全"是根本不让它变——用 Map.of / Map.copyOf 做成不可变 Map,无锁、读极快、还杜绝了一切并发写问题。核心原则是:先问"这份数据会不会被多线程改"——不改就用不可变;要改且并发高,就 ConcurrentHashMap;唯独不能,把一个普通 HashMap,丢给多个线程去并发读写。
第五件事:那些"看起来安全、其实不安全"的并发陷阱
这次踩坑后,我顺藤摸瓜,把团队代码里那些"自以为安全、其实暗藏并发 bug"的写法,系统排查了一遍。它们比"裸用 HashMap"更隐蔽。
| 看似安全的写法 | 其实的问题 | 正确做法 |
|---|---|---|
| 用了 ConcurrentHashMap, 但"先 get 再 put" | 两步之间非原子, 仍会丢更新 | 用 computeIfAbsent / merge 等原子方法 |
| i++ / count++ 计数 | 读-改-写三步非原子, 会少计 | AtomicInteger / LongAdder |
| 单例里用普通字段做懒加载 | 多线程可能初始化多次/读到半成品 | 双重检查 + volatile / 静态内部类 |
| SimpleDateFormat 当静态共享 | 它本身线程不安全, 会解析错乱 | ThreadLocal / 改用 DateTimeFormatter |
| 检查再操作(check-then-act) | 检查和操作之间状态会变 | 整体加锁 / 用原子的复合操作 |
这张表,让我对并发的"暗礁"有了更清醒的认识。最坑的,是"用了线程安全容器,就以为万事大吉":ConcurrentHashMap 的单个方法是原子的,可你把"先 get 再 put"两个方法拼起来,中间的缝隙,照样会被并发钻空子、丢更新——得用 computeIfAbsent/merge 这类把复合操作打包成原子的方法。其余的陷阱也都同源:i++ 是读-改-写三步、非原子,要用 AtomicInteger;懒加载的双重检查,少了 volatile 就可能读到半成品对象;SimpleDateFormat 静态共享会解析错乱(用 ThreadLocal 或 DateTimeFormatter);"检查再操作"的 check-then-act,检查和操作之间状态会变,得整体加锁。它们的共性是:单个操作安全,不代表"多个操作的组合"安全——并发安全的真正难点,从来不在"用没用对容器",而在"有没有看清,哪些操作必须作为一个不可分割的整体来执行"。
第六件事:面对一个共享变量,我现在会怎么决策
现在,每当我准备让一个变量/容器,被多个线程访问,脑子里都会过一遍这张决策图——核心就一个问题:它会被并发地"改"吗?
这张图的灵魂,是那个必问的问题:这个要被多线程访问的东西,会被并发地"修改"吗?如果它只读、不改,那最好的安全,就是把它做成不可变,天然安全;如果它会被改,再分情况:简单的单值或计数,用 Atomic 系列 / volatile;集合,用并发容器(ConcurrentHashMap/CopyOnWriteArrayList);而集合还要再追问一句——有没有"先查再改、多步"的复合操作?有,就必须用原子方法或整体加锁;没有,并发容器的单个方法就够。最后,无论哪条路,我都会用 jstack / 压测,去验证它在并发下确实无死锁、无丢更新,才敢上线。
我立下的几条规矩
这场"CPU 烧满、服务卡死"的事故,换来了我写并发代码时,刻进骨子里的几条铁律:
- 普通集合(HashMap/ArrayList…)绝不跨线程共享读写。它们只为单线程设计;要共享,就换并发容器或加锁,没有例外。
- 多线程共享 Map,首选 ConcurrentHashMap。读几乎无锁、写锁单桶,安全与性能兼得;别再用 Hashtable。
- "先查再改"是非原子的,用原子方法。computeIfAbsent / merge / AtomicInteger,把复合操作打包成不可分割的整体。
- 能不可变就不可变。只读数据用 Map.of / List.of,从根上消灭并发写问题。
- "碰巧没出事"不等于线程安全。并发 bug 隐蔽,要主动用压测、并发测试去逼它现形,别等线上爆。
- CPU 100% 降不下,先 top -Hp + jstack。把高 CPU 线程转 16 进制,在线程栈里找它卡在哪,真相都在栈里。
- 共享变量前先问一句:它会被并发改吗?这一问,能挡掉绝大多数并发隐患。
附:一个能直观对比"丢更新"的最小例子
为了让团队对"非原子操作丢更新"有切肤之感,我写了个最小复现:多个线程并发往 Map 里累加计数,看普通 HashMap 和正确写法的差距,跑一遍胜过千言。
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class CountDemo {
public static void main(String[] args) throws Exception {
int threads = 10, perThread = 100000;
// ✗ 错误: 普通 HashMap + "先 get 再 put"(还可能直接抛异常/死循环)
Map<String,Integer> bad = new HashMap<>();
// ✓ 正确: ConcurrentHashMap + 原子的 merge
Map<String,Integer> good = new ConcurrentHashMap<>();
Runnable rBad = () -> { for (int i=0;i<perThread;i++)
bad.put("k", bad.getOrDefault("k",0) + 1); }; // ✗ 读-改-写非原子, 丢更新
Runnable rGood = () -> { for (int i=0;i<perThread;i++)
good.merge("k", 1, Integer::sum); }; // ✓ merge 原子累加
runAll(threads, rGood); // 先跑安全的(普通 HashMap 并发可能死循环, 演示放后面或单独跑)
System.out.println("期望 = " + (threads * perThread));
System.out.println("good(ConcurrentHashMap.merge) = " + good.get("k")); // ✓ 正好等于期望
// 注意: rBad 在并发下可能少计(丢更新), 甚至 CPU 100% 死循环 —— 这正是要避免的。
}
static void runAll(int n, Runnable r) throws Exception {
ExecutorService es = Executors.newFixedThreadPool(n);
CountDownLatch latch = new CountDownLatch(n);
for (int i=0;i<n;i++) es.submit(() -> { r.run(); latch.countDown(); });
latch.await(); es.shutdown();
}
}
// 核心: good 用 merge 原子累加, 结果永远 = 期望;
// bad 用"先 get 再 put", 并发下必然少计(丢更新), 甚至死循环。
这段代码,我建议每个写 Java 的同事都亲手跑一遍。用 ConcurrentHashMap.merge 的 good,结果永远精确等于 线程数 × 每线程次数 这个期望值;而用"先 get 再 put"的 bad,在并发下必然少计(因为两步之间被插队、丢了更新),数字会飘忽不定地小于期望,甚至(若用普通 HashMap)直接 CPU 100% 死循环。同样是"往 Map 里加个数",一个滴水不漏,一个悄悄丢数据——而最可怕的是,那个 bad 写法,编译能过、单线程跑也对、低并发时甚至也"看着对",它只在高并发的某些时刻,悄无声息地少算几个数;如果这"几个数"是钱、是库存、是计费,那就是实打实的资损。这,正是我想用这个最小例子,刻进每个同事脑子里的东西:并发下的正确,从来不能靠"看着对"来判断;它要靠你真正理解每一步操作是否原子,并用压测和精确的期望值比对,去把它验证出来。
写在最后
回头看,这场由一个最普通的 HashMap 引发的、把整台机器 CPU 烧满的事故,真正教给我的,是一个比"HashMap 线程不安全"本身更深的道理:并发的世界里,"能用"和"安全",是两件完全不同的事。那个 HashMap,在单线程下、在压力不大时,跑了很久都"能用",这恰恰是它最危险的地方——它用长时间的"看起来没事",麻痹了我,让我误以为它"没问题";直到流量上来、时序凑巧,它才露出獠牙,以最凶险的方式(死循环、烧 CPU)炸给我看。所以,写并发代码,最忌讳的就是"凭感觉"和"碰运气":你不能因为"测试时没复现"就认为它安全,因为并发 bug,本就是靠"特定的、罕见的时序"才触发的,测试碰不上,不代表线上的滔天流量碰不上。真正的并发安全,源于一种近乎偏执的清醒:对每一个会被多线程碰到的变量,都诚实地问一句"它会被并发修改吗、怎么保证正确",并给出一个经得起推敲的、而非"碰巧没事"的答案。把每一个共享变量,都当成一颗需要你亲手拆除引信的炸弹来对待——这,是我用一次"CPU 100%、服务卡死"的崩溃,换来的、关于并发编程最朴素、也最深刻的敬畏。如果这篇复盘,能让你在下一次把一个集合,丢给多个线程之前,多停三秒、多问一句,那我抓栈、熬的这大半天,就值了。
—— 别看了 · 2026