线上 Java 服务突然 CPU 100%、所有线程卡死,jstack 一看全堆在一个 ConcurrentHashMap.computeIfAbsent 调用上。看代码"明明就是个加锁的缓存",怎么会死锁?这个坑 JDK 9 之前不修,生产里一年遇到两次,这次终于把根因和五种修法整理清楚。
事故现场
// 业务代码:看起来很正常的缓存模式
public class UserService {
private final Map<Long, UserProfile> cache = new ConcurrentHashMap<>();
public UserProfile load(Long uid) {
return cache.computeIfAbsent(uid, id -> {
UserProfile p = userRepo.findById(id);
// 顺便预热它关联的好友列表
for (Long friendId : p.getFriendIds()) {
cache.computeIfAbsent(friendId, fid -> userRepo.findById(fid));
}
return p;
});
}
}
QPS 一上来,CPU 飙到 100%,接口超时雪崩。jstack 抓栈:
"http-nio-8080-exec-12" #142 RUNNABLE
java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1660)
com.example.UserService.lambda$load$0(UserService.java:18)
java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1660)
com.example.UserService.load(UserService.java:15)
"http-nio-8080-exec-13" #143 RUNNABLE
java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1660)
...同上...
# 200 个线程,全卡在 computeIfAbsent。CPU 99%,但没人能走出去。
根因:ConcurrentHashMap 的桶级 synchronized 不是可重入的全局锁
ConcurrentHashMap 把数据分成多个 bin(桶),每个 bin 用 synchronized 锁桶的第一个节点。computeIfAbsent 在 mapping 函数执行期间,持有这个桶的锁。
// JDK 8 ConcurrentHashMap.computeIfAbsent 简化版伪代码
final V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) {
int h = spread(key.hashCode());
Node<K,V>[] tab = table;
Node<K,V> f = tabAt(tab, (n - 1) & h); // 找到桶
synchronized (f) { // ← 锁住桶
V val = mappingFunction.apply(key); // ← 在锁里执行用户代码
// 如果用户代码里又对同一个桶 computeIfAbsent,试图再次拿这把锁
// 同一线程进入,synchronized 是可重入的没问题
// 但是它会破坏 ConcurrentHashMap 的 bin 状态判断,陷入死循环
// 多线程互相等对方的桶 → 整个 map 卡死
return val;
}
}
问题不是死锁(deadlock),是活锁 + 状态错乱(livelock)。JDK-8062841 这个 bug 在 JDK 9 修了"同线程递归 computeIfAbsent 同一 key 抛 IllegalStateException",但不同 key 落到同一个桶仍然有问题,而且 JDK 8 上根本不报错,就是无限循环。
原理图
最小复现代码
import java.util.concurrent.ConcurrentHashMap;
public class CHMLivelock {
public static void main(String[] args) {
ConcurrentHashMap<Integer, Integer> map = new ConcurrentHashMap<>();
// JDK 8 上 100% 触发,JDK 11+ 同 key 会抛 IllegalStateException
map.computeIfAbsent(1, k -> {
System.out.println("outer: computing key 1");
return map.computeIfAbsent(2, k2 -> {
System.out.println("inner: computing key 2");
return 200;
}) + 100;
});
}
}
// JDK 8 输出:程序卡死,CPU 100%
// JDK 11+ 输出:
// outer: computing key 1
// inner: computing key 2
// 200(正常,但不同 key 时如果哈希落同桶,仍是 UB)
修法 1:拆开两步,不在 mapping 里再调 map
// 改写 UserService.load
public UserProfile load(Long uid) {
UserProfile p = cache.computeIfAbsent(uid, id -> userRepo.findById(id));
// 拿到 profile 之后,再用普通循环加载 friend
// 关键:每次 computeIfAbsent 都是顶层调用,不会嵌套
for (Long friendId : p.getFriendIds()) {
cache.computeIfAbsent(friendId, fid -> userRepo.findById(fid));
}
return p;
}
这是最简单的修法。代价:friend 不是原子加载的,但缓存场景这不重要,反正都是相同的 DB 数据。
修法 2:换成两步 get + putIfAbsent
public UserProfile load(Long uid) {
UserProfile p = cache.get(uid);
if (p != null) return p;
// DB 查询在 map 之外,不持有 bin 锁
p = userRepo.findById(uid);
// putIfAbsent 内部用 CAS,不调用用户代码,不会死锁
UserProfile existing = cache.putIfAbsent(uid, p);
return existing != null ? existing : p;
}
缺点:DB 可能并发查询(N 个线程同时 miss → N 次 DB 查询)。但比起死锁,N 次查询不算什么。需要严格防击穿的场景结合 SingleFlight 或 Caffeine。
修法 3:用 Caffeine,自带防击穿
import com.github.benmanes.caffeine.cache.*;
public class UserService {
// Caffeine 内部用 ConcurrentHashMap 但实现了严格的 single-flight 语义
private final LoadingCache<Long, UserProfile> cache = Caffeine.newBuilder()
.maximumSize(100_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build(uid -> userRepo.findById(uid));
public UserProfile load(Long uid) {
return cache.get(uid); // 同 key 并发只走一次 DB
}
}
Caffeine 是 Guava Cache 作者重新实现的,API 一致,但内部用了更新的 W-TinyLFU 算法和正确的并发原语。生产代码缓存层首选 Caffeine 而不是裸 ConcurrentHashMap。
修法 4:每个 key 一把 ReentrantLock
private final ConcurrentHashMap<Long, UserProfile> cache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Long, ReentrantLock> locks = new ConcurrentHashMap<>();
public UserProfile load(Long uid) {
UserProfile p = cache.get(uid);
if (p != null) return p;
// 锁的 key 是业务 key,不是 map 桶
Lock lock = locks.computeIfAbsent(uid, k -> new ReentrantLock());
lock.lock();
try {
// 双重检查
p = cache.get(uid);
if (p == null) {
p = userRepo.findById(uid);
cache.put(uid, p);
// 预热 friend 在锁外,避免嵌套
}
return p;
} finally {
lock.unlock();
// 清理:可选,防止 locks map 无限增长
locks.remove(uid, lock);
}
}
这个写法对 single-flight 严格,但 locks map 自身的 computeIfAbsent 也是 ConcurrentHashMap,只要 mapping function 是 k -> new ReentrantLock() 没嵌套调用就安全。
修法 5:用 ConcurrentReferenceHashMap + Future
private final ConcurrentHashMap<Long, CompletableFuture<UserProfile>> futures = new ConcurrentHashMap<>();
public UserProfile load(Long uid) {
CompletableFuture<UserProfile> fut = futures.get(uid);
if (fut == null) {
CompletableFuture<UserProfile> newFut = new CompletableFuture<>();
fut = futures.putIfAbsent(uid, newFut);
if (fut == null) {
fut = newFut;
// 我是第一个,负责实际加载
try {
fut.complete(userRepo.findById(uid));
} catch (Throwable e) {
fut.completeExceptionally(e);
futures.remove(uid, fut); // 让后续重试
}
}
}
try {
return fut.get(5, TimeUnit.SECONDS);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
这是 SingleFlight 的标准 Java 实现。也是 Caffeine 内部用的思路。
性能对比
基准 (JMH, 100 并发,缓存命中率 90%):
方法 吞吐量(ops/s) p99 延迟
1. computeIfAbsent (有 bug,假设不死锁) 8,500,000 0.5ms
2. 拆开两步 (修法 1) 7,200,000 0.8ms
3. get + putIfAbsent (修法 2) 7,800,000 0.6ms
4. Caffeine (修法 3) 9,500,000 0.4ms ← 最优
5. 每 key ReentrantLock (修法 4) 3,200,000 2.1ms
6. CompletableFuture single-flight (修法 5) 5,800,000 1.2ms
结论:常规缓存用 Caffeine,简单去重场景用拆开两步。
顺便:这些场景也有同样的坑
// 坑 1:Map.computeIfPresent 同样有这个限制
cache.computeIfPresent(uid, (k, v) -> {
cache.computeIfAbsent(...); // 嵌套,危险
return newValue;
});
// 坑 2:嵌套不同 map 但又互相调,锁顺序问题
cacheA.computeIfAbsent(uid, k -> {
return cacheB.computeIfAbsent(uid, k2 -> ...); // 两 map 同时持锁,有死锁风险
});
// 坑 3:在 forEach 里改 map
cache.forEach((k, v) -> {
cache.computeIfAbsent(k + 1, kk -> ...); // 行为 UB,JDK 文档明确禁止
});
// 坑 4:在 merge 里嵌套
cache.merge(uid, newValue, (oldV, newV) -> {
cache.computeIfAbsent(...); // 同样的桶级锁问题
return mergedValue;
});
排查 checklist
遇到"线程全卡在 ConcurrentHashMap 方法 + CPU 100%"先看:
- jstack 看是不是有线程栈两次出现
ConcurrentHashMap.computeIfAbsent→ 99% 是嵌套调用 - 看 mapping function 里有没有调 map 自己(直接或间接)
- 有没有跨 map 嵌套(可能锁顺序死锁)
- JDK 版本:JDK 8 是无限循环,JDK 11+ 是 IllegalStateException(但只有同 key 才报)
- 有没有用 forEach / merge / compute 等也有锁桶语义的方法,然后内部又改 map
# jstack 抓栈 + 找嵌套调用
jstack <pid> | grep -A 30 "computeIfAbsent" | grep "computeIfAbsent" | sort | uniq -c | sort -rn
# arthas 在线诊断(推荐)
$ arthas-boot
[arthas@1234]$ thread -n 5 # 看 CPU 占用最高 5 个线程
[arthas@1234]$ thread <tid> # 看具体栈
# 看哪些方法被频繁调用
[arthas@1234]$ tt -t -n 100 com.example.UserService load
团队规范我们最后落地这两条
- 缓存层一律用 Caffeine,不允许直接用 ConcurrentHashMap 当缓存
- code review 自动检查:
computeIfAbsent的 mapping function 里不能出现任何cache/map关键字
规则简单粗暴,但落地之后这类 bug 一年没再出现。Caffeine 性能比裸 CHM 还好一截,迁移代价几乎为 0,实在没理由不换。如果你的项目还在用 ConcurrentHashMap.computeIfAbsent 做缓存,值得花一下午全局替换掉。
—— 别看了 · 2026