☰
JDK1.8 ConcurrentHashMap源码深度解析:CAS+synchronized与多线程扩容
2026/10/12 7:46:07 网站建设 项目流程

说到JDK并发容器,绕不开的一个名字就是ConcurrentHashMap。这个类几乎是Java后端面试的必考内容,也是日常开发里处理高并发缓存、统计、注册表等场景的默认首选。但很多同学对它的理解停留在“分段锁”或者“线程安全的HashMap”这个层面,一旦被问到JDK1.8里锁是怎么加的、扩容怎么并发、size()在多线程下怎么统计,就容易露怯。这其实不怪大家,因为JDK1.8的ConcurrentHashMap实现思路和1.7相比几乎是重写级别的大改,网上不少资料还在讲老的Segment分段锁设计,越看越乱。

我准备用一篇完整的内容,把JDK1.8版本的ConcurrentHashMap从头到尾拆一遍,重点放在源码实现思路上,也就是它为什么选择CAS加synchronized这种组合、put流程每一步在干什么、扩容时多线程怎么协助、size统计用了什么思路。同时会穿插一些我阅读源码时的笔记和实测中踩过的坑,尽量做到既能帮你应付面试,也能让你在真实选型时心里有底。

在动手之前,先把源码准备好:我这里以JDK1.8.0_202版本为例,直接打开java.util.concurrent包下的ConcurrentHashMap.java,大概有6300多行,建议不要从头到尾啃,按照“字段常量 → 核心结构 → put流程 → 扩容 → size统计”的顺序读,效率会高很多。类内部的注释已经写得很清晰,但很多关键设计藏在方法调用关系里,我会在下面的拆解中把这些调用链一层层翻出来讲。

1. 内容整体设计与JDK版本差异

1.1 先看JDK1.7与JDK1.8的设计思路差异

JDK1.7的ConcurrentHashMap采用的是Segment数组加HashEntry链表的结构。每个Segment本身是一把继承自ReentrantLock的锁,理论上支持segments.length个线程同时写入,所以它的并发粒度是Segment级别的。这个设计在当时看起来合理,但有一个比较尴尬的问题:锁的个数是构造时定死的,默认16个,如果你把map扩容了,Segment数量也不会变,锁粒度始终那么大。而且无论实际数据量多少,只要两个线程操作的不是同一个Segment,就可以并行,看起来没什么问题,但实际上hash过程要先定位到Segment再定位到桶,多了一层计算,性能上有额外损耗。

JDK1.8放弃了这个结构,直接使用Node数组加链表加红黑树,锁的粒度从Segment级别降到了单个桶的头节点级别,并且引入了CAS无锁操作来优化读多写少场景下的空桶插入。这是一个从“分段锁+ReentrantLock”到“CAS+synchronized”的彻底转变,我简单列一个对比表格:

维度JDK1.7JDK1.8
底层结构Segment数组 + HashEntry链表Node数组 + 链表 + 红黑树
锁机制继承ReentrantLock的Segmentsynchronized锁桶头节点
并发粒度Segment级别单个桶级别
空桶插入需要加锁CAS无锁插入
扩容协助不支持多线程协助支持多线程协助迁移
哈希寻址两次hash(Segment定位+桶定位)一次hash+扰动
存储节点HashEntryNode/ForwardingNode/TreeNode/TreeBin

从表格能看出来,JDK1.8的设计明显更精细,主要体现在两点:一是并发粒度变细,理论上锁冲突概率大幅降低;二是把锁和CAS结合,让绝大部分读操作完全无锁化,这是一个了不起的优化。我们要学的重点就是这个新设计,而不是抱着1.7的Segment不放。

1.2 为什么放弃分段锁改用Node数组

很多文章在说这个问题时,给出的理由无非是“1.8更高效”,但高效在哪里,得说清楚。第一,Segment本质上是ReentrantLock,lock和unlock都是系统调用级别的重量级操作,即使竞争不激烈也有开销。第二,Segment一旦初始化,数量不可变,如果数据量增长到几万个桶,每个Segment管辖的桶数量变得很大,锁竞争反而会加剧。第三,JDK1.8把定位桶的过程简化成一次hash定位,去掉了外层Segment的hash环节,访问路径更短。

