做 Java 并发开发这么久,我越来越觉得 JDK 1.8 的 ConcurrentHashMap 是一份值得反复精读的源码教材。它在同一个类里同时用上了 CAS 和 synchronized,但并不是简单堆砌两种并发手段,而是把"能不加锁就不加锁,必须加锁就锁最小范围"这个思路贯彻到了极致。这篇文章我会从源码角度把这条链路完整走一遍,拆开 JDK 1.8 里 ConcurrentHashMap 的核心设计:CAS 在哪些节点入场、synchronized 锁的到底是什么、扩容和计数又是怎么配合的。无论你是刚接触并发集合的新人,还是写了好几年业务代码想回头补课的老开发,这篇文章都值得耐心看完。
1. 从分段锁到细粒度锁:JDK 8 改 ConcurrentHashMap 的根本动机
1.1 JDK 7 的 Segment 方案重在哪里
想理解 JDK 8 的设计,得先知道 JDK 7 里 ConcurrentHashMap 是怎么工作的。JDK 7 版本的核心是 Segment,一个 Segment 内部维护一个 HashEntry 数组,Segment 本身继承自 ReentrantLock。默认创建 16 个 Segment,所以并发度上限就是 16。写操作先定位到某个 Segment,然后锁住整个 Segment,再去操作里面的 HashEntry 数组。
这套方案在 JDK 5/6 时代已经算非常先进了,它把一把大锁拆成了 16 把锁,让不同 Segment 之间可以并发写。但问题也很明显:一旦某个 Segment 内部发生冲突,锁的范围是整个 Segment,数据量大的时候这个 Segment 下面可能挂着成千上万个 Entry,锁竞争仍然会很严重。而且并行度被死死限制在 16,机器核数再多也发挥不出来。
JDK 7 的 Segment 还有一个隐藏问题:很多操作虽然只需要锁一个 Segment,但为了保证跨 Segment 的一致性,像 size() 这种全局操作需要先依次尝试获取所有 Segment 的锁,失败后再重试。这在高并发下成本很高。
1.2 synchronized 在 JDK 8 时代重新变得"香"了
很多人有一个误解,觉得 synchronized 是重量级锁,性能一定不如 ReentrantLock。这个印象停留在 JDK 1.6 之前。JDK 1.6 开始对 synchronized 做了大规模优化,引入了偏向锁、轻量级锁、锁膨胀、锁消除等机制。到了 JDK 1.8,synchronized 在低竞争场景下几乎不输 ReentrantLock,在高竞争场景下由 JVM 负责锁升级,开发者不用手动控制。
ConcurrentHashMap 作者 Doug Lea 之所以在 JDK 8 里放弃 ReentrantLock 改用 synchronized,我理解有三点考虑:
- 锁粒度细了之后,大多数锁竞争根本不会发生,synchronized 的偏向锁和轻量级锁机制在这种场景下开销极低。
- synchronized 是 JVM 内置的,后续 JVM 版本可以持续优化它,但 ReentrantLock 的代码逻辑是固定的,没办法借助 JVM 升级。
- 代码可读性和维护成本更好,不用像 Segment 那套逻辑一样额外维护一个锁对象的生命周期。
换句话说,不是 ReentrantLock 不好,而是 JDK 8 之后 synchronized 在细锁粒度场景下已经足够好,而且代码更简洁。
1.3 锁粒度从"一段"变成"一桶"
JDK 8 的 ConcurrentHashMap 直接取消了 Segment,改成直接用一个 Node 数组保存数据,也就是 table。Node 数组的每个位置叫一个哈希桶(bin)。并发控制的基本单位从"一个段"缩小到了"一个桶"。两个线程同时写不同的桶,基本互不干扰;即使 hash 冲突严重,多个线程同时写同一个桶,才会出现锁竞争。
锁粒度的缩小带来的收益是巨大的。并发度不再受固定段数限制,而是取决于哈希桶的数量和 hash 分布的均匀程度。默认情况下 table 初始容量是 16,但实际上扩容之后可以到很大的规模,理论上并发度远高于 JDK 7。
但是"缩小锁粒度"不是平白无故就能做的。桶级别加锁必须解决一个关键问题:两个线程同时发现同一个桶是空的,都往里面放第一个节点,这时候谁说了算?如果也用 synchronized 锁整个 table 或某个全局对象,那又回到了粗粒度。JDK 8 的答案是:空桶插入这种原子操作,根本不需要锁,用 CAS 就够了。
2. 走进内部结构:Node、TreeBin 与 sizeCtl 的巧妙配合
2.1 Node 数组与链表的存储方式
JDK 8 的 ConcurrentHashMap 内部有一个transient volatile Node<K,V>[] table,这是最核心的存储结构。Node 是一个普通的链表节点,包含 hash、key、value 和 next 四个字段,其中 value 和 next 都声明为 volatile,保证可见性。
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; // ... }正常插入时,如果 key 定位到的桶是空的,就创建一个 Node 直接放进去;如果桶里已经有节点,就沿着链表往后找,找到了就替换 value,找不到就追加到链表尾部。链表长度超过阈值之后,会转为红黑树结构,此时桶里的头节点会变成一个 TreeBin 节点,hash 值为 -2。
TreeBin 并不是直接把红黑树节点暴露出来,而是作为一个外壳节点,内部维护红黑树的根节点,并且持有自己的读写锁状态。后续对树的插入、删除、查询都要经过 TreeBin。
2.2 为什么链表转红黑树的阈值是 8,容量阈值是 64
这是面试高频问题,也是源码里非常经典的一处设计。链表转树涉及两个条件:
- 链表长度达到
TREEIFY_THRESHOLD = 8。 - table 容量不小于
MIN_TREEIFY_CAPACITY = 64。
先解释 64。如果 table 容量还很小,比如只有 16 或 32,说明连扩容空间都没充分用起来,此时与其把链表转成红黑树,不如先扩容,让元素分散到更多桶里。扩容是比树化更根本的解决办法。
再解释 8。源码注释里给了概率分析:在理想随机哈希函数下,当负载因子为 0.75 时,某个桶里链表长度达到 8 的概率大约是千万分之六。这个概率已经低到可以认为正常业务里几乎不会发生,一旦出现链表长度到 8 的情况,大概率是 key 的 hashCode 设计严重有问题,或者发生了恶意 hash 碰撞。此时用红黑树把最坏情况下的查询复杂度从 O(n) 降到 O(log n),作为兜底方案非常合理。
这也是一个非常好的设计思路:大多数情况下用最简单的链表,性能足够好;只有极端情况才升级到复杂结构。而不是一开始就用红黑树,徒增维护成本。
2.3 sizeCtl:一个变量管三件大事
sizeCtl 是 ConcurrentHashMap 里一个非常关键的 volatile 变量,它的不同取值代表了不同的状态:
| sizeCtl 值 | 含义 |
|---|---|
| -1 | 正在初始化 table |
| -(resizingThreadCount + 1) | 正在扩容,负数的绝对值减 1 表示参与扩容的线程数 |
| 0 | 尚未初始化,默认容量 16 |
| 正数 | 下一次触发扩容的阈值,约为当前容量 × 0.75 |
我把 sizeCtl 理解成 ConcurrentHashMap 的"门卫"。初始化 table 时,多个线程可能同时进入 initTable,但只有 CAS 把 sizeCtl 从 0 改成 -1 成功的那个线程才有资格创建 table,其他线程则自旋等待。扩容时,sizeCtl 为负数表示已经有人在进行扩容了,新加入的线程看到 MOVED 状态的节点后会进来帮忙,每加入一个线程,sizeCtl 的绝对值就加 1,完成任务后减 1,直到变为正数,代表扩容结束。
3. put 方法的完整作战流程:两处关键并发控制
3.1 前置准备:spread 哈希与 table 初始化
put 方法入口是putVal,第一步计算 key 的 hash。JDK 8 里不是直接用key.hashCode(),而是做了一个 spread 扰动:
static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; }这一步把 hashCode 的高 16 位和低 16 位异或,让高位的差异也能影响到底部位。因为 table 的长度是 2 的整数次幂,定位桶时用的是(n - 1) & hash,只用到 hash 的低位。如果不做扰动,只要 hashCode 的低位相同,即使高位差异很大,也会被映射到同一个桶,容易产生碰撞。
之后进入一个for (;;)自旋,每一步都检查当前状态:
if (tab == null || (n = tab.length) == 0) tab = initTable();initTable 这个操作并发度很高,多个线程可能同时调用。它的核心逻辑是 CAS 修改 sizeCtl 从当前值变成 -1,抢到资格的线程创建 table,其他线程 yield 自己后继续自旋等待:
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { // 初始化 table }这里 CAS 最重要的意义在于:不需要全局锁,就能保证只有一个线程完成初始化。
3.2 空桶插入:CAS 一锤定音
拿到 table 之后,根据 hash 定位到桶的位置。如果桶为空,说明这个位置没有竞争,直接用 CAS 把新节点放进去:
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; }casTabAt底层是Unsafe.compareAndSwapObject,也就是 CAS 指令。它会比较 table 第 i 个位置的引用是否为 null,如果是,则替换为新建的 Node,替换成功就退出循环。
这里是 JDK 8 设计的精髓:空桶插入是一个原子操作,完全不需要加锁。如果 CAS 失败,说明有其他线程刚刚抢先插入了,那么当前线程会重新循环,走到下面分支。
CAS 的代价比 synchronized 小得多,没有线程阻塞和唤醒,也没有锁对象头部的状态维护。所以"尽量用 CAS 解决最轻量级的竞争"是这套设计的第一准则。
3.3 非空桶插入:synchronized 锁头节点
如果桶里已经有节点了,说明存在竞争,CAS 就派不上用场了。此时需要保证对这条链表的修改是互斥的。JDK 8 的做法是给这个桶的头节点加 synchronized 锁:
else { V oldVal = null; synchronized (f) { if (tabAt(tab, i) == f) { // 处理链表逻辑 } } }f 就是当前桶的头节点。锁住 f,等于是锁住了这条桶对应的整条链表。为什么锁头节点就够?因为所有要操作这条链表的线程,都必须先拿到同一个头节点对象作为锁。其他桶的线程不受影响,因为它们锁的是各自的头节点。
这里还有一个容易忽略的关键操作:进入 synchronized 块之后,会再次执行tabAt(tab, i) == f做双重检查。为什么需要?因为当前线程是在进入同步块之前就拿到了 f,但另一个线程可能在我们拿 f 之后、进入同步块之前,通过 remove 或扩容把桶的头节点换掉了。如果不重新检查,我们锁的可能是一个已经不在 table 里的旧节点,而真正操作 table 的线程锁的却是新节点,两个线程各锁各的,线程安全就无从谈起。
在 synchronized 块内部,通过 hash 值区分两种情况。如果头节点的 hash 大于 0,说明是普通链表节点,就遍历链表:
for (Node<K,V> e = f;; ++binCount) { K ek; if (e.hash == hash && ((ek = e.key) == key || (ek != null && key.equals(ek)))) { oldVal = e.val; if (!onlyIfAbsent) e.val = value; break; } Node<K,V> pred = e; if ((e = e.next) == null) { pred.next = new Node<K,V>(hash, key, value, null); break; } }链表中如果找到了相同 key,就替换 value;如果遍历到链表末尾还没有,就追加到尾部。这个逻辑跟 HashMap 很像,只是外面多包了一层 synchronized。
3.4 遇到红黑树:走 TreeBin 分支
如果头节点的 hash 等于 -2,说明是 TreeBin 节点,走树插入分支:
else if (f instanceof TreeBin) { Node<K,V> p; binCount = 2; if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) { oldVal = p.val; if (!onlyIfAbsent) p.val = value; } }同时在链表的同步块之外,putVal 的最后会检查链表长度是否达到树化阈值:
if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); // ... }treeifyBin 会再次判断 table 长度是否大于等于 64,如果小于 64,则先触发扩容而不是直接树化。
3.5 插入完成后的 addCount
putVal 最后调用了addCount(1L, binCount),这一步不知道有没有发现,它是整个 put 流程非常精妙的设计。addCount 的职责有两件事:一个是计数加 1;另一个是判断是否需要扩容。
这里的计数不是简单地对一个 long 变量做加法,因为高并发下这个变量会成为巨大的竞争热点。我用一个生活化类比:如果全公司几千人同时去前台签到,前台一定会排长队。更好的办法是每个人先在自己部门签到,最后汇总部门人数。ConcurrentHashMap 的 CounterCell 数组就是这个"部门签到本",多个线程各自更新自己的 cell,最后 sum 汇总。
addCount 里还有扩容逻辑:
if (check >= 0) { Node<K,V>[] tab, nt; int n, sc; while (s >= (long)(sc = sizeCtl) && (tab = table) != null && (n = tab.length) < MAXIMUM_CAPACITY) { ... if (sc < 0) // 已有线程在扩容,本线程参与协助 else if (U.compareAndSwapInt(this, SIZECTL, sc, sc + 1)) // 本线程成为第一个触发扩容的线程,调用 transfer } }扩容的时候,sizeCtl 被 CAS 修改为负数,后续线程看到负数就会进来帮忙。这也是 ConcurrentHashMap 和其他 Map 最大的不同:扩容是支持多线程协作的。
4. 读取与删除:无锁读取背后的可见性保障
4.1 get 方法为什么可以不加锁
get 方法全程没有加锁,它是如何保证并发安全性的?看一下 get 的核心代码:
public V get(Object key) { Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek; int h = spread(key.hashCode()); if ((tab = table) != null && (n = tab.length) > 0 && (e = tabAt(tab, (n - 1) & h)) != null) { if ((eh = e.hash) == h) { if ((ek = e.key) == key || (ek != null && key.equals(ek))) return e.val; } // ... 树查找 while ((e = e.next) != null) { // 链表查找 } } return null; }get 无锁的安全基础建立在三点:
- table 数组本身是 volatile 的,读线程能看到最新的数组引用。
- Node 的 val 和 next 字段都是 volatile 的,读线程能读到最新写入的值。
- 写线程修改链表结构时持有的是桶头节点的 synchronized 锁,但读线程不加锁也能保证不会读到半修改状态?严格说这里不是绝对保证,而是通过 volatile 保证可见性,同时通过"写操作要么替换 val,要么追加节点"这种不可变发布的方式保证读线程永远能读到完整结构。
Node 的 next 一旦发布(通过 volatile 写或者 CAS)之后,就不会再变。链表修改最常见的是追加尾部,追加完成后新节点对读线程可见。删除操作只是把前一个节点的 next 指针指向被删节点的下一个节点,读线程要么读到旧链路上的节点(值可能稍旧),要么读到新链路上的节点,绝不会读到损坏的结构。
工程上我把这个方案称为"无锁读 + 写者串行化":同一个桶的写者之间互斥,读者之间并行,读者和写者之间不需要互斥,靠 volatile 保证可见性。这在读多写少的场景下性能优势非常明显。
4.2 remove 的逻辑:锁住头节点再删
remove 方法最终调用 replaceNode,逻辑跟 put 类似:定位桶,用 synchronized 锁住头节点,然后遍历链表找到匹配节点并删除。删除操作需要维护前驱节点的 next 指针:
if (pred != null) pred.next = e.next; else setTabAt(tab, i, e.next);如果删除的是头节点,就用 CAS 直接更新桶位置为新头节点。否则修改前驱节点的 next。由于在 synchronized 块内,所以同一时间只有一个线程在改这条链表,不会出现 next 指针被并发修改的问题。
删除红黑树的节点时,TreeBin 内部有自己的同步机制。当节点数量比较少时,会触发去树化(untreeify),把红黑树重新退化为链表,阈值是 6。这里没有直接用 8,是为了避免节点数量在 7 到 8 附近频繁地树化和去树化,来回抖动,影响性能。留一点裕度,这是工程设计里常见的防抖思路。
4.3 迭代器是弱一致性的,不是快照式的
并发集合的迭代器设计是所有使用者必须理解的。ConcurrentHashMap 的迭代器不会抛 ConcurrentModificationException,也不会在创建时复制整个集合的快照,而是直接引用底层的 table 来遍历。
这种设计带来的效果是:迭代器创建之后,如果其他线程新增了节点,迭代器可能看不到;如果其他线程删除了节点,迭代器可能经过已删除的节点;已经遍历过的部分不会受影响,没遍历到的部分也不保证看到最新状态。这种一致性级别叫弱一致性(weakly consistent)。
很多初学者会把 ConcurrentHashMap 的迭代器误当成"线程安全的快照集合",这是不对的。如果业务场景要求迭代时看到的是某一时刻的稳定状态,最简单可靠的办法是手动加锁,或者先把数据复制到一个普通集合里再遍历。
5. 扩容机制:单线程任务如何被拆成多线程协作
5.1 先从全局视角看扩容流程
扩容是整个 ConcurrentHashMap 最复杂的部分,没有之一。普通 HashMap 的扩容是单线程完成:创建一个新数组,把旧数组里所有元素重新哈希放进去。ConcurrentHashMap 为了保证并发性能,把这份工作拆分成了很多小任务,允许多个线程一起搬运。
扩容从 addCount 或 treeifyBin 中触发,进入tryPresize或直接调用transfer。transfer 是核心搬运方法。
transfer 的第一步是把旧 table 的长度 n 分成若干段,每段包含的桶数量通过stride计算,默认最少是 16 个桶一段。然后通过一个transferIndex变量来分配任务。transferIndex 初始值是旧 table 的长度,每次有线程来帮忙,就通过 CAS 把 transferIndex 往前推进一段,这段范围内的桶就归这个线程搬运。
5.2 一个桶怎么搬:低位高位拆分
搬运单个桶时,如果桶里是普通链表,不会逐个节点重新 hash,那样太慢了。JDK 8 用了一个巧妙的方法:因为新 table 的长度是旧 table 的一半扩展,比如旧长度是 16,新长度是 32,定位桶时从(16 - 1) & hash变成(32 - 1) & hash,相当于多取了一位 hash 位。
实际上,JDK 7 就已经使用类似机制,但 JDK 8 的 CHM 延续了这个思路:对链表的每个节点,判断(e.hash & oldCap) == 0。如果等于 0,说明这个节点扩容后还在原来的位置 i;如果不等于 0,说明扩容后会移动到 i + oldCap 的位置。这样一次遍历就能把链表拆成低位链和高位链,各自放到新数组的对应位置。
这里我还是用个例子说明:假设旧数组长度是 16,某个节点 hash 的低 4 位是 0101,它原来在桶 5。新数组长度是 32,hash 的低 5 位如果第 5 位是 0,则是 00101 还是 5;如果第 5 位是 1,则是 10101 也就是 21,正好是 5 + 16。所以扩容后链上的每个节点只可能去两个位置:原位置,或者原位置加 oldCap。
搬运完成后,旧桶位置会被替换为一个 ForwardingNode(哈希值为 -1,即 MOVED)。这个节点的作用有两个:一是告诉其他线程,这个桶已经搬完了,你可以跳过;二是从旧数组访问该桶时,可以通过 ForwardingNode 的 nextTable 引用去新数组里找。
5.3 helpTransfer:其他线程怎么"搭把手"
当某个线程执行 put 或 remove 操作时,如果发现桶的头节点是 ForwardingNode,就会调用 helpTransfer 去帮忙:
else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f);helpTransfer 内部会先检查 nextTable 是否为空、sizeCtl 是否为负数,确认确实有人在扩容,然后 CAS 更新 sizeCtl 的绝对值,表示多了一个参与者。接着就调用 transfer 加入搬运行列。
这种多人协作的模式,核心在于 task 分配和进度同步都依赖 sizeCtl 和 transferIndex 上的 CAS 操作,不需要全局锁。最后一个完成搬运的线程,负责检查所有桶是否都处理完,把 table 引用指向新数组,并且重新计算 sizeCtl 为新的扩容阈值。
实际工作中,扩容效率的提升可能没有想象中那么巨大,因为锁竞争和 CAS 开销仍然存在,但相比旧版本单线程迁移,确实能明显缩短扩容导致的停顿时间。
6. 计数与 size:高并发下的计数方案
6.1 为什么不能只用一个 long 变量计数
直接用一个 long 类型的变量,每次 put 加 1、每次 remove 减 1,在低并发下没问题。但在高并发下,所有线程都要 CAS 修改同一个变量,CAS 失败后自旋重试,会导致严重的缓存行竞争和总线风暴。
Linux 下的 CPU 缓存一致性协议(MESI)会让多个 CPU 核心频繁地同步同一个缓存行,性能损耗非常大。这和我们平时用的 LongAdder 是同一个道理:不要所有线程都打同一个变量,把变量拆成多个槽位,每个线程只更新属于自己的槽位。
6.2 CounterCell 数组与 fullAddCount
ConcurrentHashMap 在 baseCount 的基础上增加了一个 CounterCell 数组。addCount 的流程可以简化为:
- 先尝试 CAS 更新 baseCount。
- 如果 CAS 失败,说明竞争来了,进入 CounterCell 数组逻辑。
- 通过 ThreadLocalRandom.getProbe() 算出一个线程相关随机值,定位到 CounterCell 数组的某个槽位。
- 如果槽位为空,创建一个 CounterCell 放进去。
- 如果槽位不为空,CAS 更新该 cell 的 value。
- 如果 CAS 仍然失败,说明这个槽位竞争太热,调用 fullAddCount,扩大 CounterCell 数组,让线程分布到更多槽位。
CounterCell 数组最大长度是 CPU 核心数,因为再多的槽位也没有物理上的并行能力了。这种设计跟 LongAdder 如出一辙,实际上 Doug Lea 写 ConcurrentHashMap 时确实借鉴了 LongAdder 的思想。
6.3 size() 的读取成本
size() 方法不是直接返回 baseCount,而是把 baseCount 和所有 CounterCell 的 value 全部加起来:
public int size() { long n = sumCount(); return (n < 0L) ? 0 : (n > Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n; }sumCount 遍历 CounterCell 数组,把所有 value 累加。这个过程没有加锁,所以得到的是一个近似值,在并发写入频繁时可能和真实值有偏差。如果你需要精确的、和某个时间点一致的 size,这个实现并不适合。很多面试题会在这里挖坑,问"ConcurrentHashMap 的 size 准确吗",答案是不保证强一致。
不过在绝大多数场景下这个 API 已经够用了,没必要为了精确 size 付出全局锁的代价。我们做业务的时候,如果非要一个稳定的 size,更常见的做法是额外维护一个原子计数器,或者用 LongAdder。
7. 工程里真正用得上的一些思考
7.1 我们能从这套设计里学到什么
ConcurrentHashMap 的源码我前前后后读过好几遍,每读一遍都会有一些新的体会。它给我最大的启发不是某个具体的知识,而是"并发控制级别的选择":
- 如果能用原子操作解决,就不要上锁。空桶插入用 CAS,计数用 CAS,这是所有竞争里最轻量级的一类。
- 如果必须上锁,锁的范围越小越好。JDK 8 只锁桶头节点,而不是锁整个 table,也不是锁一个 Segment。
- 复杂的并发任务,用 CAS 协调进度和状态,用锁保护具体的数据修改。扩容里任务分配用 CAS,链表修改用 synchronized,两者各司其职。
在日常业务开发中,我经常看到有的同学一谈到线程安全就想加锁,结果所有线程互相等待,并发变成串行。其实很多场景先用原子变量,再用细粒度锁,性能会好很多。
7.2 实际使用时的几点提醒
基于我踩过的坑,提几个使用 ConcurrentHashMap 时容易忽略的问题。
第一,key 的 hashCode 质量非常关键。如果 hashCode 写得很差,比如大量 key 的 hashCode 值相等,所有节点都会挤在同一个桶里,锁竞争就没法避免。对于自定义对象作为 key,一定要实现一个分散度高、稳定的 hashCode。
第二,computeIfAbsent方法里不要做耗时操作或者递归插入同一个 map。JDK 8 的 computeIfAbsent 在对应桶上加锁,如果 lambda 里做了耗时逻辑,其他访问同一桶的线程都会被阻塞。我见过有人在这个 lambda 里发 HTTP 请求,线上直接卡死。可以把耗时操作放在外面,或者使用 putIfAbsent 这种无回调的方法,加上自己的初始化逻辑。
第三,允许 null 的问题。ConcurrentHashMap 不允许 null key 和 null value,如果你往里放 null,包装类型不会被拆箱,直接抛 NullPointerException。原因很简单,在并发环境下无法区分 value 是 null 还是不存在。这在写通用工具类时很容易踩,尤其不要把接收到的集合直接转成 ConcurrentHashMap。
第四,使用迭代器做聚合计算时要明白弱一致性。统计数据可能不是实时的,如果有强一致性要求,用 synchronized 锁住整个 map,或者先快照到普通 list。但这个快照操作也要小心,直接在迭代时复制会暴露弱一致性问题,且可能出现线程切换期间的数据变化。
7.3 性能调优的一点建议
对于高并发写入的业务,初始化容量最好直接给足,比如new ConcurrentHashMap<>(expectedSize / 0.75f + 1),避免频繁扩容。扩容虽然支持多线程协作,但毕竟需要大量搬迁,性能开销不是免费的。
如果并发量非常大,且写多读少,可以考虑用 LongAdder 单独维护业务指标的计数,不要依赖 ConcurrentHashMap 的 size(),因为它要遍历 CounterCell 数组,高频调用时也有开销。
JDK 8 之后,ConcurrentHashMap 已经是一个可靠性非常高的组件,绝大多数场景不需要自己造轮子。真正需要你投入精力的,往往是理解它的行为,然后在正确的场景里正确地使用它。这也是我写了这么多年并发代码之后最大的感悟。