1. 开篇:我为什么要啃ConcurrentHashMap源码,以及你能得到什么
1.1 一次线上事故让我重新面对这张表
前几年在某公司做高并发系统时,我遇到过一场至今印象深刻的线上事故。某个内部路由表在业务高峰期被高频写入,进程CPU瞬时飙到接近100%,接口耗时从几十毫秒一下子涨到好几秒,整个JVM像是卡死在原地。日志翻来翻去看不出业务逻辑有什么问题,最后才发现是ConcurrentHashMap在反复扩容,大量线程同时进入transfer阶段的锁竞争和CAS争抢,把CPU全部吃满了。那次之后我就下定决心,不把ConcurrentHashMap底层源码啃明白,绝对不碰线上高并发场景。
那时候我才反应过来,很多人在项目里用ConcurrentHashMap,其实只停留在“它是线程安全的HashMap”这层认知上。put和get内部怎么协作、锁到底加在哪里、扩容时其他线程做什么、size()为什么会有偏差,这些一概不清楚。其实这些恰恰是并发编程里最值钱的部分,也是线上问题的高发地带。
1.2 这篇文章打算讲清楚什么
这篇文章围绕一条主线拆解:JDK 8之后的ConcurrentHashMap,是如何用“CAS + synchronized + volatile”这三大基础设施,实现一个读多写少场景下接近无锁、写冲突时又尽量局部化的线程安全哈希表。我会按照“整体设计 → 数据结构 → 核心操作 → 扩容协作 → 实战避坑”的顺序来讲,重点解释每个关键步骤背后的“为什么”,而不是简单把源码注释一遍。
文章覆盖的内容包括:JDK 7分段锁为什么被淘汰;table、sizeCtl、nextTable三个关键字段怎么协作;Node、ForwardingNode、TreeBin、ReservationNode四种节点各自的用途;put/get/remove完整链路源码;transfer多线程扩容的协作机制;以及生产环境中最常见的几个误用场景。无论你是准备并发方向面试,还是已经在线上被并发问题折磨过,这篇文章都值得花半小时认真看一遍。
2. 先搞懂整体设计:JDK 8为什么换掉“分段锁”
2.1 JDK 7的分段锁模型回顾
JDK 7的ConcurrentHashMap用的是Segment分段锁。它的核心思路是把整张表分成若干段,每个Segment本身继承自ReentrantLock,并维护一个自己的HashEntry数组。写操作先定位落到哪个Segment,然后只锁这一小段,而不是锁整个Map。默认情况下Segment数量是16,所以理论上最多支持16个线程同时写不同的段,锁竞争从“全局争抢”降级成“段间争抢”。
这个设计在当时已经算得上很漂亮的折中方案,但它存在几个绕不开的短板。第一,Segment数量在构造时就固定了,无法跟随数据规模动态调整,容量管理是分散且割裂的。第二,即使Segment再多,一旦多个线程写同一个段,照样会排队阻塞,锁的颗粒度还是太粗。第三,读操作虽然不加锁,但为了感知其他段的修改,需要去读每个Segment的count字段,这里面涉及volatile读和一定的缓存失效开销。整体上JDK 7的并发能力有上限,Segment数量成为吞吐量的天花板。
2.2 CAS + synchronized的新方案相比分段锁的收益
JDK 8直接废弃了Segment,底层改成单一的Node数组,配合三件套来保证线程安全。空桶位插入用CAS直接完成;桶位已经有节点时,写操作对这个桶的头节点加synchronized锁;读操作除了读取树形结构等少数情况外,全程不加锁。这一改带来的收益非常直观:
- 锁粒度从“一段”变成“一个桶”,不同桶位的写入互不干扰,只有发生hash冲突、多个key落在同一个桶里时才会竞争。
- 链表过长会树化成TreeBin,避免单桶查询从O(1)退化到O(n)。
- 扩容支持多线程协作搬运,多个线程可以认领不同的桶区间,同时迁移数据。
所以JDK 8换掉的不仅是锁的实现,而是整个并发模型。分段锁的定位是“用锁去划分竞争区域”,而CAS + synchronized的思路是“能用无锁就用无锁,真躲不过去了再局部加锁”。这个思路越往后看越能体会到它对高并发场景的深度适配。
2.3 volatile + Unsafe:无锁读的基础设施
JDK 8的ConcurrentHashMap能实现“读不加锁”,最关键的一点在于所有读操作都构建在volatile读写之上。table字段本身是volatile Node<K,V>[],读取某个桶的头节点用的是Unsafe层面的getObjectVolatile或getObjectAcquire,CAS写入用的是compareAndSetObject。这些操作保证读到的是某个线程已经发布出来的最新引用,而不是当前线程CPU缓存里的过期值。
这里有个很容易被忽略的基础知识点:对“引用数组”的volatile读,只能保证数组引用本身的可见性,不能保证数组里每个元素的可见性。正因为如此,源码里几乎不会直接用tab[i]去读数组元素,而是通过tabAt(tab, i)方法配合内存偏移量来模拟“对数组元素的volatile读”。理解了这一点,后面再看get和put的代码就能明白为什么到处都在调用tabAt、casTabAt、setTabAt,这些都是无锁读的底层支撑。
3. 核心数据结构逐项拆解:字段、节点和状态位
3.1 table、sizeCtl、nextTable三个关键字段
源码里最重要的字段其实就三个:
transient volatile Node<K,V>[] table; private transient volatile Node<K,V>[] nextTable; private transient volatile int sizeCtl;table:主哈希表本体,首次访问时通过initTable()懒加载创建。nextTable:扩容期间使用的新表。只有扩容进行中才不为null,扩容完成后会被置空。sizeCtl:这是整个类的“状态开关”,一个int扛起了多种含义。等于-1表示有线程正在初始化表;等于负数但不是-1时表示有线程正在扩容,低16位保存着扩容线程数加1之类的辅助信息;为正数时,在表未初始化前表示初始容量,表初始化之后表示触发扩容的阈值,默认大约是新容量的0.75倍。
sizeCtl这个字段是读源码时最容易绕晕的地方,因为它不是单纯保存一个数值,而是把多重含义压缩进一个int。我的建议是先记住它的“正数代表阈值、-1代表初始化、负数代表扩容中”这样一个粗粒度认知,等看到addCount和helpTransfer时再结合代码理解低16位和高16位的分工,那时候自然会通。
3.2 Node / ForwardingNode / TreeBin / ReservationNode
JDK 8的桶位节点一共有四种类型,理解它们的区别是看懂源码的前提。
普通Node是链表节点:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; }它保存了hash值、key、value和后继节点。val和next都是volatile的,这很重要,因为写线程在构造链表时只要先把key和val填好,再把next通过volatile发布出去,读线程就不会看到半初始化的链表结构。
ForwardingNode是扩容时留在旧表桶位里的“路标”,hash固定为MOVED(-1)。它的作用非常巧妙:任何线程读到这个节点,就知道对应桶的数据已经搬到新表了,读操作会顺着它去nextTable继续找,写操作会通过helpTransfer加入扩容。TreeBin则是不直接存放用户数据、而是作为红黑树根节点占住桶位,hash固定为TREEBIN(-2),内部持有红黑树和读写控制。ReservationNode出场频率很低,hash固定为RESERVED(-3),主要用在computeIfAbsent计算期间占位,防止其他线程对同一个空桶重复执行昂贵的初始化计算。
3.3 spread()哈希与地址计算里的小心思
每个键的hashCode在进入表之前都要经过spread处理:
static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; }h ^ (h >>> 16)把高16位和低16位做异或,让高位的信息也能参与到底部索引的运算,目的是让hash分布更均匀。& HASH_BITS(HASH_BITS = 0x7fffffff)则强制把符号位清零,保证结果hash是非负数。为什么必须是非负数?因为负数hash被内部征用了:-1给MOVED,-2给TREEBIN,-3给RESERVED。如果某个自定义hashCode经过spread后还能变成负数,那会直接和内部逻辑冲突,破坏整个Map的正确性。
计算桶索引用的是(n - 1) & hash,因为table长度永远是2的幂,n-1就是一组全1掩码,一次位运算就能完成取模,比%运算符快很多。这也是ConcurrentHashMap和HashMap都坚持扩容成两倍的根本原因。读源码看到这些位运算技巧时,不要只感叹“好厉害”,要想清楚它们解决的是真实性能问题——高并发下每次hash计算都快一点,整体吞吐就能差出一个量级。
4. put、get、remove核心链路源码详解
4.1 putVal完整流程:从CAS占坑到synchronized锁bin
put操作的核心封装在putVal方法里,我贴一段主干代码配合注释来说明:
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 (Node<K,V>[] tab = table;;) { Node<K,V> f; int n, i, fh; if (tab == null || (n = tab.length) == 0) tab = initTable(); // 1. 表还没建,先初始化 else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) { if (casTabAt(tab, i, null, // 2. 桶是空的,CAS直接放入 new Node<K,V>(hash, key, value, null))) break; // 成功则退出,失败则重试 } else if ((fh = f.hash) == MOVED) tab = helpTransfer(tab, f); // 3. 桶位是ForwardingNode,先帮忙扩容 else { V oldVal = null; synchronized (f) { // 4. 锁住桶头节点,再执行插入 if (tabAt(tab, i) == f) { // double-check:防止期间桶头被换 if (fh >= 0) { // 正常链表节点 binCount = 1; // 遍历链表:key存在则替换value,不存在则追加到尾部 } else if (f instanceof TreeBin) { // 树形节点 // 走红黑树插入逻辑 } } } if (binCount != 0) { if (binCount >= TREEIFY_THRESHOLD) treeifyBin(tab, i); // 5. 链表过长,触发树化 if (oldVal != null) return oldVal; break; } } } addCount(1L, binCount); // 6. 更新元素计数 return null; }这段代码有三个细节值得反复琢磨。第一,空桶插入不锁,用CAS保证并发下只有单个线程能成功放入,失败的线程会进入下一轮循环重新读取最新状态,相当于一种轻量自旋。第二,进入synchronized之前先判MOVED,进入锁内还要再用tabAt(tab, i) == f做一次双检,原因是有可能在线程等待锁的过程中,锁对象所在的桶已经被扩容线程搬走、桶头被替换成ForwardingNode。第三,锁的对象是桶头Node,而不是整张表,所以写不同桶的线程彼此完全无干扰,这才是高并发的底气来源。
4.2 get的无锁读如何保证线程安全
get方法的完整流程比put简单得多,代码大致如下:
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; // 命中桶头节点 } else if (eh < 0) return (p = e.find(h, key)) != null ? p.val : null; // ForwardingNode / TreeBin / ReservationNode while ((e = e.next) != null) { // 遍历链表 if (e.hash == h && ((ek = e.key) == key || (ek != null && key.equals(ek)))) return e.val; } } return null; }get不加锁还能保证线程安全,依赖的是三层可见性保障。第一层,table是volatile引用,读线程能拿到当前已发布的最新数组。第二层,tabAt对数组元素做Unsafe volatile读,拿到的是最新发布的桶头节点。第三层,普通Node的val和next都是volatile,并且链表构造顺序遵循“先写hash和key,再写val,最后发布next”,所以读线程要么看到完整的旧链表,要么看到完整的新链表,绝不会看到中间状态。
不过要强调一点,volatile保证的是最终可见性,不是全局时间一致性。get在并发写入时看到的可能是某个已经发生但尚未被“完全传播”给该线程的版本,这是弱一致性设计。绝大多数缓存、路由场景都能接受,但如果你需要的是严格的读取一致性,那就得另寻方案。
4.3 remove和replace的锁粒度控制细节
remove和replace底层都依赖replaceNode核心方法,逻辑和put很相似:定位到桶,先判断是否MOVED,不是MOVED再锁桶头节点,进入锁内重新校验桶头没变,然后遍历链表或者操作TreeBin找到匹配节点。删除链表节点时需要重新串联前后引用;删除树节点时调用removeTreeNode,如果树节点数量低到解除阈值(默认6),会把树还原回链表。
replaceNode里藏着一个很容易被低估的设计:它支持传入期望的旧值cv,只有当前值满足v == cv || (cv != null && cv.equals(v))时才真正替换或删除。这相当于提供了“条件删除”的能力,可以当作一个轻量级的CAS式接口来用。需要注意它锁定的只是一个键值对,不能拿来做跨键的原子变更,如果想实现“先读多个键再按条件更新”,还是要显式加锁。
5. 扩容机制transfer:多线程协作的精华
5.1 触发扩容的完整链路:addCount → resizeStamp → transfer
put成功后会调用addCount(1L, binCount)。它有两件事要做:更新元素计数,以及判断当前元素总数是否已经达到扩容阈值sizeCtl。如果阈值达成,就触发扩容。扩容触发时会用到resizeStamp方法:
static final int resizeStamp(int n) { return Integer.numberOfLeadingZeros(n) | (1 << (RESIZE_STAMP_BITS - 1)); }RESIZE_STAMP_BITS默认是16。resizeStamp把当前容量n的前导零数量放到结果的高位区域,并置上第16位,得到一个“扩容戳”。随后sizeCtl会被设置成(rs << RESIZE_STAMP_SHIFT) + 2。因为rs的高位保存了n的标识,低位保存了扩容线程参与数,源码就能用sizeCtl同时判断“现在是不是同一轮扩容”以及“有多少线程在帮忙”。这个设计我读第一遍时感觉很绕,后来画了状态图才真正明白,本质就是用一个int做“多信息编码”。
带两个关键阈值一起记会更清楚:第一个发起扩容的线程把sizeCtl从正阈值CAS改成(rs << 16) + 2;后续线程看到sizeCtl为负,判断sc >>> RESIZE_STAMP_SHIFT == rs后,可以把sizeCtl加1再声称自己参与扩容。最后一个做完收尾的线程会把sizeCtl恢复成新表的扩容阈值,也就是(n << 1) - (n >>> 1),约等于新容量的0.75倍。
5.2 数据迁移中的ln/hn拆分与节点转移
transfer方法里最核心的一段,是锁住旧表桶头之后,把旧链表的节点拆成两条:低位链ln和高位链hn。由于新表长度为旧表的两倍,原来索引为i的桶,迁移后元素要么继续留在索引i(低位),要么挪到索引i+n(高位)。判断依据是看节点hash的某一个比特位是否为0:
if ((runBit = (e.hash & n)) == 0) { // 归入低位链 ln } else { // 归入高位链 hn }源码里有一个lastRun优化:先找到链表中runBit相同的一段连续尾部,这整段节点可以原样迁移到新表,不需要逐个重建,在链表较长时性能优势明显。迁移完成后,旧桶位会被替换成一个ForwardingNode,新表对应位置则插入ln或hn。这里面的精巧之处在于,迁移期间依然可以接受新写入,只是写迁移中的桶需要等待锁释放,但迁移完成后的桶位就是ForwardingNode,写线程会自觉去新的表继续操作。
再往上一层看,迁移任务不是由一个线程独占完成的。transfer内部通过volatile的transferIndex字段来分配任务,每个线程通过CAS领取一段连续下标区间,默认最少处理16个桶。这种“认领制”既保证任务不重复,又避免了中央调度器成为瓶颈。多个线程可以并行迁移不同区间,整张表的搬迁速度会随着参与者增加而加快。
5.3 扩容期间读写线程如何保持有序
扩容期间,get线程如果走到一个ForwardingNode,会调用fwd.find(h, key)去nextTable里继续查找;写线程遇到MOVED时则会调用helpTransfer加入搬运。这种“路过搭把手”的设计,保证了扩容不会阻塞读,也能让其他写线程顺带加快扩容速度。
需要提醒的是,扩容并不是“某个线程阻塞所有写操作直到完成”的架子。实际上扩容过程中,写操作依然可以对已经迁完的桶和还没开始迁的桶直接操作,只有正在迁移的那一个桶会被短暂锁住。理解了这一点,你就能明白为什么JDK 8这套扩容能支撑高并发——它把一次全局搬迁打散成了多个局部任务,每个线程只付出很小的代价,就能参与到一次可能涉及上千万数据量的迁移中。
6. 实战避坑:ConcurrentHashMap的高频误用与排查心得
6.1 get + put不是原子操作,复合操作要换打法
实际开发里最常见的误用是“先get判断,再put设置”。比如:
if (!cache.containsKey(key)) { cache.put(key, loadFromDB(key)); }两个线程同时执行这段代码,就会重复加载数据,甚至后写覆盖先写。ConcurrentHashMap保证的是单次操作的原子性,不是跨操作的复合原子性。需要原子复合逻辑时,应该优先用computeIfAbsent、compute、merge。这些方法内部是在锁住桶头节点的基础上执行的,能保证同一个桶内对同一个key的操作互斥。但使用时要克制,不要在compute方法里做重活,否则持锁时间过长会拖慢其他写同一桶的线程。
6.2 频繁扩容会拖垮性能,预初始化会救你
回到开头那个线上事故,根因就是高频put触发了连续扩容。扩容本身的搬运工作量还可以接受,最怕的是大量线程同时发现“需要扩容”,然后一起进入CAS竞争。扩容期间sizeCtl的争抢和transfer阶段的锁等待会让很多线程短暂卡住,表现出来就是CPU飙升、接口耗时抖动。
解决方案其实很朴实:预估容量,创建时指定。new ConcurrentHashMap<>(expectedSize)直接设置初始容量,能显著减少扩容次数。对于缓存类、路由表类的Map,在初始化时就给足空间非常划算。如果不方便估算,也至少不要在热路径上用默认16容量硬扛百万级数据。
6.3 size()是近似值,别把它当精确统计
size()的实现是sumCount(),把baseCount和所有CounterCell的值加起来。CounterCell是LongAdder式的分段计数器,put和remove只会CAS更新线程自己随机选中的那个cell,目的是避免所有线程争抢同一个计数器。但这个求和过程没有全局锁,也没有冻结写入,所以返回的是“某个瞬间的近似值”。在并发写入很频繁时,它有可能会滞后或者轻微偏差。业务上如果拿size做过期判断、告警阈值,一定要留足余量,或者自己做一套可靠计数。
6.4 树化不是万能的,避免糟糕hash才是根本
链表长度超过TREEIFY_THRESHOLD(默认8)时会尝试树化,但前提是table长度达到MIN_TREEIFY_CAPACITY(默认64)。如果表还很小,treeifyBin只会触发扩容而不是树化。而且树化只是“兜底”,TreeNode的存储开销远大于普通Node,读写时还有额外的树结构调整成本。如果线上出现了大量树化,大概率是key的hashCode实现差到离谱,这时候应该优先去修复hash分布,而不是指望红黑树来救场。想验证分布,定期扫描bucket长度打印出来观察即可。
下面把常见的几类问题整理成一张速查表,方便遇到现象时快速对照:
| 常见误用 | 典型现象 | 根本原因 | 正确做法 |
|---|---|---|---|
| get后put做判断 | 重复加载、值被覆盖 | 复合操作不是原子的 | computeIfAbsent / merge |
| 默认容量扛大数据量 | CPU飙高、GC压力上升 | 频繁扩容 + 多线程迁移 | 构造时指定initialCapacity |
| 拿size做精确统计 | 结果滞后或轻微偏差 | CounterCell是分段近似计数 | 留余量或自建精确计数 |
| key的hashCode质量差 | 大量树化、读写性能下降 | hash碰撞严重 | 换用高质量hash或包装key |
7. 写在最后:源码读完之后的一些体会
7.1 这套设计给我的启发
ConcurrentHashMap源码读了这么多遍,我最深的感受是:它最打动人的不是某个精巧的算法,而是一套清醒的取舍逻辑。读多写少的场景下,它宁可容忍弱一致性也要让读操作全程无锁;写操作则是能CAS就不加锁,实在躲不过冲突才锁局部桶位。这种“把无锁和锁用在各自最合适的位置”的思维方式,比任何一个单独的数据结构都更有价值。等你自己去设计高并发组件时,会非常受用。
7.2 两个实战小习惯
最后分享两个排查线上问题的习惯。第一,遇到高并发下性能抖动,不要只盯业务日志,先看一眼对象分配和GC曲线。ConcurrentHashMap底层都是普通对象,过频写入会持续产生垃圾,内存紧张时这部分分配压力会直接体现在GC上。第二,读源码不要只背结论,建议自己动手把put流程和transfer流程用断点跑一遍,或者在纸上画状态图。很多“为什么这样设计”只有在真正看到运行现场时才会豁然开朗,这比反复看注释有用得多。