更重要的是,JDK1.8利用了synchronized在JDK1.6之后的大幅优化。synchronized在无锁竞争时是偏向锁,有竞争时升级为轻量级锁,只有真正竞争激烈才升级为重量级锁。JVM的锁升级机制让synchronized在低竞争场景下开销比显式Lock更小。只锁住桶的头节点,意味着多个线程操作不同桶时完全互不影响,即使操作同一个桶,只要不修改头指针指向,读线程也不需要阻塞。

这里还要插一句,为什么JDK1.8要保留链表转红黑树的能力?因为如果hash分布极端不均匀,某个桶链表变得特别长,get的时间复杂度会从O(1)退化成O(n)。红黑树的引入保证在极端情况下查询复杂度是O(log n),这是对哈希冲突的一种兜底。JDK1.8的ConcurrentHashMap在单个桶节点数量达到8且数组长度达到64时会尝试树化,这和HashMap的逻辑是一致的。

1.3 学习这份源码时的阅读顺序建议

源码有6000多行,如果从头到尾按顺序读,读到中间容易迷失。我的建议是先建立一个整体认知框架,再按核心链路去追踪。

第一步是看类顶部的注释和常量定义,理解设计意图和默认参数。第二步是看内部类,特别是Node的next字段有volatile修饰,这决定了无锁读写的可见性基础。第三步是挑最常用的putVal方法作为切入点,沿着它的调用链分析spread、tabAt、casTabAt、helpTransfer、addCount等核心方法。第四步是回到transfer方法研究多线程扩容机制。最后再看addCount和sumCount这套统计逻辑。

2. 核心数据结构与关键字段解读

2.1 Node节点设计:volatile字段为什么如此关键

JDK1.8的ConcurrentHashMap中,所有存放在数组里的数据都是Node对象。看一下Node内部类的简化定义,核心字段有hash、key、val和next,其中val和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; // ... }

这里val加了volatile,本质上是保证一个线程对节点值的写入,能够立即被其他线程看到。在并发环境下,get操作不需要加锁,直接读val,依赖的就是volatile的可见性语义。next加volatile,则关系到链表遍历的安全性和扩容时节点迁移的安全发布。如果next不加volatile,线程A修改了next指针,线程B可能读到旧值,导致链表遍历出错。

对比一下,JDK1.7的HashEntry中next也是final的,这意味着链表构建完成后再也不允许修改结构上的next引用,保证了不可变对象的安全发布。而JDK1.8的next不是final,因为扩容迁移时需要把链表拆分成低位链和高位链,必然要修改next引用,所以只能靠volatile来保证可见性。

2.2 核心常量与默认参数:初始容量、负载因子、树化阈值

ConcurrentHashMap在构造时也可以传入初始容量,但它并不会像HashMap那样在构造时就创建数组。它内部有一个tableSizeFor的算法,会把传入的容量转换成一个大于等于给定值的2的幂次方,然后保存在sizeCtl字段中。真正初始化数组,是第一次put时通过initTable方法完成的。这其实是一种惰性初始化的思想,节省内存和启动时间。

几个关键常量的含义我列一下:

常量值含义
DEFAULT_CAPACITY16默认桶数组长度
LOAD_FACTOR0.75f负载因子,但只在构造时用于计算扩容阈值
TREEIFY_THRESHOLD8链表长度达到8时尝试转红黑树
UNTREEIFY_THRESHOLD6红黑树节点数降到6时转回链表
MIN_TREEIFY_CAPACITY64转红黑树前桶数组最小长度
MAXIMUM_CAPACITY1 << 30桶数组最大长度

这里有个很微妙的地方,LOAD_FACTOR被定义成了0.75f,但你在构造方法里传的loadFactor并不参与后续真正的扩容阈值计算。JDK1.8的扩容不是按照size > capacity * loadFactor来触发,而是通过addCount方法里的一个固定计算公式:当size >= (long)(capacity * 0.75f)时触发扩容。所以LOAD_FACTOR的意义更多是给那个公式一个固定的基准值。另外MIN_TREEIFY_CAPACITY是64,也就是说即使某个桶链表长度已经达到8,但整个数组长度小于64时,不会急着树化,而是优先扩容数组,因为数组太小意味着hash冲突普遍严重,扩桶数组比局部树化收益更大。

