ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

一个 computeIfAbsent 嵌套调用,把单核打满并且永不退出:ConcurrentHashMap 的 4 个致命细节

一个 computeIfAbsent 嵌套调用,把单核打满并且永不退出:ConcurrentHashMap 的 4 个致命细节

title: 一个 computeIfAbsent 嵌套调用,把单核打满并且永不退出:ConcurrentHashMap 的 4 个致命细节
tags: [Java, 并发编程, ConcurrentHashMap, JDK源码, CAS]
category: Java 后端


一个永远跑不完的定时任务

我们有个配置解析服务,每 5 分钟拉一次规则中心的全量配置,解析成内存对象。JDK 8u202,服务跑了两年没出过事。

某个周四早上,监控告警说这个服务的 CPU 从常态 8% 涨到 27% 并且不再下来。看单核,有一个核心持续 100%。奇怪的是:接口正常响应、内存平稳、GC 正常、日志里那个定时任务的"开始解析"打印出来了,"解析完成"一直没打。

jstack抓了三次,同一个线程一直卡在同一个地方:

"config-refresh-1" #47 daemon prio=5 os_prio=0 tid=0x00007f8c... nid=0x2f13 runnable [0x00007f8b...] java.lang.Thread.State: RUNNABLE at java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1660) at com.xxx.config.RuleResolver.resolve(RuleResolver.java:52) at com.xxx.config.RuleResolver.lambda$resolve$0(RuleResolver.java:55) at java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1660) at com.xxx.config.RuleResolver.resolve(RuleResolver.java:52)

注意状态是RUNNABLE,不是BLOCKED——它在忙等,占着 CPU 空转。而且栈里computeIfAbsent出现了两次,中间夹着我们自己的resolve

那段递归解析的代码

规则之间有继承关系:规则 B 可以extends规则 A,解析 B 的时候要先把 A 解析出来。当时的写法是拿ConcurrentHashMap做解析结果的缓存,递归解析父规则:

