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

发布时间:2026/10/14 22:21:13
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 NodeK,V[]读取某个桶的头节点用的是Unsafe层面的getObjectVolatile或getObjectAcquireCAS写入用的是compareAndSetObject。这些操作保证读到的是某个线程已经发布出来的最新引用而不是当前线程CPU缓存里的过期值。这里有个很容易被忽略的基础知识点对“引用数组”的volatile读只能保证数组引用本身的可见性不能保证数组里每个元素的可见性。正因为如此源码里几乎不会直接用tab[i]去读数组元素而是通过tabAt(tab, i)方法配合内存偏移量来模拟“对数组元素的volatile读”。理解了这一点后面再看get和put的代码就能明白为什么到处都在调用tabAt、casTabAt、setTabAt这些都是无锁读的底层支撑。3. 核心数据结构逐项拆解字段、节点和状态位3.1 table、sizeCtl、nextTable三个关键字段源码里最重要的字段其实就三个transient volatile NodeK,V[] table; private transient volatile NodeK,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 / ReservationNodeJDK 8的桶位节点一共有四种类型理解它们的区别是看懂源码的前提。普通Node是链表节点static class NodeK,V implements Map.EntryK,V { final int hash; final K key; volatile V val; volatile NodeK,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_BITSHASH_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锁binput操作的核心封装在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 (NodeK,V[] tab table;;) { NodeK,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 NodeK,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) { NodeK,V[] tab; NodeK,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 → transferput成功后会调用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低位要么挪到索引in高位。判断依据是看节点hash的某一个比特位是否为0if ((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或包装key7. 写在最后源码读完之后的一些体会7.1 这套设计给我的启发ConcurrentHashMap源码读了这么多遍我最深的感受是它最打动人的不是某个精巧的算法而是一套清醒的取舍逻辑。读多写少的场景下它宁可容忍弱一致性也要让读操作全程无锁写操作则是能CAS就不加锁实在躲不过冲突才锁局部桶位。这种“把无锁和锁用在各自最合适的位置”的思维方式比任何一个单独的数据结构都更有价值。等你自己去设计高并发组件时会非常受用。7.2 两个实战小习惯最后分享两个排查线上问题的习惯。第一遇到高并发下性能抖动不要只盯业务日志先看一眼对象分配和GC曲线。ConcurrentHashMap底层都是普通对象过频写入会持续产生垃圾内存紧张时这部分分配压力会直接体现在GC上。第二读源码不要只背结论建议自己动手把put流程和transfer流程用断点跑一遍或者在纸上画状态图。很多“为什么这样设计”只有在真正看到运行现场时才会豁然开朗这比反复看注释有用得多。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询