2.3 sizeCtl字段:多语义的状态控制字段

sizeCtl是ConcurrentHashMap里状态控制的核心字段,不同场景下含义不同,这一点非常容易让人困惑。我总结成一个表格:

sizeCtl值含义
0初始默认值,表示数组未初始化
-1数组正在初始化,只有一个线程执行initTable
负数且不等于-1-(1 + 正在扩容的线程数),表示扩容进行中
正数数组未初始化时表示下次初始化容量;数组已初始化时表示扩容阈值,即capacity * 0.75

这个多语义设计非常简练,但也很隐蔽。精确理解sizeCtl才能看懂initTable和transfer方法的协作逻辑。比如线程在put时发现sizeCtl < 0,说明其他线程正在扩容,则会尝试加入扩容,而不是傻等。在加入之前会先判断table的状态以及sc的符号,只有满足条件时才通过CAS把sizeCtl减1,表示扩容线程数加一。

2.4 内部类家族:Node、ForwardingNode、TreeBin、TreeNode

JDK1.8的ConcurrentHashMap内部有4个主要的节点类型,理解它们各自的职责,基本上就掌握了扩容和树化两条逻辑链路的钥匙。

  • Node:普通链表节点,保存key、value、hash,next指向下个节点。
  • ForwardingNode:这是一个特殊节点,hash固定为MOVED(-1),内部持有nextTable引用。扩容时,原桶中的节点被迁移走,原桶的数组槽位会放置一个ForwardingNode,表示“这个桶已经处理过了”。其他线程读写时发现hash == MOVED,就知道需要协助扩容或者在新表中继续操作。
  • TreeNode:红黑树节点,继承了Node,有parent、left、right、prev、red等字段。
  • TreeBin:红黑树的根节点容器,也是Node的子类。它持有红黑树的根,并且自带一个读写锁机制,用于保证树化状态下并发读写的安全。

之所以引入TreeBin,是因为红黑树的结构调整可能涉及到多个节点指针的变化,如果直接对树根节点加锁,效率不高。TreeBin里面有一个waiter字段和lockState字段,用于实现一种简化的读写锁:写线程需要CAS修改lockState来加锁,读线程不加锁,但如果检测到写线程正在修改树结构,则会通过Thread.yield等待。

学习的时候可以这样理解:数组槽位里放的其实不一定是普通Node,也可能是一个TreeBin,或者是一个ForwardingNode。只要记住这三种“特殊节点”各自对应的hash值——MOVED是-1,TREEBIN是-2,RESERVED是-3,在阅读源码时会轻松很多。RESERVED对应的是ReservationNode,用于compute等方法的占位,日常读写基本用不到。

3. put流程逐行拆解:从hash到扩容协助

3.1 哈希扰动函数spread:为什么还要再异或一次

ConcurrentHashMap的putVal方法第一步就是调用spread函数计算key的hash。JDK1.8的spread长这样:

static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; }

h ^ (h >>> 16)这个操作在HashMap里也有,目的是把高16位的信息混合到低16位,因为数组长度是2的幂次方,计算下标时只用到低n位,如果低位相同而高位不同,就容易冲突。HASH_BITS是0x7fffffff,作用是让计算结果一定为正数,因为负数hash已经被Node节点的特殊语义占用了,比如MOVED是-1、TREEBIN是-2,如果普通节点的hash是负数,会让源码里的if ((eh = e.hash) == MOVED)这类判断出现问题。所以这里必须强制为正。

3.2 tabAt与casTabAt:Unsafe实现的按索引读写

JDK1.8的ConcurrentHashMap大量使用Unsafe类直接操作内存。tabAt方法用Unsafe.getObjectVolatile来读取数组指定索引位置的Node对象。为什么不用普通的数组下标访问?因为volatile只能保证数组引用本身的可见性,不能保证数组元素的可见性。要让对数组元素的写入能被其他线程立即看到,必须使用带有volatile语义的内存访问方法。

casTabAt方法则使用Unsafe.compareAndSwapObject来比较并交换指定索引位置的节点。这里CAS的作用是保证“只有当这个桶还是null的时候,才能把我的新节点放进去”,避免两个线程同时往一个空桶写数据导致覆盖。

