ConcurrentHashMap源码精读:从CAS桶锁到扩容协作
发布时间:2026/10/15 11:33:00 锦皓数字建站

如果你已经能背出 HashMap 的“数组链表红黑树”结构那 ConcurrentHashMap 绝对值得你再较真儿一次。它是 JDK 并发包里信息密度最高的源码之一一趟读下来你会把 volatile、CAS、synchronized、哈希扰动、树化阈值、扩容协作这些知识点全部串起来。很多人读它只记住了“CAS 桶锁”六个字可真要问“扩容时别的线程怎么帮忙搬数据”“size 为什么可能不准”“get 为什么不用加锁”能讲清楚的人并不多。我自己立过一个 flag把 ConcurrentHashMap 的核心方法逐行读完并且给每行能写注释的都写注释。读完最大的感慨是作者不是在写数据结构而是在设计一套高并发的状态机。这篇文章就把我逐行批注时觉得最关键的几条线重新走一遍包括怎么读、为什么这么设计、哪些地方容易想当然。1. 读之前先想清楚ConcurrentHashMap 在并发之下到底解决了什么很多源码分析一上来就贴代码我觉得这是最大的误区。看源码之前必须先搞清楚设计目标否则看到一堆 if-else 只会觉得“好复杂”而不会觉得“这步太妙了”。1.1 从 JDK 7 的分段锁到 JDK 8 的 CAS 加桶锁JDK 7 的 ConcurrentHashMap 用的是分段锁内部维护一个 Segment 数组每个 Segment 继承 ReentrantLock里面又维护一个小 HashEntry 数组。数据被拆到多个 Segment 里写不同的 Segment 互不影响。听起来不错但问题在于分段数量是初始化时固定的默认只有 16 个一旦写压力集中在某个 Segment这个 Segment 就会变成瓶颈其他 Segment 再空闲也帮不上忙。而且每写一个桶都要经过一次可重入锁的获取与释放哪怕桶里只有一个元素也要走完整的锁流程。JDK 8 把 Segment 直接砍掉了回到一张大哈希表用 volatile 修饰数组引用和节点 value空桶用 CAS 直接写入非空桶用 synchronized 锁住桶头节点。锁粒度从“段”降到了“桶”。两个线程写不同的桶时各自锁不同的头节点互不干扰写同一个桶时锁的也只是这一条链或这棵树全表其他位置照常读写。这个改动让并发粒度真正细到了单桶级别。1.2 读源码之前需要准备好的三块拼图读这份源码需要的基础知识其实不算多但缺一不可volatile 的可见性语义知道读 volatile 字段能看到最新值写 volatile 字段会立即刷新到共享内存CAS 的基本操作知道 compareAndSwapXxx 是“比较并交换”的原子操作失败后会循环重试HashMap 的扩容思路尤其是容量为什么是 2 的幂、为什么下标要用(n - 1) hash。红黑树那一块建议先不用背旋转算法只需要知道它是一棵自平衡二叉搜索树能保证查找 O(log n) 即可。真到了 TreeBin 里的 putTreeVal 和 find 时知道左小右大、需要变色旋转就够你读懂了。1.3 我推荐的阅读路线字段到 put再回头补扩容如果从第一个方法按顺序读下去大概率读不到一半就晕了。我比较顺的路线是先把静态常量和字段过一遍理解“状态变量”都长什么样然后精读 putVal因为它是所有写路径的总入口读完 putVal 自然就看到了扩容入口 addCount 和 transfer再专门啃 transfer最后回来看 get、size、remove、compute 这些方法。先把骨架立起来再往上面挂血肉就不会迷路。2. 先别急着进 put看看静态常量和字段透露了哪些设计信号我读源码的习惯是先看“公共设施”。ConcurrentHashMap 的字段数量不多但每一个都承担了至少两三种职责。不理解它们后续所有眼花缭乱的操作都会失去锚点。2.1 spread() 这个扰动函数为什么还要和 HASH_BITS 取与先看这段高频使用的函数static final int spread(int h) { return (h ^ (h 16)) HASH_BITS; }第一个操作是h ^ (h 16)把 key 的 hashCode 高 16 位和低 16 位搅在一起。原因很直白哈希表定位下标时用的是(n - 1) hash容量 n 是 2 的幂所以 n-1 的低位全是 1高位全是 0。如果 key 的 hashCode 高位差异很大、低位恰好相同不做扰动就会出现大量碰撞。让高 16 位参与异或之后任何一位的变化都能传递到低位决策上。第二个操作是 HASH_BITS而HASH_BITS 0x7fffffff作用是强制最高位变成 0。为什么要多此一举因为普通节点的 hash 值必须是非负数。这样在遍历时看到“hash 小于 0”的节点就可以直接断定它不是普通链表节点而是 ForwardingNode、TreeBin 或 ReservationNode 这三种特殊节点之一。一个字段同时肩负数据定位和类型识别两个职责这是读源码时容易忽略的点。2.2 sizeCtl 一个变量当三个用ConcurrentHashMap 里最容易被误解的字段就是 sizeCtl。它表面上是 int实际上是个状态机状态含义0尚未初始化使用默认容量正数可能是初始容量也可能是扩容阈值-1有人正在初始化表小于 -1正在扩容低 16 位记录参与扩容的线程数高位部分记录扩容批次标识初始化完成后正数时它等于n - (n 2)也就是 0.75n 的扩容阈值。扩容时它变成负数通过高位的 resizeStamp 区分是第几轮扩容用低位的线程数判断还有多少线程没退出。一个变量承担了标识、计数、阈值三种作用也正因为如此所有修改都必须通过 CAS 进行否则多线程一改就会乱套。2.3 节点 hash 的四个特殊值和 ForwardingNode 的使命普通节点的 hash 是 spread 后的非负数但还存在三个角色static final int MOVED -1; // ForwardingNode static final int TREEBIN -2; // TreeBin 的根 static final int RESERVED -3; // ReservationNodeMOVED 节点就是扩容时的“路标”它内部保存了 nextTable 引用。任何线程读到 MOVED 节点都知道旧数组这个桶已经被迁移完了而且可以直接通过它的 find 方法到新数组里去查数据。TREEBIN 是红黑树桶的标记注意它和 TreeNode 不同TreeBin 是树的头管理对象锁也锁在它身上。RESERVED 是 computeIfAbsent 这类计算型方法用的占位节点防止同一个 key 被多个线程同时初始化。2.4 构造函数为什么不急着建表new ConcurrentHashMap()的构造函数几乎是空的只是初始化了相关字段new ConcurrentHashMap(initialCapacity)也只是顺手把容量换算进 sizeCtlint cap tableSizeFor(initialCapacity (initialCapacity 1) 1); this.sizeCtl cap;tableSizeFor 会把传入值调整成不小于它的 2 的幂。这里多加了(initialCapacity 1)是为了给 0.75 的负载因子提前留出余量免得刚初始化完就立刻扩容。真正建表要等到第一次 put 时在 initTable 里做。这个设计是为了让“不用的容器不占空间”同时也把并发初始化的问题统一到了 initTable 一个入口里面。3. putVal 逐行走读从 CAS 投递到 synchronized 锁链putVal 是 ConcurrentHashMap 最核心的写入口。读完它你会明白空桶、冲突桶、扩容桶、树化桶分别走了哪条分支。下面这段是去掉边界分支后的主流程骨架我用注释标出每一行的意图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(); // 表还没建先抢初始化 else if ((f tabAt(tab, i (n - 1) hash)) null) { if (casTabAt(tab, i, null, new NodeK,V(hash, key, value))) break; // 空桶直接 CAS 放入 } else if ((fh f.hash) MOVED) tab helpTransfer(tab, f); // 遇到扩容路标先帮一把 else { V oldVal null; synchronized (f) { // 锁住这个桶的头节点 if (tabAt(tab, i) f) { // 二次确认头没变 if (fh 0) { // 普通链表桶 // 遍历链表找 key找到就替换找不到就尾插 } else if (f instanceof TreeBin) { // 红黑树桶交给 TreeBin.putTreeVal } } } if (binCount ! 0) { if (binCount TREEIFY_THRESHOLD) treeifyBin(tab, i); // 可能转红黑树 addCount(1L, binCount); // 计数并可能触发扩容 } } } return null; }3.1 空桶的快速路径为什么敢用 CAS 不设锁当目标桶位置是 null 时代码没有加锁而是直接casTabAt。CAS 的语义是“只有当前值是 null 时我才把新节点写进去”所以不会出现两个线程同时插入到同一个空桶的问题。如果 CAS 失败说明另外一个线程抢先写入了当前线程就跳到下一轮循环重新判断该走链表分支就走链表分支。这个路径连 synchronized 都没碰性能自然好。这里有个容易忽略的点tabAt和casTabAt并不是普通的数组读写它们走的是 Unsafe 的 getObjectVolatile / compareAndSwapObject。Java 数组元素本身不具备 volatile 语义如果直接tab[i] node另一个线程可能看不到这次写入。所以源码里所有对桶位置的读写都必须通过这两个工具方法这就是“读数组元素也讲可见性”的细节。3.2 遇到 ForwardingNode 时的态度先帮忙扩容再谈写入如果定位到的桶头节点 hash 是 MOVED说明这个桶已经迁移到新数组 nextTable 中了。此时当前线程不能直接往旧数组里写因为旧数组的该位置已经变成了“路标”写了也是白写最终新数组里也看不到。所以代码调用 helpTransfer把自己也变成扩容劳动力之一。helpTransfer 的逻辑是先检查 nextTable 是否存在然后用 CAS 调整 sizeCtl 的线程计数最后调用 transfer 领取任务。这个过程是“顺手帮忙”而不是“排队等待”。在扩容期间多个写线程可以同时进入 transfer每人分一段桶去搬工作量被拆成了众包。3.3 非空桶的 synchronized 块二次检查是底线当桶里已经有节点时代码进入了synchronized (f)。这里锁的是桶头对象不是整个表。但进入同步块之后第一件事是if (tabAt(tab, i) f)。为什么要二次确认因为线程在执行到 synchronized 之前另一个线程可能已经把桶头换掉了比如发生过一次扩容当前桶位置已经变成了 ForwardingNode。锁一个过期的头节点没有任何意义所以必须重新读一次 volatile 桶位置确认锁的还是当前有效的头。在锁内部普通链表节点走遍历查找。找到相同 key 就按 onlyIfAbsent 决定是否覆盖找不到就在链表尾部新增节点。这里没有 hash 冲突时的独立处理因为链表的顺序并不敏感只有树化之后才需要遵循红黑树规则。3.4 binCount 到 8 之后为什么还要看看数组长度够不够binCount 记录的是链表中节点数量。插入完成后代码发现 binCount 达到 TREEIFY_THRESHOLD(8) 就调用 treeifyBin。但 treeifyBin 内部还有一个致命判断if (tab null || (n tab.length) MIN_TREEIFY_CAPACITY) tryPresize(n 1);只有 table 长度不小于 MIN_TREEIFY_CAPACITY(64) 时才真的把链表转成红黑树否则只做扩容把容量翻倍。原因是冲突严重不一定是 key 的哈希分布差也可能是因为桶太少了。在容量只有 16 或 32 时多个本来分散的 key 被迫落到同一个桶里这时候最合理的手段是把桶变多而不是花高昂代价去维护一棵红黑树。红黑树节点多两个指针、维护成本高只有确认“桶已经足够多却仍然长链化”树化才值得。4. transfer 扩容一场不需要全局锁的众筹式搬迁ConcurrentHashMap 最精彩的地方就是 transfer。整个扩容过程没有一把全表锁多个线程各领一段搬完一个桶就把旧位置标记成 ForwardingNode读线程遇到路标自动去新数组找写线程遇到路标也会加入搬迁。我第一遍读到这里时起了一身鸡皮疙瘩。4.1 扩容是怎么被触发的addCount 和 tryPresize写操作最后会调用addCount(1L, binCount)。addCount 不只是计数它还承担扩容检测。关键逻辑可以理解为当 binCount 大于等于 0 时说明当前是一次真实写入写完后检查counterCells和 baseCount 的总和是否超过 sizeCtl如果超过就调用 transfer 开始扩容。而 treeifyBin 里发现容量不够 64 时调用的 tryPresize走的是另一条主动扩容路径它直接根据目标容量去 resizeStamp 并启动 transfer。两条路最终都会汇入同一个 transfer。4.2 stride、transferIndex 和 bound把大数组切成任务包transfer 一开始先计算每个线程一步最多搬多少个桶int stride (n 3) / NCPU; if (stride MIN_TRANSFER_STRIDE) stride MIN_TRANSFER_STRIDE;n 是旧数组长度NCPU 是 CPU 核数。默认情况下一个线程一次至少要领 MIN_TRANSFER_STRIDE 个桶避免线程数太多时任务切得太碎反而增加 CAS 争抢。实际领取时transferIndex 是一个 volatile 字段记录当前任务边界。线程通过 CAS 从 transferIndex 扣下一段区间然后把本线程负责的区间设为[bound, i]。因为扩容任务是异步的参与者随时可能加入或退出所以 while 循环里用--i bound来代表从高索引向低索引逐个搬。这个从后往前搬的顺序本身没有特殊意义只是为了和 transferIndex 递减逻辑保持一致让每次 CAS 减出来的区间不重叠。4.3 链表迁移的杀手锏一次 hash n 切两半迁移一个链表桶时源码没有傻到逐节点计算新位置而是用了一个决定性技巧。Array 容量翻倍后节点的新下标要么保持原下标 i要么变成 i n关键看它的 hash 在新增的那一位上是 0 还是 1。也就是if ((ph n) 0) { // 放入低位链 } else { // 放入高位链 }这行代码几乎是 transfer 里最精华的一行。n 在扩容前是 old table 的长度它的二进制只有一个 1 位。一个节点的 hash 和 n 做与运算结果非零说明它新增位的值是 1新位置是 i n结果是 0新位置仍是 i。所以一次遍历就能把链表拆成两条一条留在原索引 i一条放到 i n根本不用为每个节点重新计算下标。再看一个叫 lastRun 的优化。代码从链表头开始往后找“最后一段 runBit 相同的尾巴”然后直接把这段尾巴整体挂过去只处理它之前的节点。这是因为一段连续的后缀节点既然 hashn 的结果都一样它们在新旧数组中的相对位置完全不变可以整段复用省掉一部分新建节点的操作。这个优化在链表很长、尾部连续相同的情况下收益明显。4.4 TreeBin 和 TreeNode 的拆分也有高低位如果桶头是 TreeBin迁移逻辑不直接遍历红黑树而是先顺着 TreeBin.first 这条链表把所有 TreeNode 扫一遍同样用hash n分成 lo 和 hi 两半。然后对每一半重新判断节点数量如果少到 UNTREEIFY_THRESHOLD(6) 以下说明这半边退化回链表更高效就 new 普通 Node 串成链表否则新建一棵 TreeBin。这个“6”和“8”之间留了 2 个节点的缓冲就是为了避免在树化和退化之间反复横跳。迁移完桶内所有节点后源码会在旧数组的这个桶位置放入 ForwardingNodesetTabAt(tab, i, fwd);这行非常重要。它是“这个桶已经被人搬完了”的唯一标志。后续线程如果还没领到这个桶看到它的位置是 ForwardingNode 就会跳过如果准备写入这个桶也会先帮忙扩容而不是直接覆盖。4.5 最后一个线程负责收尾nextTable 转正与 sizeCtl 更新多线程一起扩容怎么判断“扩容结束了”源码用了一个非常巧妙的 CAS 计数每个线程搬完自己领到的区间后会尝试通过 CAS 把 sizeCtl 减 1。但这个值减到多少才算结束取决于 resizeStamp 的换算关系。只有当最后一次 CAS 使计数满足退出条件时这个线程才是真正的“收尾线程”它负责把 nextTable 赋给 table并清空 nextTable。此后 sizeCtl 更新为(n 1) - (n 1)这里 n 是旧容量所以新容量是 2n新阈值就是 2n 的 0.75也就是(2n - n/2) 1.5n。所有参与扩容的线程都各回各家表正式完成翻倍。5. get、size、遍历没有锁也敢读的底气ConcurrentHashMap 的读方法从来不直接加锁。给人的第一感觉是“不设防”实际上它的安全建立在 volatile 和特殊节点的配合上。5.1 get 为什么可以全程无锁get 的流程非常简单NodeK,V[] tab table; int n tab.length; NodeK,V e tabAt(tab, (n - 1) h); if (e null) return null; if (e.hash h key 相等) return e.val; else if (e.hash 0) return e.find(h, key); else 遍历链表;依赖 volatile 见到的 table 引用自然能看到最新的数组结构。读到一个普通的桶头说明该桶还没有被迁移直接遍历即可。读到 MOVED 节点就会调用 ForwardingNode.find把查询转发到新数组读到 TreeBin会用树查找读到 ReservationNode直接返回 null。这些 find 方法内部都是无锁的靠的是节点本身不被修改或修改时使用 volatile 写入。那“读一半写一半”会不会读到脏数据答案是读线程能看到的是同一时刻某个中间态但绝不会出现指针撕裂之类的非法状态。节点的 next 字段是用 volatile 修饰的链表的拼接顺序保证了读到的每个节点都是已经完整构造好的对象。它的读结果不保证是你调用 get 那一刻之前的“最终态”这就是弱一致性。5.2 size() 的 baseCount 和 CounterCell 是怎么配合的size 的难点是每个写线程都去 CAS 一个大变量冲突会非常严重。源码的解决办法是分散计数。增加计数时先尝试给 baseCount 做 CAS如果失败说明竞争激烈就转而找到一个 CounterCell 单元格对这个 cell 的 value 做 CAS。每个线程通过 ThreadLocalRandom.getProbe 获取一个 probe 值再根据 cells 长度取模确定用哪个格子。真正执行 size() 时则是把 baseCount 和所有 CounterCell 的 value 全部加起来。因为加了这么多个格子且每个格子都在随时变化所以最终结果只能是一个近似值。如果追求严格一致性那每次 size() 都得阻塞全部写操作这对一个并发容器来说是得不偿失的。官方文档也直接说明 size 返回的是一个估计值不要拿它当精确水位线。5.3 遍历器的弱一致性到底“弱”到什么程度ConcurrentHashMap 的迭代器不会抛出 ConcurrentModificationException。它不是快照式遍历而是顺着当前桶数组移动并且能看到迭代过程中已经发生的修改。如果你在遍历时恰逢扩容可能出现某些在旧数组里的桶已经搬到新数组里、而迭代器还没走到的情况于是这次“漏”掉了那个桶里的元素。这种“弱一致”在很多场景下是能接受的比如统计、清理、批量打印但如果你的业务是“遍历时必须看到某个时刻的完整副本”那就得先 clone 或 lock 一把大局。6. 较真儿之后我实际验证过的几条经验源码读完了不代表真的理解了。我发现自己至少凭空踩过几个坑下面这几条是我验证过并且觉得很有用的经验。6.1 验证“桶锁不锁全表”的正确姿势我一直知道 ConcurrentHashMap 是桶锁但真正感受到它是在一个压测里16 个线程同时往两个不同 key 的桶里写通过 JFR 的锁竞争事件能看到 synchronized 锁几乎不争抢。而如果把所有线程都压到同一个 key 上锁竞争会立刻飙升。对比实验非常直观不管并发多大只要 key 分散桶锁的设计就能扛住可一旦 key 聚集它也会退化为一种“局部串行”。这说明官方推荐用不可变对象和分散性好的 hashCode 做主键不是空话。6.2 扩容期间的观察实验读请求会去哪儿我曾经用一个大 map 在后台循环 put同时在前台循环 get 某个不存在的 key然后故意把所有 put 集中到触发扩容的容量临界点。用 JFR 观察线程栈可以看到有些读线程短暂地跑到 transfer 方法里帮忙迁移。这不是 bug而是读路径遇到 ForwardingNode 后主动帮助扩容的证明。读线程不写数据但它帮忙把桶位置标记为 ForwardingNode从而加速后续读写。理解了这一点之后再看 helpTransfer 这个名字就觉得作者真的太会取名了。6.3 一份更进阶的源码阅读清单读完这一轮之后我觉得还差点火候又补了这几个点remove 方法里的 replaceNode 逻辑它同时处理了链表删除和红黑树退化和 putVal 形成对照computeIfAbsent 里的 ReservationNode 占位思想理解了它就能明白为什么这个操作允许“只计算一次”addCount 的 fullAddCount 方法里面涉及 CounterCell 初始化和扩容非常值得单独看HashMap 里的 resize 逻辑里面的(e.hash oldCap)拆分思想和 ConcurrentHashMap 一脉相承。如果非要给后来者一个建议不要光看注释和博客自己准备一个源码工程在每个核心方法里打断点用多线程 Debug 配合日志观察线程名。纸上得来终觉浅这句老话放在并发源码上特别成立。等你亲眼看到一个线程走进 helpTransfer、另一个线程在 transfer 里领到一段任务包你会真正理解“并发容器”这四个字的分量。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。