public class RuleResolver { private final ConcurrentHashMap<String, Rule> cache = new ConcurrentHashMap<>(); public Rule resolve(String ruleId) { return cache.computeIfAbsent(ruleId, id -> { RawRule raw = ruleRepository.load(id); Rule parent = null; if (raw.getParentId() != null) { parent = resolve(raw.getParentId()); // 递归,又回到 computeIfAbsent } return Rule.build(raw, parent); }); } }

这段代码在 JDK 8 上跑了两年,一直相安无事。周四出事的原因是:运营那天新配了一条规则promo_v2,它的parentIdpromo_base,而promo_basepromo_v2这两个 key 恰好哈希到了同一个桶

同桶递归 = 死循环。

为什么同桶递归会死循环

先看computeIfAbsent的源码骨架(JDK 8u202,ConcurrentHashMap.java1660 行附近):

public V computeIfAbsent(K key, Function<? super K, ? extends V> mappingFunction) { if (key == null || mappingFunction == null) throw new NullPointerException(); int h = spread(key.hashCode()); V val = null; int binCount = 0; for (Node<K,V>[] tab = table;;) { // 关键:这是个无限 for Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); else if ((f = tabAt(tab, i = (n - 1) & h)) == null) { // 桶是空的,用 CAS 直接放一个占位节点,不加锁 Node<K,V> r = new ReservationNode<K,V>(); synchronized (r) { if (casTabAt(tab, i, null, r)) { binCount = 1; Node<K,V> node = null; try { if ((val = mappingFunction.apply(key)) != null) node = new Node<K,V>(h, key, val, null); } finally { setTabAt(tab, i, node); // 计算完才把真节点写回 } } } if (binCount != 0) break; } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); // 正在扩容,帮忙搬运 else { boolean added = false; synchronized (f) { // 桶不空,锁住桶首节点 if (tabAt(tab, i) == f) { // ... 遍历链表/红黑树,没找到就调 mappingFunction 计算并插入 } } if (binCount != 0) break; } } if (val != null) addCount(1L, binCount); return val; }

四个细节,逐个说:

细节一:空桶用ReservationNode占位。第一次解析promo_v2时,它的桶是空的,走到第 12 行。CHM 会 new 一个ReservationNode(哈希值为RESERVED = -3),synchronized (r)锁住它,CAS 塞进桶里,然后才调用mappingFunction.apply(key)。也就是说:计算函数是在持有桶锁的情况下执行的,而此时桶里躺着一个哈希值为 -3 的占位节点。

细节二:递归进来时找不到匹配,也不满足任何插入分支。递归调用resolve("promo_base")时,因为两个 key 同桶,tabAt(tab, i)拿到的不是 null,而是那个ReservationNode。于是走到第 30 行的else分支,synchronized (f)——这里f就是ReservationNode,而当前线程已经持有它的锁(synchronized 可重入),所以不会阻塞,能进去。进去之后遍历:ReservationNode的 hash 是 -3,既不等于promo_base的 hash,也不是链表节点(fh >= 0不成立),也不是TreeBinf instanceof TreeBin不成立)。结果就是什么分支都没命中,binCount还是 0

细节三:binCount == 0就不 break,回到for (;;)重头再来。看第 27 行和第 35 行,只有binCount != 0才跳出循环。既然什么都没做,binCount恒为 0,这个 for 循环就永远转下去。线程状态是RUNNABLE,单核跑满 100%,永不退出。

细节四:这不是死锁,是活锁。死锁至少jstack会检测出来并打印 "Found one Java-level deadlock"。活锁不会——它看起来像在正常工作,只是永远做不完。这也是为什么我们的监控没有任何一条规则命中:CPU 高不到告警阈值(27% 总体),线程数正常,没有异常日志。

这个问题是 JDK 官方 bug JDK-8062841,在 JDK 9 中被修复。修复方式不是让它能正常工作,而是让它快速失败

// JDK 9+ 的 computeIfAbsent,else 分支里多了这么一段 else if (f instanceof ReservationNode) throw new IllegalStateException("Recursive update");

我后来在 JDK 11.0.16 上跑同样的代码,立刻抛IllegalStateException: Recursive update,栈很清楚,两分钟就能定位。这也是我一直主张新项目至少从 JDK 11 起步的一个具体理由——不是为了新语法,是为了这类"把隐性死循环变成显性异常"的修复。

我们最开始查的是 GC 和死锁

回顾排查过程,有两个小时是浪费掉的。

第一个错误方向:以为是 GC。单核 100%,第一反应是某个 GC 线程在空转。看 GC 日志,Young GC 每 40 秒一次、耗时 12ms,Full GC 一次都没有。jstat -gcutil也正常。排除掉花了 25 分钟。

第二个错误方向:找死锁。jstack输出的末尾没有 deadlock 段落,但我们还是手工比对了所有BLOCKED线程的锁持有关系,确认没有环。这一步花了 40 分钟,纯属浪费——因为它压根不是死锁。

真正有用的一步:连续抓三次 jstack,比对同一个线程的栈。三次的栈完全一致,且是RUNNABLE状态卡在 JDK 内部方法上。这个组合几乎只有一种可能:JDK 内部的循环没能退出。搜computeIfAbsent+RUNNABLE+无限循环,第一条就是那个 JDK bug。

经验固化:RUNNABLE状态 + 栈不动 + CPU 单核满 = 找活锁,不要找死锁。死锁看BLOCKED,活锁看RUNNABLE。这两个的排查路径完全不同。

四种改法

方案是否根治递归问题线程安全性能适用场景
A. 升级到 JDK 9+否(变成抛异常,问题仍在)不变只能算"让问题可见"
B. 递归改迭代,先展平继承链再填 map最好我们的选择
C. 换成Collections.synchronizedMap是(无桶锁概念)差,全局锁低并发场景可用
D. 双 map:读用 CHM,写先算好再 putIfAbsent计算耗时长时更优

我们选 B,把递归展开成两阶段:

public class RuleResolver { private final ConcurrentHashMap<String, Rule> cache = new ConcurrentHashMap<>(); public Rule resolve(String ruleId) { Rule cached = cache.get(ruleId); // 快路径:直接读,无锁 if (cached != null) { return cached; } // 慢路径:先把整条继承链展平,全程不碰 map Deque<RawRule> chain = new ArrayDeque<>(); Set<String> visited = new HashSet<>(); // 防止配置里出现环 String cur = ruleId; while (cur != null) { if (!visited.add(cur)) { throw new IllegalStateException("rule inherit cycle detected at: " + cur); } RawRule raw = ruleRepository.load(cur); chain.push(raw); // 压栈,出栈时就是从祖先到子孙的顺序 cur = raw.getParentId(); } // 从最顶层祖先开始,逐层构建;每次只做一个 key 的 putIfAbsent,不嵌套 Rule parent = null; Rule result = null; while (!chain.isEmpty()) { RawRule raw = chain.pop(); Rule built = Rule.build(raw, parent); // putIfAbsent 里没有用户代码,不存在递归风险 Rule prev = cache.putIfAbsent(raw.getId(), built); result = (prev != null) ? prev : built; parent = result; } return result; } }

改动的核心思路只有一句:不要在computeIfAbsent的 lambda 里碰同一个 map。展平之后,每次 map 操作都是原子的单 key 操作,中间不夹用户代码。

顺带加了两个之前没有的东西:

  • visited集合检测继承环。原来的递归版本如果配置里出现 A→B→A,会栈溢出。展平版本会抛一个业务语义清晰的异常,运营在配置页面就能看到错在哪。
  • putIfAbsent的返回值判断。并发场景下可能两个线程同时构建同一条链,putIfAbsent返回非 null 说明别人先放进去了,用别人的那份,保证同一个 ruleId 全局只有一个 Rule 实例。

顺便说说 CHM 从 JDK 7 到 8 到底改了什么

既然聊到桶锁,把演进也捋一遍,这是面试高频但很多人只记得"分段锁没了":

维度JDK 7JDK 8+
数据结构Segment 数组 + HashEntry 数组 + 链表Node 数组 + 链表 + 红黑树
锁粒度Segment(默认 16 段,一段锁住一批桶)单个桶的首节点
锁实现Segment 继承 ReentrantLocksynchronized+ CAS
并发度固定 16(由 concurrencyLevel 决定,之后不变)等于桶数量,随扩容增长
空桶写入需要加锁CAS 无锁写入(casTabAt
size()遍历 Segment 求和,重试 3 次不行就全锁baseCount+CounterCell[]分散计数
链表转树链表长度 ≥8 且表长 ≥64 转红黑树

有两点经常被误解,我在评审里纠正过不止一次:

"JDK 8 用 synchronized 是性能倒退"——不是。JDK 6 之后synchronized有偏向锁/轻量级锁优化,无竞争时开销极低;而且这里锁的是单个桶的首节点,竞争概率远低于 JDK 7 的段锁。真正的收益在空桶 CAS 写入:新 key 落到空桶时完全不加锁,这在稀疏 map 上快得多。

"链表长度到 8 就转红黑树"——不完整。还有个条件:表长必须 ≥64。看treeifyBin源码:

private final void treeifyBin(Node<K,V>[] tab, int index) { Node<K,V> b; int n; if (tab != null) { if ((n = tab.length) < MIN_TREEIFY_CAPACITY) // MIN_TREEIFY_CAPACITY = 64 tryPresize(n << 1); // 表太小,先扩容而不是转树 else if ((b = tabAt(tab, index)) != null && b.hash >= 0) { // ... 真正转成 TreeBin } } }

表长小于 64 时,宁可扩容也不转树——因为小表上链表长纯粹是容量不够导致的,扩容一下就散开了,转树反而增加内存和维护成本。

复盘数字

指标事故时修复后
配置刷新任务耗时永不结束(卡死 6 小时后重启)平均 340ms(1200 条规则)
单核 CPU 占用100%(持续)峰值 11%,瞬时
服务整体 CPU27%8%(恢复常态)
配置生效延迟无穷大(配置卡在旧版本 6 小时)≤5 分钟(一个刷新周期)
继承环配置的表现StackOverflowError明确异常 + 告警,运营可自查

有一个隐性损失当时没算进去:那 6 小时里,所有依赖这个服务的下游拿到的都是旧配置。因为解析线程卡住了,但缓存里的老数据还在,读接口一切正常。运营那天上线的促销规则实际上一直没生效,是业务方来问"为什么活动没开始"才被发现的。"服务看起来是好的"比"服务挂了"更危险,这句话我现在写在团队 wiki 的第一页。

我的判断

computeIfAbsent的 lambda 里,不要做三件事:不要访问同一个 map(递归风险)、不要做 IO/RPC(会长时间持有桶锁,阻塞同桶的其他 key)、不要抛业务异常(异常会让占位节点被清理,但你的重试逻辑可能没考虑这一点)。这三条我们已经写进 code review checklist。

CHM 当本地缓存要慎重。它没有过期、没有容量上限、没有淘汰策略。我见过太多"先用 CHM 顶一下"最后顶成 OOM 的。需要缓存语义就直接上 Caffeine,Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(10, MINUTES)三行的事,而且它的get(key, mappingFunction)内部也做了递归保护。

什么时候 CHM 仍是最优解:key 集合有界且已知(比如枚举、配置项、类型注册表)、只增不删、读远多于写。这种场景下 CHM 的读性能是无锁的,任何缓存框架都比不过。

JDK 8 该升了。这个 bug 只是众多例子之一。如果实在升不动大版本,至少要知道自己踩在哪些已知坑上——把项目里所有computeIfAbsent搜一遍,检查 lambda 里有没有碰同一个 map,这件事十分钟就能做完。

思考题

  1. 如果把示例中的computeIfAbsent换成merge,同样的递归调用会发生什么?源码上有区别吗?
  2. ReservationNode的 hash 值是RESERVED = -3。CHM 里还有MOVED = -1(ForwardingNode)和TREEBIN = -2。为什么这些特殊节点都用负数?普通节点的 hash 为什么一定是非负的?
  3. 上面的展平方案里,两个线程同时解析同一条继承链会重复Rule.build。如果build很贵(比如要编译表达式),该怎么改才能既避免递归又避免重复计算?

你在 JDK 8 上还踩过哪些"升级到 9 就消失"的坑?评论区见。

返回列表