“ConcurrentHashMap的size()方法线程安全吗?”我把这个问题抛给过团队里几个写了三四年 Java 的同事,得到的回答出奇一致:“它都是线程安全的 Map 了,size() 还能不安全?”可等我追问一句“为什么安全?拿到的一定是最新值吗”,对方往往就卡住了。如果你也停留在“线程安全的容器,方法当然也安全”这个结论层面,那这篇可能正好能帮你把所有疑问补上。
先说清楚这篇的适用范围:想回答清楚面试题的、对并发容器内部实现感兴趣的、以及在高并发统计场景里踩过 size 不准确坑的同学,都值得往下看。全文不绕弯子,直接讲结论、讲源码、讲版本演变、讲实际业务里的取舍。
1. 先回答结论:size()线程安全,但“安全”二字的含义比你想的窄
1.1 线程安全到底指的是什么
首先给明确答案:ConcurrentHashMap 的 size() 方法是线程安全的,你可以在多线程环境下随意调用,不用担心它内部状态被破坏、不用加锁、也不会抛出任何并发修改异常。
但这个“安全”其实只覆盖了三层意思:
第一,size() 不会产生脏读、撕裂读之类的数据损坏问题。Java 里 long 类型的读写虽然不保证是原子的,但 ConcurrentHashMap 内部用来计数的字段是 volatile long,并且通过 CAS 维护,读取时能拿到某个合法状态下的值,不会读到“一半是旧的、一半是新的”这种拼接数据。
第二,size() 的内部执行过程不会修改任何共享状态。它只是把某个时刻的计数汇总一下返回给你,不像某些集合的 size 需要加锁或者重建索引,所以并发调用再频繁也不会互相干扰。
第三,size() 不会死锁、不会阻塞、不会触发锁竞争。因为 JDK 8 之后它根本就没拿锁,本质上就是一次无锁读取。
这个结论如果拿到面试里,先说出来,面试官一般会点头,但重点在后面那句。
1.2 线程安全不等于实时精确
真正有意思的是下面这层:size() 返回的值不保证是“此时此刻的真实元素个数”,它是一个基于当前可见状态算出来的“大致数量”,在并发写入时可能会短暂过期,甚至可能出现“等于昨天、又等于今天”的跳跃感。
这样说可能还有点抽象,打个比方:你面前有十个收银员,每位收银员各有一张纸记录自己接待了多少顾客。你现在想知道商场里一共有多少顾客,就只能把十张纸上的数字都加一遍。但就在你挨个看这十张纸的过程中,某些收银员可能又接待了新顾客,在纸上写下了新数字。你看到的数据,有些是旧的,有些是新的,但你最终算出来的一定是一个真实存在过、或最接近当前情况的数字,而不是乱编的。
ConcurrentHashMap 的 size() 就是干这件事的,它的设计目标从来都不是“实时精确”,而是“在高并发写入下仍然能快速算出一个不炸、不阻塞、大致可信的数量”。
注意:很多人把“方法线程安全”误解成“方法返回值一定是精确实时的”。这两个概念完全不同。线程安全描述的是调用过程不会出错,而精确性描述的是返回值的时效性。ConcurrentHashMap 解决的是前者,并且明确放弃了后者的一部分要求。
2. 从 put 到 size 的完整计数链路:读懂 addCount 与 sumCount 的源码
2.1 写操作路径上的 addCount 做了什么
要理解 size(),不能只看 size() 这个方法本身,关键在写入那一刻计数是怎么维护起来的。JDK 8 的 ConcurrentHashMap 里,每次 put、remove、replace 成功改动了键值对之后,都会调用一个方法:
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; } if (check <= 1) return; s = sumCount(); } if (check >= 0) { // 后面的分支是扩容判断,跟计数关系不大,先不展开 } }这个方法里出现了两个核心字段:
private transient volatile long baseCount; private transient volatile CounterCell[] counterCells;当并发竞争比较低的时候,线程直接尝试用 CAS 把改动量 x(插入是 +1,删除是 -1)累加到 baseCount 上,大部分情况一次就成功了,这个路径非常快。
一旦并发竞争激烈,CAS 反复失败,说明多个线程在抢同一个 baseCount。这时候就走到了 CounterCell 数组的分支:每个线程根据自己线程的随机探针值(ThreadLocalRandom.getProbe())选一个槽位,把计数累加到对应的 CounterCell 的 value 上,相当于把压力分摊到多个“小本子”上。每个线程各写各的槽,互相不抢,性能才能保住。
2.2 读路径上的 sumCount 如何汇总
现在看读路径。size() 方法本身很简洁:
public int size() { long n = sumCount(); return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); }真正干活的是 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; }这段代码最值得品味的点在于:它先读了一次 counterCells 数组引用,然后遍历这个“旧数组”,把里面所有非空槽位的 value 累加起来,再加上 baseCount。
注意,如果在这个遍历过程中,正好有另一个线程对 counterCells 进行了扩容、替换数组,那么新数组里新增的计数就不会被读到,因为 sumCount 手里的 as 还是旧引用。这就是 size() 可能出现偏差的根基原因之一。
那它到底是不是并发安全的?从结果角度说,它读到的每一个 CounterCell.value 都是 volatile 的,CAS 更新时也有内存屏障,所以每次读取拿到的都是某个状态下写进去的完整值,绝不是撕裂值;但数组遍历过程中,某个 CounterCell 可能被别的线程更新了,也可能整个数组都被替换了,所以汇总结果只是一个“快照近似值”。
2.3 为什么不能用一个简单的 volatile long 计数器
这是面试里很容易被追问的点。有人会想:既然只需要统计元素数量,维护一个 volatile long size 字段,put 时候 size++,remove 时候 size--,不行吗?
逻辑上可行,并发性能上会很难看。所有线程都去抢一个 baseCount,CAS 失败率会随着线程数增加而急剧上升。高并发场景下,大量线程把时间耗在 CAS 自旋重试上,写操作的吞吐量会崩。
ConcurrentHashMap 的做法是把“一个抢不到的计数点”扩展成“一组可以分散竞争的计数点”。每个 CounterCell 相当于一个独立的小计数器,线程通过哈希分散到不同槽位,各写各的,把并发竞争从“万人挤一座桥”变成“每人一座桥”。
这个思路和并发工具包里的 LongAdder 是异曲同工,事实上 LongAdder 的核心设计也是 base + Cell[] 的分散竞争模型。理解了 CounterCell,LongAdder 基本就理解了一半。
补充一个很实用的冷知识:JDK 8 里 isEmpty() 的源码是
return sumCount() <= 0L;,注意它用的是小于等于零,而不是严格等于零。注释里写了 “ignore transient negative values”。原因是并发 remove 时,baseCount 可能先被减成负数,而 CounterCell 里还有正数没汇总,一瞬间总和可能是负的。这个细节说明 JDK 开发者早就知道 size 类方法是“尽力而为”的近似读数。
3. 从 Java 7 到 Java 8:size()实现思路的两次重大演进
3.1 Java 7:基于Segment的两次无锁探测加全量加锁
Java 7 的 ConcurrentHashMap 内部是 Segment 结构,每个 Segment 底层类似一个小的 HashMap,同时本身继承 ReentrantLock。每次写操作只需要锁住对应的一个 Segment,其他 Segment 不受影响。
在这种结构下的 size() 实现思路很有意思:
public int size() { final Segment<K,V>[] segments = this.segments; long sum = 0; long check = 0; int[] mc = new int[segments.length]; for (int k = 0; k < RETRIES_BEFORE_LOCK; ++k) { check = 0; sum = 0; int mcsum = 0; for (int i = 0; i < segments.length; ++i) { sum += segments[i].count; mcsum += mc[i] = segments[i].modCount; } if (mcsum != 0) { for (int i = 0; i < segments.length; ++i) { if (segments[i].modCount != mc[i]) { check = 0; break; } check += mc[i]; } } if (check == mcsum) break; } if (check != mcsum) { for (int j = 0; j < segments.length; ++j) segments[j].lock(); sum = 0; for (int j = 0; j < segments.length; ++j) sum += segments[j].count; for (int j = 0; j < segments.length; ++j) segments[j].unlock(); } return (sum > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)sum; }它的策略是:先不给任何 Segment 加锁,连续遍历两次所有 Segment,累加 count 的同时记录每个 Segment 的 modCount。如果两次遍历之间,所有 Segment 的 modCount 都没有发生变化,就认为这段时间没有写操作发生,直接返回这个累加结果,这是无锁快速路径。
如果第二次遍历时发现 modCount 对不上,说明这期间有线程改过数据,无锁方式已经算不准了。此时退回到保守方案:把全部 Segment 一把锁住,然后再统一累加 count,最后释放所有锁。加锁期间没有写线程能改数据,所以算出来的结果是精确的。
但这个路径的代价是:需要同时拿到所有 Segment 的锁。如果某个 Segment 正在被长时间写入,其他线程会在这里等。这种“先乐观探测,再悲观兜底”的思路在 Java 7 里已经算不错,但整个方法的实现仍然偏重,而且在 Segment 数量较多时,两次全量遍历本身也不便宜。
3.2 Java 8:为什么彻底放弃 Segment 结构
Java 8 重写了 ConcurrentHashMap,直接删掉了 Segment 这种“继承 ReentrantLock”的设计。原因是 Segment 锁虽然缩小了加锁范围,但本质上还是“锁分段”模型,一个写操作仍然要独占某个段,而且段数量一旦设计完就固定了,扩容时还得锁住整个 Segment。
Java 8 的做法是:放弃了锁分段,改用 CAS + synchronized 锁单个桶节点。写操作在大多数情况下只需要 CAS 更新链表或红黑树的头节点,真正发生哈希冲突、树化时才锁住单个桶。并发度从“最多 16 个段”提升到了“每个桶都可以并行写”。
对应的,size() 也不再有“全量加锁”的兜底逻辑了。它彻底采用无锁的 sumCount() 方案。也就是说,Java 8 的 size() 在并发修改下天然就是不精确的,但换来的是它自己永远不会成为瓶颈、永远不会阻塞写线程。
这里有一个关键差异值得单独说:Java 7 的 size() 在检测到并发修改后,会加锁拿到一个精确值;Java 8 的 size() 则彻底躺平,永远只给一个近似值。对业务来说,如果你真的需要精确数量,Java 8 的 ConcurrentHashMap 反而更不满足需求,这也是很多从 Java 7 迁移到 8 之后,发现统计值突然“不太准”的根因之一。
3.3 CounterCell的扩容与伪共享优化细节
CounterCell 数组也不是永恒的,它同样会扩容,但有一个很有意思的天花板:数组长度不会超过 CPU 核数 NCPU。
我们来拆一段 fullAddCount 的核心逻辑。这个方法负责处理 addCount 里 CAS 失败的兜底,包括初始化 CounterCell 数组、在空槽位上放置新 CounterCell、对已有槽位执行 CAS 累加、以及数组扩容:
private final void fullAddCount(long x, boolean wasUncontended) { int h; if ((h = ThreadLocalRandom.getProbe()) == 0) { ThreadLocalRandom.localInit(); h = ThreadLocalRandom.getProbe(); wasUncontended = true; } boolean collide = false; for (;;) { CounterCell[] as; CounterCell a; int n; long v; if ((as = counterCells) != null && (n = as.length) > 0) { if ((a = as[(n - 1) & h]) == null) { // 目标槽位为空,尝试创建新的 CounterCell 放进去 ... } else if (!wasUncontended) wasUncontended = true; else if (U.compareAndSwapLong(a, CELLVALUE, v = a.value, v + x)) break; else if (counterCells != as || n >= NCPU) collide = false; else if (!collide) collide = true; else if (cellsBusy == 0 && U.compareAndSwapInt(this, CELLSBUSY, 0, 1)) { // 数组扩容为原来的两倍 try { if (counterCells == as) { CounterCell[] rs = new CounterCell[n << 1]; for (int i = 0; i < n; ++i) rs[i] = as[i]; counterCells = rs; } } finally { cellsBusy = 0; } collide = false; continue; } h = ThreadLocalRandom.advanceProbe(h); } ... } }当某个线程在自己的槽位上 CAS 累加连续失败,它会尝试换个槽位再试。如果反复冲突,就触发扩容。但一旦数组长度超过 NCPU,就不再扩容了,因为线程数最多也就 NCPU 个,更大的数组意义不大,只会增加 sumCount 遍历的开销。
这里还有一个值得说的底层优化:CounterCell 类上标了 @sun.misc.Contended 注解。这个注解的目的是解决伪共享问题。不了解 CPU 缓存行的读者可以把缓存行想象成一页能放下几十个字的稿纸,CPU 按页读写。如果多个 CounterCell 对象被分配在同一个缓存行里,一个线程修改自己的那个值,会导致其他线程缓存行里的“邻居”也要跟着失效,性能反而会下降。@Contended 会让 JVM 给每个 CounterCell 额外填充字节,让每个计数器独占一个缓存行,避免这种互相拖累。这也是 ConcurrentHashMap 能从底层压榨出性能的细节之一。
4. 高并发业务中size()的使用边界与替代方案
4.1 使用 size() 做空判断的坑
实际项目中最常见的坑是拿 size() 来判断 Map 是否为空:
if (map.size() == 0) { // 执行一些“空容器”逻辑 }在高并发场景下,这个判断是危险的。比如一个线程正在执行 remove,另一个线程恰好读到 sumCount 为 0 或负数,就会认为容器已经清空,触发了“空容器”分支。可实际上容器里可能还有一个正在被移除的元素,或者马上又有新的写入进来了。
JDK 8 的 isEmpty() 用sumCount() <= 0L来“忽略瞬时负值”,但这也没有根本解决问题,它只是尽量避免把负数误判成空。真正稳妥的做法是:如果你的业务逻辑里,“容器是否为空”会触发不可逆的操作(比如停服、清理、重新初始化),不要依赖 ConcurrentHashMap 自身的 size 类方法,应该自己维护一个 AtomicBoolean、AtomicLong,或者用锁保护整个判断和操作。
我接手过一个库存缓存服务,某次活动期间用 map.size() == 0 判断 SKU 列表是否全部下架,结果在热点 SKU 频繁上下架时,运营后台出现“售罄”和“有货”来回闪烁的情况。后来排查发现,就是 size() 的近似值导致误判,最后改成一个独立 AtomicLong 计数器,并且把“判断空 + 执行下架”包进同一把锁里,问题才彻底消失。
4.2 需要精确计数时的三个方案
如果你的业务真的需要“当前精确元素个数”,ConcurrentHashMap.size() 满足不了,有三个常见替代方案:
第一个方案是给 Map 加读写锁,把所有 put、remove、size 都放进同一把锁里。这样 size() 拿到的自然是精确值,但写并发能力会大幅下降,基本退化回 Collections.synchronizedMap 的水平。适合读多写少、数量级不大、但精确性要求极高的场景。
第二个方案是自己在写路径上维护一个 AtomicLong 精确计数器。每次 put 成功后调用 counter.incrementAndGet(),remove 成功后调用 decrementAndGet()。这个方案的优点是计数本身是无锁的,性能不错;但需要注意,Map 结构和计数是两个不同的共享对象,无法在同一个原子操作里同时完成修改。如果某个线程 put 成功,但还没执行 increment 之前,另一个线程读到计数和 Map 实际内容不一致,仍然会有瞬时误差。
第三个方案是使用数据库或外部存储来计数。适合分布式环境,通过事务或强一致性组件保证最终一致。Map 本身退化为高频缓存层,精确数量以存储层为准。
三种方案没有绝对优劣,核心是分清“当前多少条”和“大致多少条”的业务容忍度。能容忍轻微过期,就用 ConcurrentHashMap 自带的 size();不能容忍,就不要心疼为之付出的并发度。
4.3 size()与mappingCount()的差异
还有一个经常被忽略的细节:ConcurrentHashMap 提供了两个类似的方法,size() 和 mappingCount()。
public int size() { long n = sumCount(); return ((n < 0L) ? 0 : (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n); } public long mappingCount() { long n = sumCount(); return (n < 0L) ? 0L : n; }两者底层逻辑完全一样,区别只在返回类型和溢出处理上:
| 方法 | 返回类型 | 最大容量处理 |
|---|---|---|
| size() | int | 超过 Integer.MAX_VALUE 时返回 Integer.MAX_VALUE |
| mappingCount() | long | 直接返回 long,理论上可容纳所有元素计数 |
如果你统计的元素数量可能超过 21 亿,必须用 mappingCount()。不过现实中单机 JVM 的 ConcurrentHashMap 存 21 亿个键值对基本不现实,内存早就爆了,所以这个点更多是规范层面和面试层面的意义。但在团队代码评审时,用 mappingCount() 能传递一个信号:你考虑过容量边界问题。
5. 实测验证:多线程读写下size()的表现
5.1 一个能跑的实验 Demo
光看源码还是不够直观,建议你自己跑一个简单实验验证并发下的表现。下面这段代码可以复制到本地跑一下:
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ConcurrentHashMapSizeDemo { public static void main(String[] args) throws Exception { ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>(); int threadCount = 16; int opsPerThread = 100000; ExecutorService pool = Executors.newFixedThreadPool(threadCount); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threadCount); for (int i = 0; i < threadCount; i++) { final int threadIndex = i; pool.execute(() -> { try { start.await(); for (int j = 0; j < opsPerThread; j++) { map.put(threadIndex + "-" + j, j); } } catch (Exception e) { e.printStackTrace(); } finally { done.countDown(); } }); } // 在读线程里不停调用 size() Thread reader = new Thread(() -> { try { start.await(); for (int i = 0; i < 50; i++) { int s = map.size(); System.out.println("read size = " + s); Thread.sleep(1); } } catch (Exception e) { e.printStackTrace(); } }); reader.start(); long begin = System.currentTimeMillis(); start.countDown(); done.await(); long end = System.currentTimeMillis(); int finalSize = map.size(); System.out.println("final size = " + finalSize); System.out.println("expect size = " + (threadCount * opsPerThread)); System.out.println("elapsed ms = " + (end - begin)); pool.shutdown(); } }最终结果里,final size 应该等于 1600000,说明所有写操作都生效了。但中间打印出的 read size,你可能会看到一些不是单调递增的值,可能从几千跳到几万,又偶尔出现相同的值重复好几次。
这个现象就是弱一致性的直观体现。sumCount() 在遍历 CounterCell 数组时,某些槽位读到的是新值,某些槽位读到的是旧值,汇总出来的结果自然可能暂时低于真实数量。但它不会返回负值、不会爆出超过真实总数的夸张数字,也不会让任何写线程停下来等它。
5.2 实验结果说明了什么
把这段实验结果和源码结合起来看,可以得出几个明确结论:
第一,size() 调用本身在极端并发下也不会引发异常或数据破坏。实验里 16 个线程疯狂写入,同时还有线程高频读 size(),程序没有任何抖动或崩溃。
第二,size() 的返回值和“最终真实值”之间可能存在偏差,偏差大小取决于并发写线程数量和读取时间点。它的设计逻辑决定了:并发竞争越激烈,读到过期值的概率越大;竞争平息后,最终值一定会收敛到真实元素个数。
第三,从实验结果可以反推出一个重要结论:如果业务允许“最终一致”,ConcurrentHashMap 的 size() 完全够用;如果业务要求“强一致”,它不是正确的工具。
这个实验也适合用来考察团队里新人对并发模型的理解。能解释清楚“为什么最终一样、过程中不一样”的人,通常才算真正理解了弱一致性。
写在最后的一点个人体会
我自己两三年前第一次认真读 ConcurrentHashMap 的 size 相关源码时,最大的启发不是“哦,它线程安全”,而是意识到“线程安全”这个词太宽泛了,落到具体方法上,必须追问“安全到哪种一致性级别”。不少并发问题,根源就是用了太强或太弱的工具,却没想清楚业务到底需要什么。
如果你准备吃透这个话题,建议按这个顺序动手:先跑一遍上面的多线程实验,观察 size 值的变化;再顺着 put -> addCount -> sumCount 的调用链把源码过一遍;最后回头看看 Java 7 和 Java 8 的差异,面试时把“为什么这样设计”讲清楚,比背任何结论都有效。
最后分享一个小技巧:读这类并发源码时,不要一开始就钻进每一个分支,先抓住三个问题——共享字段是什么、写路径怎么更新、读路径怎么汇总。三个问题问完,整张地图就基本在脑子里了。