ConcurrentHashMap 在 JDK 8 砍掉分段锁后真的更快吗:一次 size() 把接口拖慢 300ms 的复盘
引子我们网关有个任务分发接口用ConcurrentHashMap存待处理任务。代码逻辑大概是每次请求进来先看map.size() 0有没有活儿有就取一个处理。上线后压测单接口 P99 居然有 320ms可业务逻辑本身很轻——就是一个map.get()加一次 DB 写入。把火焰图一拉时间全花在ConcurrentHashMap.size()上。那一刻我意识到我一直以为 CHM 的size()是 O(1) 的结果它根本不是。这篇文章就聊聊 CHM 在 JDK 7 到 JDK 8 的演进分段锁为什么被砍、size()为什么不是 O(1)以及我们那 300ms 到底是怎么没的。问题分段锁到底解决了什么又坑了什么JDK 7 的ConcurrentHashMap内部是一个Segment数组每个Segment本身是一把ReentrantLock底下包着一个HashEntry数组。写操作只锁所在的那个 Segment所以理论上 16 个 Segment 能支持 16 个线程同时写——这就是分段锁的核心思想把一把大锁拆成多把小锁降低竞争。但分段锁的问题也很明显并发度被 Segment 数量钉死。你初始化时定了 16 个 Segment以后最多 16 路写并发扩不了。很多操作要锁多个 Segment 才能干。最典型的就是size()它得依次锁住所有 Segment、求和、再解锁期间其他写操作被整体卡住。内存上每个 Segment 都是独立的哈希表结构开销大、而且大部分时候用不满。JDK 8 干脆把分段锁整个删了改成了数组 链表/红黑树锁的粒度从Segment降到单个桶的头节点空桶甚至用 CAS 无锁插入。这是一次彻底的重构。原理一JDK 8 的 put 怎么做到尽量无锁看putVal的核心片段简化自 JDK 8 源码final V putVal(K key, V value, boolean onlyIfAbsent) { if (key null || value null) throw new NullPointerException(); int hash spread(key.hashCode()); int binCount 0; for (NodeK,V[] tab table;;) { NodeK,V f; int n, i, fh; if (tab null || (n tab.length) 0) tab initTable(); // ① 延迟初始化第一次插入才建表 else if ((f tabAt(tab, i (n - 1) hash)) null) { if (casTabAt(tab, i, null, new Node(hash, key, value))) break; // ② 桶为空直接 CAS 无锁放进去 } else if ((fh f.hash) MOVED) tab helpTransfer(tab, f); // ③ 正在扩容当前线程顺手帮忙迁移 else { V oldVal null; synchronized (f) { // ④ 桶非空只锁桶头这一个 Node if (tabAt(tab, i) f) { // 遍历链表或红黑树插入 / 覆盖 } } } } addCount(1L, binCount); // ⑤ 计数 可能触发扩容 return null; }逐行解释第 4 行spread(key.hashCode())做一次扰动把高位也揉进低位减少哈希冲突——CHM 不允许null键和值这里直接抛 NPE。第 8 行initTable()是延迟初始化表不是构造时建的是第一次put才建避免空 map 也占一大块内存。第 11 行casTabAt(..., null, newNode)是精髓桶是空的多个线程同时来只有一个能 CAS 成功其余重试。这一步完全无锁比 JDK 7 锁 Segment 轻得多。第 16 行synchronized (f)只在桶非空、需要遍历链表/树时才锁而且是锁f这个桶头节点——不同桶之间互不影响并发度等于桶的数量默认 16可远超 16 路。第 17 行helpTransfer体现 CHM 的扩容是多线程协作的发现正在扩容当前线程不是干等而是帮忙搬数据所以 CHM 扩容比 HashMap 单线程搬快很多。原理二size() 为什么不是 O(1)这是我那 300ms 的根源。JDK 8 的size()长这样public int size() { long n sumCount(); return ((n 0L) ? 0 : (n (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); } final long sumCount() { CounterCell[] as counterCells; long sum baseCount; // ① 基础计数 if (as ! null) { for (CounterCell a : as) // ② 遍历所有分段计数器累加 if (a ! null) sum a.value; } return sum; }逐行解释第 9 行baseCount是没有竞争时的总计数。但高并发下大家都去 CAS 改baseCount会撞车所以 CHM 引入了CounterCell[]——把计数分摊到多个 cell上每个线程改自己的 cell思路和LongAdder的分段思想一模一样。第 11 行for (CounterCell a : as)是重点size()要把baseCount和所有CounterCell 遍历一遍求和。当 map 很大、并发很高、CounterCell 数组被撑开到几十个时这就是一次 O(分段数) 的遍历不是 O(1)。更隐蔽的是sumCount()读的是某个瞬间的快照遍历过程中别的线程还在改计数所以它返回的是个近似值而且它和put的计数 CAS 会互相竞争 CPU。所以 CHM 的size()是为了高并发写入的吞吐牺牲了 size 的精确性和速度——这是设计取舍不是 bug。文档也明确写了size()返回的是估计值mappingCount 同理。原理三computeIfAbsent 的递归坑CHM 还有个名声在外的坑和computeIfAbsent有关ConcurrentHashMapString, ListString map new ConcurrentHashMap(); // 危险写法value 计算函数里又访问了同一个 map map.computeIfAbsent(A, k - { // JDK 8 中这里再调本 map 的 computeIfAbsent 会递归获取同一把桶锁 // 可能死锁或抛出 IllegalStateException: Recursive update return map.computeIfAbsent(B, k2 - new ArrayList()); });逐行解释第 4 行computeIfAbsent(A, ...)在 JDK 8 里会先锁住 A 所在的桶然后执行 value 计算函数。第 6 行在 value 函数里又对同一个 map 调computeIfAbsent(B, ...)JDK 8 没有做递归保护会试图再拿锁可能死锁或抛Recursive update异常。这个坑在JDK 9 才修复改成检测递归并抛更明确的异常 / 部分场景规避。如果你还在 JDK 8 上跑value 函数里绝对不要再访问同一个 CHM。回到那 300ms我们当时的代码脱敏后长这样private final ConcurrentHashMapLong, Task pending new ConcurrentHashMap(); public Task pollTask() { if (pending.size() 0) { // ① 问题在这每次请求都调 size() // 取一个任务处理 return pending.values().stream().findAny().orElse(null); } return null; }压测时pending里常驻几十万个 key每次请求都走size()遍历所有 CounterCell 求和单次 300ms 左右而且size()的计数 CAS 还和put抢资源越压越慢。修复方式很朴素——别让 CHM 替你数数private final ConcurrentHashMapLong, Task pending new ConcurrentHashMap(); private final AtomicLong pendingCount new AtomicLong(0); // 自己维护计数 public void addTask(Task t) { pending.put(t.id, t); pendingCount.incrementAndGet(); } public Task pollTask() { if (pendingCount.get() 0) { // O(1)不再遍历 map // 取任务 Task t pending.values().stream().findAny().orElse(null); if (t ! null) pendingCount.decrementAndGet(); return t; } return null; }改完之后接口 P99 从 320ms 直接掉到 18ms。size()那次调用本质上是在热路径上用了一个为吞吐牺牲精度的近似统计方法去做精确判定方向就错了。我的取舍判断JDK 8 的 CHM 比 JDK 7 快吗在大数是场景确实更快。锁粒度从 Segment默认 16降到单个桶空桶还能无锁 CAS并发度不再被 Segment 数钉死。但更快是有前提的——你的写操作得分散在多个桶上。如果所有 key 都哈希到一个桶哈希函数设计差那还是退化成一把锁。别在热路径调 size() / mappingCount()它们是求和不是 O(1)而且返回的是近似值。需要还剩多少这种判定自己用AtomicLong维护。别拿 CHM 当无上限缓存CHM 没有淘汰策略往里无限堆大对象只会把堆内存吃爆。本地缓存请用Caffeine带容量上限和淘汰。JDK 8 上 computeIfAbsent 的 value 函数别递归访问同一个 map如果业务需要计算时依赖其他 key先用get判断再putIfAbsent拆成两步。总结CHM 从 JDK 7 的分段锁走到 JDK 8 的数组 桶头锁 CAS是一次把并发度从固定几把锁解放到每个桶一把锁的升级。但它不是银弹size()为了高并发写入的吞吐把计数做成了分段求和类似 LongAdder所以它是近似且非 O(1) 的computeIfAbsent在 JDK 8 还有递归死锁的坑。任何拿 CHM 当计数器 / 当缓存 / 当在 value 里套自己的写法都要先想清楚它在哪个维度上坑你。思考题你现在项目里有没有在循环或请求入口调用map.size()做判定的把调用点改成AtomicLong计数前后各压一次把 P99 对比贴出来。如果你的 CHM 是当缓存用的不妨量一下它的最大 size 和占用的堆内存看看是不是早该换成 Caffeine 了。