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 prio5 os_prio0 tid0x00007f8c... nid0x2f13 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 ConcurrentHashMapString, 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它的parentId是promo_base而promo_base和promo_v2这两个 key 恰好哈希到了同一个桶。同桶递归 死循环。为什么同桶递归会死循环先看computeIfAbsent的源码骨架JDK 8u202ConcurrentHashMap.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 (NodeK,V[] tab table;;) { // 关键这是个无限 for NodeK,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 直接放一个占位节点不加锁 NodeK,V r new ReservationNodeK,V(); synchronized (r) { if (casTabAt(tab, i, null, r)) { binCount 1; NodeK,V node null; try { if ((val mappingFunction.apply(key)) ! null) node new NodeK,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 -3synchronized (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 的 computeIfAbsentelse 分支里多了这么一段 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 秒一次、耗时 12msFull GC 一次都没有。jstat -gcutil也正常。排除掉花了 25 分钟。第二个错误方向找死锁。jstack输出的末尾没有 deadlock 段落但我们还是手工比对了所有BLOCKED线程的锁持有关系确认没有环。这一步花了 40 分钟纯属浪费——因为它压根不是死锁。真正有用的一步连续抓三次 jstack比对同一个线程的栈。三次的栈完全一致且是RUNNABLE状态卡在 JDK 内部方法上。这个组合几乎只有一种可能JDK 内部的循环没能退出。搜computeIfAbsentRUNNABLE无限循环第一条就是那个 JDK bug。经验固化RUNNABLE状态 栈不动 CPU 单核满 找活锁不要找死锁。死锁看BLOCKED活锁看RUNNABLE。这两个的排查路径完全不同。四种改法方案是否根治递归问题线程安全性能适用场景A. 升级到 JDK 9否变成抛异常问题仍在是不变只能算让问题可见B. 递归改迭代先展平继承链再填 map是是最好我们的选择C. 换成Collections.synchronizedMap是无桶锁概念是差全局锁低并发场景可用D. 双 map读用 CHM写先算好再 putIfAbsent是是好计算耗时长时更优我们选 B把递归展开成两阶段public class RuleResolver { private final ConcurrentHashMapString, Rule cache new ConcurrentHashMap(); public Rule resolve(String ruleId) { Rule cached cache.get(ruleId); // 快路径直接读无锁 if (cached ! null) { return cached; } // 慢路径先把整条继承链展平全程不碰 map DequeRawRule chain new ArrayDeque(); SetString 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 无锁写入casTabAtsize()遍历 Segment 求和重试 3 次不行就全锁baseCountCounterCell[]分散计数链表转树无链表长度 ≥8 且表长 ≥64 转红黑树有两点经常被误解我在评审里纠正过不止一次JDK 8 用 synchronized 是性能倒退——不是。JDK 6 之后synchronized有偏向锁/轻量级锁优化无竞争时开销极低而且这里锁的是单个桶的首节点竞争概率远低于 JDK 7 的段锁。真正的收益在空桶 CAS 写入新 key 落到空桶时完全不加锁这在稀疏 map 上快得多。链表长度到 8 就转红黑树——不完整。还有个条件表长必须 ≥64。看treeifyBin源码private final void treeifyBin(NodeK,V[] tab, int index) { NodeK,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 小时后重启平均 340ms1200 条规则单核 CPU 占用100%持续峰值 11%瞬时服务整体 CPU27%8%恢复常态配置生效延迟无穷大配置卡在旧版本 6 小时≤5 分钟一个刷新周期继承环配置的表现StackOverflowError明确异常 告警运营可自查有一个隐性损失当时没算进去那 6 小时里所有依赖这个服务的下游拿到的都是旧配置。因为解析线程卡住了但缓存里的老数据还在读接口一切正常。运营那天上线的促销规则实际上一直没生效是业务方来问为什么活动没开始才被发现的。服务看起来是好的比服务挂了更危险这句话我现在写在团队 wiki 的第一页。我的判断computeIfAbsent的 lambda 里不要做三件事不要访问同一个 map递归风险、不要做 IO/RPC会长时间持有桶锁阻塞同桶的其他 key、不要抛业务异常异常会让占位节点被清理但你的重试逻辑可能没考虑这一点。这三条我们已经写进 code review checklist。CHM 当本地缓存要慎重。它没有过期、没有容量上限、没有淘汰策略。我见过太多先用 CHM 顶一下最后顶成 OOM 的。需要缓存语义就直接上 CaffeineCaffeine.newBuilder().maximumSize(10_000).expireAfterWrite(10, MINUTES)三行的事而且它的get(key, mappingFunction)内部也做了递归保护。什么时候 CHM 仍是最优解key 集合有界且已知比如枚举、配置项、类型注册表、只增不删、读远多于写。这种场景下 CHM 的读性能是无锁的任何缓存框架都比不过。JDK 8 该升了。这个 bug 只是众多例子之一。如果实在升不动大版本至少要知道自己踩在哪些已知坑上——把项目里所有computeIfAbsent搜一遍检查 lambda 里有没有碰同一个 map这件事十分钟就能做完。思考题如果把示例中的computeIfAbsent换成merge同样的递归调用会发生什么源码上有区别吗ReservationNode的 hash 值是RESERVED -3。CHM 里还有MOVED -1ForwardingNode和TREEBIN -2。为什么这些特殊节点都用负数普通节点的 hash 为什么一定是非负的上面的展平方案里两个线程同时解析同一条继承链会重复Rule.build。如果build很贵比如要编译表达式该怎么改才能既避免递归又避免重复计算你在 JDK 8 上还踩过哪些升级到 9 就消失的坑评论区见。
返回列表