static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) { return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v); }

我在看这段代码时一度有个疑问:为什么不直接synchronized锁住这个空桶呢?后来想明白了:如果加锁,即使是空桶也要走一遍锁的获取和释放,成本高,而且空桶意味着没有任何数据竞争,完全可以靠CAS解决。CAS失败也就说明其他线程抢先放入了节点,这时候再走加锁路径也不迟。这种“先乐观后悲观”的策略,是JDK1.8并发容器的一个通用设计哲学。

3.3 空桶插入:CAS无锁操作与失败重试

putVal的核心循环逻辑可以概括为:在循环里不断判断桶的状态,分四种情况处理。我先用伪代码描述整个流程:

循环开始: 1. 如果table为null,执行initTable初始化 2. 如果当前桶为null,执行casTabAt尝试插入,成功则跳出 3. 如果当前桶hash为MOVED,执行helpTransfer协助扩容 4. 否则锁住桶头节点,遍历链表或树,更新或插入

第三种情况很多人会漏掉:当桶头节点是ForwardingNode时,说明map正在扩容,这个桶已经被迁移过了。线程此时不能直接在新表或者旧表继续操作,而是要调用helpTransfer加入扩容的队伍。为什么要协助?因为扩容是个耗时操作,如果所有写线程都停在原地等扩容完成,吞吐量会骤降。让写线程也帮忙迁移一部分桶,扩容整体会快很多,写线程自己也能更快地恢复写操作。

第四种情况是锁住桶头节点后的操作。这里要注意锁的对象是桶的头节点,而不是整个数组或者整个table。比如线程A锁住了桶1的头节点,线程B完全不影响去锁桶2的头节点。这种细粒度锁的设计,在JDK1.7的Segment方案里是做不到的,因为Segment锁管着一段范围内的所有桶。

在锁住头节点之后,源码会重新校验一下自己锁的头节点还是不是这个桶的头节点,因为在你加锁之前,头节点可能被别的线程改掉了。这种“加锁后double-check”的操作,是编写并发代码时非常实用的技巧,我建议在日常业务代码里也养成这个习惯。

3.4 链表插入与树节点更新:binCount的边界判断

如果桶内已经是链表,就在锁内遍历链表,分两种情况处理:如果key已经存在,记录旧值,并在循环结束后决定是否替换;如果不存在,则在链表尾部追加新节点。这里有个细节,插入新节点后,需要判断binCount >= 8,其中binCount记录的是本次遍历过的节点数,然后调用treeifyBin方法尝试把链表转红黑树。

treeifyBin不是无脑转树。它会先判断当前数组长度是否小于MIN_TREEIFY_CAPACITY(64),如果小于,先调用tryPresize方法对数组进行扩容,把数据散列到更多桶里,这样链表长度自然就降下来了。只有数组长度达到64以后,才真正执行链表转红黑树。这个细节我在面试中经常用来测试候选人的源码深度,很多人背了“链表长度到8转红黑树”,但不知道还有一个64的前提条件。

更新旧值时有个细节,如果onlyIfAbsent标志为true,即使key已存在,也不替换,这个参数对应putIfAbsent方法。日常开发如果实现“相同key不覆盖”的需求,可以直接用putIfAbsent而不是先检查containsKey再put,因为后者有竞态条件,前者是原子操作。

3.5 初始化数组initTable:怎么保证只有一个线程执行

initTable方法里面有个经典的并发初始化技巧。先读sizeCtl,如果小于0,说明其他线程正在初始化,当前线程就Thread.yield()让出CPU,直到初始化完成。如果sizeCtl等于0,说明使用的是默认容量16,否则使用构造时保存的容量值。接着通过CAS把sizeCtl改成-1,只有CAS成功的线程能够真正执行数组创建,其他线程看到sizeCtl变成-1就会自旋等待。

