ConcurrentHashMap.computeIfAbsent 嵌套调用导致 CPU 100% 的真实事故复盘

线上 Java 服务 CPU 100% 全线程卡死,jstack 看全堆在 ConcurrentHashMap.computeIfAbsent。本文讲清楚桶级 synchronized 嵌套调用的活锁原理 + 最小复现 + 5 种修法 + JMH 性能对比 + 排查 checklist。结论:缓存层一律用 Caffeine。

线上 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%"先看:

  1. jstack 看是不是有线程栈两次出现 ConcurrentHashMap.computeIfAbsent → 99% 是嵌套调用
  2. 看 mapping function 里有没有调 map 自己(直接或间接)
  3. 有没有跨 map 嵌套(可能锁顺序死锁)
  4. JDK 版本:JDK 8 是无限循环,JDK 11+ 是 IllegalStateException(但只有同 key 才报)
  5. 有没有用 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

团队规范我们最后落地这两条

  1. 缓存层一律用 Caffeine,不允许直接用 ConcurrentHashMap 当缓存
  2. code review 自动检查:computeIfAbsent 的 mapping function 里不能出现任何 cache / map 关键字

规则简单粗暴,但落地之后这类 bug 一年没再出现。Caffeine 性能比裸 CHM 还好一截,迁移代价几乎为 0,实在没理由不换。如果你的项目还在用 ConcurrentHashMap.computeIfAbsent 做缓存,值得花一下午全局替换掉。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

MongoDB 副本集 primary 切换丢了 12 个订单的复盘:writeConcern 必须 majority

2026-5-19 10:52:51

技术教程

雪花算法时钟回拨导致主键冲突的事故复盘:4 种工程化修法对比

2026-5-19 11:17:56

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索