news 2026/10/14 22:21:08

ConcurrentHashMap源码深度解析:CAS+synchronized+volatile如何支撑高并发

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
ConcurrentHashMap源码深度解析:CAS+synchronized+volatile如何支撑高并发

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流程用断点跑一遍,或者在纸上画状态图。很多“为什么这样设计”只有在真正看到运行现场时才会豁然开朗,这比反复看注释有用得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 22:21:08

微信小程序扭蛋机开发实测:从概率设计到动效调优全记录

最近花了两周时间&#xff0c;把一个商场里常见的实体扭蛋机搬进了微信小程序&#xff0c;从原型到上线完整实测了一轮。结论先说&#xff1a;扭蛋机小程序在"开发简单"和"互动有趣"这两件事上&#xff0c;确实没有夸张。我原本预期这种带随机玩法的小程序…

作者头像 李华
网站建设 2026/10/14 22:20:51

SAP MDG主数据治理实战:架构、配置与避坑指南

简介&#xff1a;《SAP MDG Overview》是一份面向SAP顾问、数据治理专家及企业架构师的PDF演示资料&#xff0c;系统介绍SAP Master Data Governance 9.0的中央治理核心功能&#xff0c;帮助读者理解如何通过统一平台管理客户、供应商、物料等关键主数据&#xff0c;解决跨系统…

作者头像 李华
网站建设 2026/10/14 22:19:22

扫描线+线段树+离散化:矩形面积并模板题全解析

刷题刷到一定程度&#xff0c;几乎绕不开 P5490 这道扫描线模板题。题目内容总结起来就一句话&#xff1a;给你最多十万个矩形&#xff0c;坐标范围撑到 1e9&#xff0c;求它们覆盖的总面积&#xff0c;重叠部分只算一次。第一次做这类题的人&#xff0c;最直观的想法往往是二维…

作者头像 李华
网站建设 2026/10/14 22:19:10

操作系统核心机制拆解:虚拟化、并发与持久化及排查指南

如果你只是把操作系统当成一个“帮程序跑起来的软件”&#xff0c;那你迟早会在线上栽跟头。我自己就见过不少类似的场景&#xff1a;一个进程突然卡死不动&#xff0c;另一台机器内存被吃光&#xff0c;还有人把数据库写入变慢当成网络问题查了半天&#xff0c;最后发现是文件…

作者头像 李华
网站建设 2026/10/14 22:18:57

arXiv AI论文海量更新怎么读?以2026.10.06清单为例的实战指南

早把10月6日的arXiv cs.AI区间拉下来&#xff0c;一天的阅读时间就算正式开始了。做这种清单追踪的人应该都有同感&#xff1a;arXiv每天的上新量已经远超出一个人能精读的上限&#xff0c;cs.AI这一类目尤其典型&#xff0c;标题里密密麻麻全是模型、对齐、评测、Agent、推理框…

作者头像 李华