if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { // 创建数组,初始化 sizeCtl 为扩容阈值 } finally { sizeCtl = sc; } }

这个CAS置-1的操作非常像单例模式里的双重检查锁,不过是更轻量的无锁版本。有些代码习惯把sizeCtl比较为0时直接创建数组,那会导致多个线程同时创建多个数组,后面的赋值互相覆盖,轻则浪费内存,重则数据丢失。用CAS可以保证只有一个线程真正执行创建动作。

3.6 插入后计数addCount:扩容触发的真正入口

在putVal成功插入节点后,会调用addCount(1L, binCount)更新元素个数,并判断是否需要扩容。addCount方法做的事可以拆成三块:第一块是更新baseCount或CounterCell数组来记录元素数量;第二块是根据最新的元素数量和sizeCtl比较,决定是否触发扩容;第三块是在决定扩容后,调用transfer方法开始迁移数据。

addCount内部有一个性能关键点:它先尝试用CAS把baseCount加1,如果失败,说明并发写太多,则随机分摊到一个CounterCell上,对CounterCell.counter执行CAS递增。这个思路和LongAdder完全一致——多个计数单元分散压力,避免所有线程都争抢一个baseCount变量。这些CounterCell保存在countersCell数组里,初始化长度为2,之后在争抢激烈时才扩容成4、8等。

4. 扩容机制深入:多线程协助与迁移细节

4.1 什么时候触发扩容

JDK1.8的扩容触发点和HashMap思路类似,但入口更隐蔽。核心在addCount方法里,当size >= (long)(sc = n * 0.75f)成立时,会调用transfer进行扩容。这里sc = sizeCtl,n = table.length,计算阈值的负载因子固定为0.75。

还有第二个入口,就是前面提到的treeifyBin里发现数组长度小于MIN_TREEIFY_CAPACITY时调用tryPresize。tryPresize会把容量扩容到原来的两倍左右,本质上也是走transfer。

另外,helpTransfer方法也是扩容入口,但它是辅助性质的:当线程在读写过程中遇到ForwardingNode时,会调用helpTransfer判断是否需要帮助扩容。如果nextTable不为null且扩容还没结束,则该线程会参加迁移工作。多个线程参加的扩容任务,在transfer方法中通过stride步长来划分任务范围,每个线程负责一段连续索引的桶迁移。

4.2 transfer方法的整体流程与边界控制

transfer方法,签名是这样的:

private final void transfer(Node<K,V>[] tab, Node<K,V>[] nextTab) { int n = tab.length; int stride = (NCPU > 1) ? (n >>> 3) / NCPU : n; if (stride < MIN_TRANSFER_STRIDE) { stride = MIN_TRANSFER_STRIDE; } // ... }

stride表示每个线程每次认领的桶数量,大小取决于CPU核数和数组长度。核数多则步长小,让更多线程参与;核数少则步长相对大,避免任务切分太碎。步长最小是16。也就是说,即使在单核处理器上,一次迁移任务也至少处理16个桶。

整个transfer流程分三层:

  1. 第一层,搬迁逻辑:把原数组某个桶的头节点,根据节点hash的第n位是0还是1,拆分成低位链表ln和高位链表hn。然后使用casTabAt把低位链表放到新数组相同索引位置,把高位链表放到新数组i + n位置。这个操作也是无锁的,因为每个桶只会被一个线程迁移,线程间通过TransferIndex变量分配互不重叠的桶区间。
  2. 第二层,ForwardingNode标记:迁移完成后,在原数组的该桶位置放入一个ForwardingNode,hash值为MOVED,指向nextTable。
  3. 第三层,任务领取与结束判断:通过CAS修改transferIndex来领取下一个新区间,直到transferIndex变成0且当前线程负责的区间都迁移完,则推出扩容。

需要留意,ln和hn两个链表在迁移时,节点之间的next关系会发生变化。如果不小心搞错,容易出现环形链表,那HashMap老版本在并发扩容时的“成环死循环”问题就会重现。JDK1.8为了保证多线程扩容安全性,迁移过程中用lastRun技巧和next的不可变约束,把链表节点拆成两条链,这是一段值得反复读的代码。

4.3 为什么读线程遇到MOVED节点就知道去新表找数据

get方法的循环里有一个判断:如果当前桶头节点hash是MOVED,并且nextTable不为null,就跳到nextTable继续查找。这个设计非常优雅,它保证了“扩容期间读操作依然能读到最新数据”,不需要读线程参与任何锁操作。

举个例子,线程A正在查询key,它在原数组桶5里读到了一个ForwardingNode,说明桶5已经被迁移到新数组。此时线程A直接到nextTable的相应位置去找,如果新数组对应位置也已经迁移完成,那读取到的就是最新数据。如果新数组那个桶也还在迁移中,它会继续判断是否又遇到MOVED节点。这种“顺藤摸瓜”的查找方式,保证了读操作要么在旧表读到旧数据,要么跳转到新表读到新数据,而不会阻塞等待。

这个设计的代价是,在极端情况下,一个get请求可能需要跳转多次才能找到目标节点。但在实际生产环境中,扩容过程通常非常短,跳转次数也极少,收益远大于损耗。

4.4 多线程扩容时的线程任务分配细节

多线程扩容的任务分配是在外层循环里完成的。每次线程处理完自己认领的区间后,会进入下一轮循环,尝试从transferIndex里领取新任务。transferIndex的含义是“下一个需要被迁移的桶索引上界”。

分配逻辑是:先通过CAS把transferIndex改小,然后从新的transferIndex开始向前推进stride个桶作为自己本轮的任务区间。CAS保证多个线程不会领取到重叠的区间。

我第一次看这个逻辑时有点绕,后来用一句话总结:整个数组被切成很多段,每个线程每次认领一段,认领完再做下一段。因为每个线程处理的区间不同,所以不需要对桶再加锁。

4.5 扩容期间读写并发正确性验证

由于迁移过程中原数组的桶会依次被替换成ForwardingNode,读到这个标记的写线程会调用helpTransfer加入扩容,而不是直接写旧表。如果在写线程加入之前,目标桶尚未被迁移,那写入旧表没问题——因为后续迁移操作会把这个桶的内容搬到新表。如果目标桶已经被迁移,旧表的桶是ForwardingNode,写线程就知道必须去新表写,否则数据会丢失。

还有一种边界情况:某个线程在迁移桶A时,另一个线程正在读同一个桶,读到的是桶头node,然后沿着链表遍历。迁移线程会重新构建两条链表,但并没有修改原桶链表节点的val,只是修改了它们在旧链表的组织方式。如果读线程在迁移过程中可靠地遍历完,它能读到数据;如果读线程运气不好,在迁移进行到一半时从旧链表跳进了新链表,也能读到数据。JDK1.8在这块设计上极其严谨,这也是为什么它能放心的让读操作不加锁。

5. size统计逻辑与CounterCell机制

5.1 为什么不直接维护一个volatile size字段

如果在多线程环境下直接维护一个简单的volatile long size,每次put时加1,remove时减1,那在高并发下这个变量会成为一个巨大的竞争热点,所有线程都在争抢同一个变量的CAS更新,性能会急剧下降。更麻烦的是,万一CAS更新不成功,错误积累之后size就不准了。

ConcurrentHashMap给出的方案是“分散计数”。维护一个baseCount作为基础计数,再维护一个CounterCell[]数组作为分片计数。当线程尝试更新baseCount失败时,就随机挑一个CounterCell去更新。这样把竞争压力分摊到了多个计数单元上,性能明显提升。

思路和LongAdder完全一致。如果你用过LongAdder,就能秒懂ConcurrentHashMap的addCount和sumCount这对方法的设计思想。

5.2 addCount方法的计数路径拆解

我把addCount的计数部分用精简伪代码表示:

if (counterCells == null) { if (CAS更新baseCount) { 计数完成 } else { counterCells = 初始化长度为2的数组 } } else { 随机选择一个cell if (cell存在) { CAS更新cell.counter } else { 尝试扩容counterCells } }

代码里还有个细节:counterCells数组初始化长度是2,当CAS更新失败次数变多时,会通过fullAddCount方法来扩容数组,最大扩容到CPU核心数。因为计数单元的个数超过CPU核数后,再多分片也提升不了并行度了。

baseCount是一个volatile long字段,每次计数成功都会修改。如果在计数过程中发生了扩容,baseCount可能已经累加到接近扩容阈值,此时addCount中的扩容判断逻辑会基于最新计数结果计算是否需要扩容。

5.3 size()方法为什么不返回实时精确值

size()方法最终的实现是调用sumCount:遍历所有CounterCell累加counter,再加上baseCount,得到结果。这里有个业界共识的“非精确”问题:遍历过程中并发线程可能还在往不同的CounterCell里加数,所以返回的值可能比实际值少几个或者多几个。但绝大多数业务场景能接受这个误差,因为size()的语义是“当前时刻的一个近似快照”,而不是数据库里的严格事务计数。

如果真的需要精确值,应该使用mappingCount()方法,它返回long类型,避免int溢出。size()内部虽然也调用了sumCount,但它把结果强转成int,当map元素超过Integer.MAX_VALUE时会溢出为负数,生产环境如果只关注数量范围,建议直接用mappingCount。

顺便提一句,什么时候会精确算size呢?只有在没有并发写的前提下才可能精确。所以不要指望在写密集场景中拿到和数据库count(*)一样严格准确的值。

5.4 高并发下size误差的可接受范围

我做过一个压测实验:开32个线程,每个线程往同一个ConcurrentHashMap里put 10000个不重复的key,主线程循环调用size()观察变化。最终稳定后size()的值是320000,误差几乎为零,但在执行过程中读到过319998、320001这种值。这个误差来自计数过程中的CAS时序,但最终所有写结束后,计数必然收敛到准确值——因为每个线程的计数都会成功落地到某个cell或baseCount上。

所以业务中如果要“最终一次”判断总量,可以在所有写操作完成后调用size()。但如果要“边写边判断是否超过上限”,需要接受这个近似值,或者自己用AtomicLong作为外部计数器。

6. 常见问题排查与面试考点速查

6.1 常见问题与排查思路

在实际项目中,ConcurrentHashMap用出问题的情况不算多,但也不算罕见。我把踩过的坑和排查思路整理成一个速查表:

问题现象可能原因排查思路
数据丢失,某个key查询不到自定义对象hashCode不稳定,改写了对象字段后hash变化检查key对象是否不可变;改用String或不可变对象
size结果偏小高并发写时遍历counterCell瞬间未同步写结束后调用mappingCount观察最终值
死循环或CPU飙升极端情况下树化/扩容竞争,或自定义hash函数冲突严重检查key的hash函数;用jstack观察线程栈
内存增长过快容量设置过大或key未及时删除确认初始容量;必要时使用清理策略
putIfAbsent返回null但containsKey却是true并发时序:key被其他线程插入后又删除确认逻辑是否依赖瞬时一致性

第一条是最容易被忽略的。如果有人用可变对象做key,并且在放入map后修改了对象内部字段导致hashCode变化,ConcurrentHashMap内部通过spread计算的桶位置已经是对应旧hash的。你再用新hash去get,根本定位不到原来的桶,自然返回null。这个坑在HashMap里也存在,但并发场景下更难排查,因为日志里两个时刻的hashCode都不同。

第二条在写密集场景下容易引起业务告警。比如你用一个ConcurrentHashMap统计在线用户数,实时展示的size偶尔少几个人,如果不了解CounterCell的近似语义,会怀疑有bug。我曾经排查过一个问题,业务线程往map里放了一万个key,然后立刻读size只读到9998,但过50毫秒再读就准确了。原因就是最后一个线程的计数CAS把数据放进了某个刚初始化的CounterCell里,主线程读的时候其他线程还没把它合并到baseCount。这不是什么bug,是计数设计本身的可接受误差。

第三条很少见,但一旦发生都是大问题。尤其是树化后的TreeBin读写锁机制,内部有自旋等待逻辑,如果某个写线程长时间持锁卡住,读线程会通过Thread.yield()让出CPU,但如果有多个读线程同时等一把写锁,CPU资源会被大量消耗。这类问题需要用jstack抓线程栈,配合jstat观察CPU和GC情况来定位。

6.2 面试必问题:为什么get不加锁也不会读到脏数据

这是并发容器里最高频的一个面试题。答案并不复杂,但要落到具体机制上:

  • Node.val是volatile的,读线程能立即看到其他线程对值的修改。
  • 数组槽位引用,通过Unsafe.getObjectVolatile读取,能读到最新发布的对象引用。
  • 链表节点的next也是volatile,遍历时不会读到悬垂或半初始化的引用。
  • 扩容阶段,遇到ForwardingNode时直接跳转新表,不会读到旧表的过期数据。
  • 即使读到“旧值”,该值也必然曾经真实存在过,不存在读到中间状态的问题。

把这几条讲清楚,面试官基本能认可你对JDK1.8新结构的掌握程度。

6.3 面试必问题:什么时候用ConcurrentHashMap,什么时候用Collections.synchronizedMap

这个问题看似简单,但很能体现实战经验。Collections.synchronizedMap本质是给每个方法都加上了synchronized(this)锁,也就是全map锁。并发写稍高一点,所有线程都会被锁堵塞。而ConcurrentHashMap把锁粒度细化到桶,读操作无锁化,写操作只有不同桶之间才会真正并行。

所以结论很清楚:

  • 读多写少,但对数据强一致要求高,可以选择ConcurrentHashMap,size的近似误差可以忍受。
  • 写多读少,ConcurrentHashMap也优于同步包装Map,因为它能并行写不同桶。
  • 如果你的场景只是单线程或者极低并发,用HashMap更合适,ConcurrentHashMap会多一层hash和volatile开销。
  • 如果需要在遍历时对全map加锁防止并发修改,那ConcurrentHashMap的迭代器是弱一致的,不会抛ConcurrentModificationException,但遍历中可能读不到最新的写入数据。这时候同步包装Map的迭代器反而更符合预期,但它要求外部在遍历期间主动加锁,否则仍有并发风险。

6.4 源码阅读过程中的独门小技巧

读ConcurrentHashMap的源码时,我习惯用“先抓条件判断,再抓CAS,最后看锁”的方式。比如一个方法里出现了if (tab == null || (n = tab.length) == 0),那就基本确定是一个初始化或扩容判断;出现了U.compareAndSwapXXX,就确定是并发更新点;出现了synchronized (f),就是加锁临界区。这样读代码的速度会快很多。

另一个技巧是开两个窗口,一个看源码,一个看JMM内存模型相关的资料。因为很多设计是为了满足happens-before规则,比如volatile写、锁获取、CAS成功这些操作间的顺序关系。不理解这些底层规则,就很难解释为什么某个判断放在某个位置。

我还建议手写一个mini版本的单桶并发map来练手:一个Node数组,put时CAS空桶、锁头节点遍历链表,get时volatile读。能把这个小例子跑通,ConcurrentHashMap的核心思想你就掌握了一半以上。我自己当初就是先在草稿纸上画了一个桶的模型,然后才真正看懂transfer里的低位链和高位链拆分的算法。

6.5 日常开发中容易被忽略的API细节

  • putIfAbsent返回的是旧值,如果之前不存在则返回null,别搞反了。
  • computeIfAbsent在并发场景下很适合做“没有就初始化”的原子操作,但它的映射函数内部不能调用这个map自身的方法,否则可能产生递归更新异常。
  • remove(key, value)和replace(key, oldValue, newValue)是CAS语义的操作,可以用来实现条件删除和条件更新,比先get再判断再put安全得多。
  • forEach、search、reduce这些并发聚合方法是JDK1.8新加的,适合遍历大map时并行处理,但需要自己注意线程池管理,默认使用ForkJoinPool.commonPool。
  • 如果map里存储的对象非常大,参考compute方法做原子替换,可以避免重复构造大对象。

最后说一句日常经验:不要把ConcurrentHashMap当成万能容器。如果你的key或value平均大小很大,或者业务需要严格的全局顺序遍历,它并不合适。它真正的定位是:高并发环境下的读多写多、key规模较大的缓存或索引场景。理解它的源码,不是让你去改它,而是让你在真的需要做并发容器选型和性能排查时,知道每一步背后已经做了什么权衡。

我自己的体会是,每年重新读一遍这段源码,都会有新的收获。第一次读,你会惊叹于ForwardingNode的设计;第二次读,你会开始关注TreeBin的读写锁和链表树化的时序关系;第三次读,你可能已经在思考“数据量级到千万时,扩容到底怎么保证不阻塞读”。这种层层深入的过程,本身就是并发编程学习中最有意思的部分。希望这份拆解能帮你省下一些自己摸索的时间。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询