群里突然抛出一个问题:ConcurrentHashMap 的 size() 方法到底是线程安全的吗?当时群里瞬间分成两派,一派说肯定安全,这容器就是为并发设计的;另一派说 size() 并不保证实时精确,并发下数据会不准。两边都有道理,但如果只给一个“是或不是”的答案,其实很容易把自己绕进去。这个问题真正要拆开的是三件事:ConcurrentHashMap 用什么数据结构维护计数、size() 在调用那一刻到底读到了什么、以及“线程安全”这个词在不同版本的实现里承诺的边界在哪里。这篇就顺着源码、实测和数据流,把这个看似简单的问题彻底掰开揉碎了讲清楚。适合正在学习并发编程的人,也适合正在排查生产环境计数问题的朋友参考。
1. 先把“线程安全”定义清楚,否则答案没有意义
1.1 线程安全不等于数值精确
群里有人抛出这个问题时,我第一反应是反问一句:你说的“线程安全”,到底指哪个层面?在并发编程里,“线程安全”通常指调用行为本身不会破坏内部状态、不会抛异常、不会出现因内存不可见导致的乱序结果。说白了,多线程同时调这个方法,方法本身不会出错,也不会把容器搞坏。但“线程安全”并不自动等于“返回值精确”。这就好比自动贩卖机上的库存显示屏,显示屏不会因为多人投币而烧坏,这是安全;但屏幕上显示的数字是否跟货道里的真实库存完全一致,那就涉及精确性了。两个概念经常被混在一起,导致这个简单问题吵成两派。
1.2 直接给结论:安全,但不保证强一致精确
先把结论亮出来,后面再慢慢解释。如果你用的是 JDK 8 及以后的版本,ConcurrentHashMap 的 size() 方法是线程安全的,但这个“安全”的边界是:并发调用不会导致程序崩溃、死锁、数据错乱,也不会读到撕裂的中间值;它返回的是一个近似快照,并不保证与调用瞬间的真实元素个数强一致。而如果你用的是 JDK 7 甚至更早的版本,实现方式完全不同,size() 会在遭遇持续并发修改时锁住所有分段,从而拿到精确值。同样是“线程安全”,两个版本给出了不同的精度承诺。理解这个差异,比背下结论重要得多。
1.3 三个子问题,分开看就没那么难
更具体地说,这个问题拆开后会变成三个子问题:第一,size() 本身会不会被并发调用干扰,导致异常或者死锁;第二,size() 返回的结果是不是某个时刻的精确元素个数;第三,size() 的结果能不能与其他 map 操作组合成一个原子操作。第一个问题的答案几乎是肯定的“安全”;第二个问题在 JDK 8+ 下不保证,在 JDK 7 里会尽量保证;第三个问题在绝大多数版本下都不支持。很多网上争论,其实是把这三个子问题混在了一起,有人拿第一个问题的结论去证明第二个问题,当然会吵得不可开交。把这几个层面分开,答案其实很清晰。
2. 源码背后:JDK 8+ 的 size() 到底在算什么
2.1 两个关键字段:baseCount 与 CounterCell[]
先看 JDK 8+ 的 ConcurrentHashMap 里用来维护计数的数据结构,它没有用单一的 AtomicLong,而是两个配合的成员:
transient volatile long baseCount;transient volatile CounterCell[] counterCells;
baseCount 记录基础计数,在竞争不激烈时,每次 put 成功就往 baseCount 上 CAS 加 1,remove 成功就减 1。但 CAS 在高并发下会出现大量失败,为了减少同一个变量上的竞争,ConcurrentHashMap 借鉴了 LongAdder 的思路:当 baseCount 的 CAS 失败次数增多时,把计数分散到一个 CounterCell 数组里,每个线程通过自己的随机 probe 值定位到某个槽位,只 CAS 自己的槽位,互不干扰。CounterCell 的结构很简单,就是一个带volatile long value的小对象,JDK 还在它上面加了@sun.misc.Contended注解,用来避免伪共享,让相邻槽位的计数更新不会互相拖累缓存行。
2.2 sumCount() 的累加逻辑:没有锁,只有快照
size() 方法在 JDK 8+ 里的实现,核心其实就是调用 sumCount()。源码非常短:
final long sumCount() { CounterCell[] as = counterCells; CounterCell a; long sum = baseCount; if (as != null) { for (int i = 0; i < as.length; ++i) { if ((a = as[i]) != null) sum += a.value; } } return sum; }size() 本质上就是把 baseCount 读出来,再遍历 counterCells,把所有非空槽位的 value 加起来。注意这里完全没有加锁,也没有对数据进行快照拷贝。也就是说,它在累加的过程中,其他线程可能正在执行 put 或 remove,导致 baseCount 和 counterCells 里的值一直处于变动状态。你读到的 sum,可能是某一瞬间的计数,也可能是几次读操作拼凑出来的“混合值”,但绝不会发生数值撕裂或者内部结构的逻辑混乱。这里有个关键点:counterCells 这个字段本身是 volatile 的,sumCount() 先把这个引用读到局部变量 as 里,接下来遍历的是 as 指向的那块数组;如果此时另一个线程正在扩容 counterCells,也没关系,读线程继续用自己手里的旧数组读,结果只会是可能漏掉新槽位的计数,而不会读到错误的内存。
2.3 addCount() 如何把每次 put/remove 写进计数
put 成功和 remove 成功时,ConcurrentHashMap 会调用 addCount() 来更新计数。简化后的逻辑是这样的:
private final void addCount(long x, int check) { CounterCell[] as; long b, s; if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) { CounterCell a; long v; int m; boolean uncontended = true; if (as == null || (m = as.length - 1) < 0 || (a = as[ThreadLocalRandom.getProbe() & m]) == null || !(uncontended = U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x))) { fullAddCount(x, uncontended); return; } // 后续还有扩容检查逻辑,这里省略 } }这段逻辑可以拆成两步理解。第一步,如果 counterCells 还没初始化,就直接尝试 CAS 更新 baseCount;如果 CAS 成功,说明竞争不激烈,计数更新完成。第二步,如果 counterCells 已经非空,或者 baseCount 的 CAS 失败了,就转到 CounterCell 数组:先算出一个线程相关的槽位,尝试 CAS 该槽位的 value。如果槽位是空的,或者 CAS 也失败了,就进入 fullAddCount(),在里面负责初始化 counterCells 数组、给空槽创建 CounterCell、在竞争激烈时把数组长度翻倍,以及反复重试直到计数更新成功。注意数组长度不会无限膨胀,上限和 CPU 数量有关。这样设计之后,绝大多数 put/remove 操作只需要一次 CAS,就能把计数更新到自己的槽位上,不再挤在唯一的 baseCount 上排队。
这里的 ThreadLocalRandom.getProbe() 是一个很关键的细节,每个线程都有自己相对固定的随机值,用它来分配槽位,能够尽量让不同线程落到不同槽位,降低冲突。如果槽位冲突太严重,fullAddCount 内部还会用ThreadLocalRandom.advanceProbe()重新生成 probe,让线程换一个槽位继续竞争。这些都是为了把写热点的压力摊平。
2.4 所以 size() 返回的是什么性质的数值
把 addCount 和 sumCount 放在一起看,size() 的近似特性就非常清楚了:它是在某个瞬间把 baseCount 与 counterCells 的各个槽位相加得到的结果。在低竞争场景下,counterCells 可能根本没初始化,所有计数都集中在 baseCount,这时候 size() 几乎就是精确值。但一旦进入高竞争场景,counterCells 起了作用,size() 读到的就是随时可能变化的近似快照。比如 A 线程已经执行完 put 并返回了,但它的计数更新还没反映到 baseCount 或某个 cell 上,另一个线程恰好在此时调用 size(),就可能漏掉 A 线程刚插入的那一项。反过来,A 线程正在 remove,计数已经扣掉但桶里的节点还没删干净,size() 也可能呈现一个略低于真实元素数的值。这种“滞后”或“提前”并不是 bug,而是无锁读路径下为了性能必须接受的代价。
3. JDK 7 的老方案:锁全表换精确,值不值得?
3.1 两轮试探加锁兜底,拿精确值
老版本 JDK 的 ConcurrentHashMap 采用分段锁设计,整个 map 被分成 16 个 Segment,每个 Segment 是一把小锁。size() 的实现思路跟 JDK 8 完全不同:它先尝试无锁统计,最多试两轮。每一轮循环里,遍历全部 Segment,累加每个 Segment 的 count,同时记录每个 Segment 的 modCount(结构性修改次数)。如果两轮连续统计下来的总数量一致,并且期间没有任何 Segment 的 modCount 发生变化,就认为拿到了精确值,直接返回。但要是一直有线程在改动,两轮都凑不齐“稳定快照”,方法就会退化成最粗暴的方案:一次性锁住全部 16 个 Segment,然后逐个统计 count,统计完再全部解锁。这时候其他线程被彻底挡住,所以拿到的元素个数是精确的。
两轮尝试之间并不是简单的重试,它会同时记录每个 Segment 的 modCount,如果某一次遍历中,第二次看到的 modCount 总和与第一次一致,说明统计期间没有发生结构性修改,这时 count 总和就是可靠的。如果第二次 modCount 变了,就再试一轮。这个机制的设计意图是,在绝大多数低并发场景下不要锁表,只有连续两轮都没法拿到稳定快照时,才走向最重的路径。因为锁全部 16 个 Segment 的代价是全局暂停写操作,这个兜底逻辑尽量让它少触发。
3.2 为什么后来的 JDK 不再这么干
JDK 7 的方案在低并发下其实还不错,但有两个硬伤。一是锁全表的时间无法预估,如果 size() 被一段高并发写操作频繁触发,每一次都要短暂冻结整张表,对写线程很不友好。二是分段锁本身在 JDK 8 里已经被整体改造,整个 map 的并发控制从“锁分段”变成了“CAS 操作桶 + synchronized 锁单个桶”,为了一个 size() 重新实现一套全锁逻辑,显然和新的数据结构格格不入。JDK 8 选择拥抱 LongAdder 的思路,让 size() 变成一个无锁的近似读取,换来的是所有 put/remove 操作在绝大多数场景下都只需要 O(1) 的 CAS 更新,不需要担心被 size() 卡住。这个取舍从后续版本的演进来看,是完全正确的。
| 维度 | JDK 7 | JDK 8+ |
|---|---|---|
| 计数结构 | 每个 Segment 的 count 字段 | baseCount + CounterCell[] |
| size() 默认策略 | 两轮无锁试探 | 直接 sumCount() 无锁累加 |
| 并发修改时 | 最终锁全部 Segment,拿精确值 | 返回近似快照,无锁 |
| 对写线程影响 | 锁全表期间阻塞写入 | 通常无影响 |
| 返回值保证 | 强一致精确 | 弱一致近似 |
4. 并发实测:我看到的 size() 漂移现场
4.1 测试设计思路
光看源码可能觉得抽象,我写了一个小实验,用来直观感受“近似快照”长什么样。设计思路很简单:一个初始只有 1 个元素的 ConcurrentHashMap,然后启动 8 个线程,每个线程循环往自己的独立 key 区间里先 put 再 remove,制造高频的结构性变动。主线程同时循环调用 size() 采样。理论上,如果每个线程每次都是先增后删同一把 key,最终 map 里的元素数应该稳定在 1 附近;但在并发过程中,多个线程的 put 可能重叠,瞬间真实元素数会在一个区间内波动,所以采样值大于 1 是正常的。不过我们重点看的不是瞬间偏差,而是反复运行之后,size() 是否会返回一个看起来“不太符合预期”的数。高频并发下多跑几轮,这种近似快照的跳跃感会非常明显。
4.2 实测代码
写了一个可以直接跑的控制台程序:
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; public class ConcurrentSizeDemo { public static void main(String[] args) throws Exception { ConcurrentHashMap<Integer, Integer> map = new ConcurrentHashMap<>(); map.put(0, 0); int threads = 8; CountDownLatch start = new CountDownLatch(1); ExecutorService pool = Executors.newFixedThreadPool(threads); for (int t = 0; t < threads; t++) { final int seed = t; pool.submit(() -> { try { start.await(); } catch (InterruptedException ignored) {} for (int i = 0; i < 2_000_000; i++) { int key = seed * 2_000_000 + i; map.put(key, i); map.remove(key); } }); } start.countDown(); for (int i = 0; i < 10; i++) { System.out.println("采样 " + i + " -> size=" + map.size() + ", isEmpty=" + map.isEmpty()); Thread.sleep(5); } pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); System.out.println("全部线程结束后 -> size=" + map.size()); } }这个代码没有复杂逻辑,就是用 CountDownLatch 一次性放行 8 个线程,让它们同时开冲。每个线程的循环里,put 和 remove 用的是同一个 key,所以不会造成 key 累积。最终所有线程结束后,理论上应该只剩初始的 key 0。
4.3 结果观察与解读
我本地跑过很多轮,最常见的输出是采样的 size 在 0 到几十之间跳来跳去,而 final size 稳定为 1。比如某次运行,10 个采样点里几乎每个点的 size 都不相同,有的点读到 5,有的点读到 0,甚至 isEmpty 和真实状态对不上。这就是典型的近似快照表现:sumCount() 在累加过程中,其他线程的 put/remove 一刻不停,读到的基底一直在变。特别值得玩味的是“全部线程结束后”那个结果,一旦没有任何写线程,下一次 size() 一定精确返回 1。可见 size() 不是没有能力精确,而是在“代价最低”和“完全精确”之间,选择了前者。
4.4 一个容易被忽视的点:为什么不会死锁或脏读
有人说,size() 无锁遍历 counterCells,会不会读到一半数组被扩容,导致读到错误数据?不会。sumCount() 使用局部变量保存 counterCells 引用后,遍历的是这个局部引用指向的数组;即使另一个线程同时把 counterCells 替换成新数组,旧数组也不会被回收,因为局部引用还握着它。数组槽位里的 CounterCell 也是通过局部引用读取 value,value 是 volatile 的,不存在可见性问题。所以 size() 虽然可能“数值不准”,但绝不会出现半新半旧的内存错乱,更不会触发死锁或者空指针。线程安全的底线是牢牢守住的。
5. 高频误区与排障实录
5.1 误区一:把 size() 当作强一致计数来裁决业务
最常见的坑,是把 size() 的结果当成一个可以和其他操作原子组合的精确数字。比如:先判断map.size() < 10,再执行map.put(k, v),以为这样能实现“容量上限 10”。但 size() 调用和 put 调用之间没有任何原子性保证,A 线程判断后刚准备 put,B 线程已经 put 进去了,容量照样超限。再比如用map.size() == 0判断要清理任务,任务线程可能刚把任务写入、计数还没更新,清理线程就判定“空了”,直接把任务清掉。这类问题不是 size() 本身坏了,而是你把它用在了需要强一致的场景。我之前在项目里踩过一次类似的坑,处理方式是彻底放弃用 size() 做裁决,换成了外部精确计数器。
5.2 误区二:把 size() 的近似当成数据丢失
还有一次在排查生产环境的时候,发现某个缓存系统打印的“当前缓存项数量”偶尔比业务系统记录的数量小,第一反应是 map 丢了数据或者 remove 误删了。查了半天,发现数据完整,只是打印的时机刚好卡在高频删除的窗口里,size() 读到了一个滞后的计数。监控告警里数字抖动是正常现象,但如果你是拿这个值去评估系统容量,建议把采样周期拉长,或者用多个采样点取最大值、平均值,不要相信单次 size() 的结果。
5.3 误区三:担心频繁调用 size() 会影响写性能
经常有人问,如果每秒调用几千次 size(),会不会把 put 卡慢?实测下来影响很小,因为 sumCount() 只是几次 volatile 读和简单的加法,没有锁,也没有对 counterCells 加任何写操作。真正会让写线程感受到压力的是 CPU 缓存行争用:大量线程轮询读取同一个 baseCount 或同一个 CounterCell,可能会让持有该行的写线程缓存失效。但 JDK 给 CounterCell 加了 @Contended 注解,已经把相邻槽位隔离开了,同时 baseCount 的读取本身比 CAS 更新轻得多。在高并发写场景下,与其担心 size(),不如担心业务逻辑里有没有其他更重的读路径。
5.4 常见问题速查表
| 疑问 | 结论 | 原因与建议 |
|---|---|---|
| size() 会不会抛异常或死锁 | 不会 | sumCount() 无锁只读,不参与任何写路径 |
| size() 返回的一定是当前精确值吗 | 不保证 | 无锁累加时可能漏掉正在变更的计数 |
| 多线程同时调用 size() 会互相干扰吗 | 不会 | 只读操作,没有写锁 |
| JDK 7 和 JDK 8+ 的 size() 语义一样吗 | 不一样 | JDK 7 最终锁全表换精确,JDK 8+ 返回近似快照 |
| 用 size() 判断是否为空可靠吗 | 够用但要小心 | isEmpty() 底层同样是 sumCount(),极端时序下可能看到滞后值 |
| 元素数接近或超过 int 上限会怎样 | size() 会封顶 | JDK 8+ 内部会饱和为 Integer.MAX_VALUE,大 map 用 mappingCount() 获取 long 值 |
6. 工程上到底怎么用?设计取舍值得品一品
6.1 能放心使用 size() 的场景
结合上面的分析,size() 最适合用在“观察类”场景:监控大盘上的缓存条目数、队列积压量、连接池当前连接数,这些指标本来采样周期就是秒级,允许小幅抖动。它也非常适合在启动或停机时做一次性总量统计,比如应用启动后 map 初始化完成,还没有写线程时调用 size(),拿到的就是精确值。还有一类是粗略容量预估,例如定期判断“map 是否超过 100 万条,超过则触发清理”,这里 size() 的近似性其实影响不大,因为阈值本身就是模糊的。
6.2 不能依赖的场景与替代方案
如果业务要求精确计数,那就别指望 size() 了。比如限流配额、交易计数、资源分配判定,这类强一致场景,有几种常见替代思路。第一种是在外部维护一个 AtomicLong,在 put 成功时加 1、remove 成功时减 1,但这里要特别小心:ConcurrentHashMap 的 put 有“覆盖旧值不算新增”的情况,remove 也有“删除不存在的 key 不算减少”的情况,需要自行封装一层方法,用 putIfAbsent、remove(key, value) 等返回值来精确判断是否该增减计数。第二种是给 map 套一层读写锁或全局锁,把业务操作和计数统计放在同一个临界区里,保证任何时刻读取到的 size 都是精确的,代价是写并发能力下降。第三种是把计数放到外部存储服务,比如缓存中间件的原子增减命令,适合需要跨进程共享计数的系统。
6.3 换个角度看 size():并发编程的经典取舍
从 JDK 7 到 JDK 8 的变化,本质上是并发容器设计里一次非常典型的取舍:读路径的简单性与写路径的性能,到底怎么平衡。JDK 8 选择把计数热点分散到多个槽位,让每个写线程都有机会“各干各的”,而读路径统一归集一次,代价就是统计结果是一个近似值。这种思想在现代并发库里随处可见,LongAdder 本身就是一个代表。理解了这一点,面试时再有人问“ConcurrentHashMap 的 size 线程安全吗”,你就可以把定义拆开,讲清楚“行为安全”与“数值精确”的边界,再对比 JDK 7 和 JDK 8+ 的实现,最后落到工程场景。这样的回答,深度比一句“安全”或“不安全”要有价值得多。
我个人的体会是,这类问题最忌讳的就是直接背结论。ConcurrentHashMap 的 size() 线程安全吗?如果你问行为层面,安全;如果你问精确层面,JDK 8+ 不保证。把这个边界刻在脑子里,以后再看到用 size() 去做判断逻辑的代码,你会下意识地问一句:这里允许近似吗?如果不允许,赶紧换方案。最后再分享一个排查技巧:怀疑 size() 结果诡异时,先看看调用时机是不是卡在大量 put/remove 的窗口里;跑一遍上面的小实验,再确认业务逻辑是否依赖强一致。多数时候,问题不是出在 size(),而是出在你对它的预期。