摘要本文深入剖析了 ConcurrentHashMap 1.8 的协同扩容机制对比了 Hashtable 全表锁的阻塞式扩容。核心要点包括1transfer() 方法通过 CAS 竞争 transferIndex 实现线程间任务分配2ForwardingNode 作为扩容路标保证读操作完全无锁3sizeCtl 字段复用三种语义低16位记录扩容线程数4helpTransfer() 让写线程变扩容工人实现“线程越多扩容越快”。文章详细解析了链表/红黑树迁移、高低位拆分、发号师模式等关键设计并提供了面试标准回答。上篇拆解了 put 操作的 CASsynchronized 三层防御。写这篇之前我把这个问题问过 100 多个候选人能答到位的不到 20 个。不是他们不知道扩容这个概念而是他们从来没想过——扩容期间正在读数据的线程发生了什么大多数人默认扩容嘛总得锁点什么东西吧这个默认就是分水岭。ConcurrentHashMap 的设计者 Doug Lea 给出的答案是两个字协同。先说 Hashtable 为什么输在这里Hashtable 的rehash()被put()内的synchronized代码块调用扩容期间整个 Hashtable 对所有线程都是阻塞的——读操作不行因为get()也要获取同一个锁。一行代码if (count threshold) { rehash(); }让所有线程在外面排队数据量一上来就是性能黑洞。这背后是设计理念的分野你是保护数据还是保护并发能力Hashtable 选了前者——扩容期间数据一致性第一性能次要。ConcurrentHashMap 说我全都要——既要一致性也要并发能力。其实这个问题放到生产环境里最典型的悲剧场景是这样的一个业务 HashMap 跑了大半年从来没出过问题因为数据量一直没超过默认容量 16。某天大促流量翻了三倍put 操作触发扩容——然后整个请求链路开始超时。最要命的是开发盯着监控一脸懵CPU 没涨、内存没爆怎么就大面积 500 了因为阻塞不在你的业务代码里在 JDK 的数据结构内部。transfer()协同扩容的核心骨架private final void transfer(NodeK,V[] tab, NodeK,V[] nextTab) { int n tab.length, stride; if ((stride (NCPU 1) ? (n 3) / NCPU : n) MIN_TRANSFER_STRIDE) stride MIN_TRANSFER_STRIDE; // ① 迁移步长最少16槽位 if (nextTab null) { // ② 发起线程创建2倍大新数组 nextTab new Node?,?[n 1]; nextTable nextTab; transferIndex n; } ForwardingNodeK,V fwd new ForwardingNodeK,V(nextTab); // ③ 标记节点 }三个关键设计决策步长 stride 按 CPU 核心数均分除以 8最少16保证每个核心分到的槽位数合理transferIndex 从数组末尾往前分配——和 slot 索引的自然增长方向形成隔离发起扩容的线程和继续 put 的线程操作不同区域少打架ForwardingNode 是关键当一个槽位的数据全部迁移完成后旧数组这个位置被替换为 ForwardingNodehashMOVED-1任何后续访问到达时看到 hashMOVED 就知道去新数组操作了。接下来是迁移的核心循环——这段代码是理解协同最关键的部分boolean advance true; boolean finishing false; for (int i 0, bound 0;;) { NodeK,V f; int fh; // 这一段是发号师逻辑当前线程手上没活干了去抢新的区间 while (advance) { int nextIndex, nextBound; if (--i bound || finishing) advance false; // 当前区间还有活继续干 else if ((nextIndex transferIndex) 0) { i -1; advance false; // 全局都没活了 } else if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex, nextBound (nextIndex stride ? nextIndex - stride : 0))) { bound nextBound; // CAS抢到新区间设置边界 i nextIndex - 1; // 从区间最右边开始往前迁移 advance false; } } // ... 具体迁移逻辑对每个槽位 f根据它的类型做不同处理 }这个 while 循环是整个迁移引擎的心脏。它做了三件事检查当前手上还有没有活--i bound、检查全局还有没有未分配的活transferIndex 0、如果全局有活且手头没活就去 CAS 抢一个新区间。这个设计妙在不需要一个中央调度器每个线程自己看情况决定是继续干还是去抢活还是收工。发号师模式CAS 认领迁移区间// transferIndex 是全局分配指针线程通过 CAS 竞争认领迁移区间 if (U.compareAndSwapInt(this, TRANSFERINDEX, nextIndex, nextBound (nextIndex stride ? nextIndex - stride : 0))) { bound nextBound; i nextIndex - 1; }线程 A 读取 transferIndex64CAS 改成 48认领 [48,64) 区间。线程 B 看到 transferIndex48CAS 改成 32认领 [32,48)。谁抢到谁干活。当 transferIndex0 时说明所有槽位都被认领了当前线程结束迁移。这就是协同两个字的精髓——扩容不是由一个线程扛的所有来访问的线程都是免费劳动力。注意一个容易忽略的细节迁移方向是从数组末尾往前i 递减而不是从 0 开始递增。为什么因为 put 操作的 slot 计算是(n-1) hash往低索引的槽位走。如果迁移也从 0 开始往后正在 put 的线程会和迁移线程在同一个槽位上频繁冲突。倒着走两种操作自然错开——发号师从尾部发牌put 的槽位大部分在前面。ForwardingNode扩容期间的路标static final class ForwardingNodeK,V extends NodeK,V { final NodeK,V[] nextTable; ForwardingNode(NodeK,V[] tab) { super(MOVED, null, null, null); // hash MOVED -1 this.nextTable tab; } NodeK,V find(int h, Object k) { // 直接在 nextTable 里找绕过当前节点 outer: for (NodeK,V[] tab nextTable;;) { NodeK,V e; int n; if (k null || tab null || (n tab.length) 0 || (e tabAt(tab, (n - 1) h)) null) return null; for (;;) { // ... 在新数组里做正常的链表/红黑树查找 } } } }ForwardingNode 本质上是一个转发器。它的 hash 值固定为 -1常量 MOVED是 ConcurrentHashMap 里四个特殊 hash 值之一。关键在于它的find()方法——重写了 Node 的查找逻辑不扫描当前槽位直接跳到 nextTable新数组里去定位。这意味着一个读线程到达旧数组的某个槽位发现这里站着一个 ForwardingNode它不需要等待迁移完成也不需要自己参与迁移直接拿到新数组引用去新数组里读。零阻塞、零等待。面试常有人问ForwardingNode 和新数组之间怎么保证可见性答案在nextTable字段的声明上——它是volatile的。发起扩容的线程在 CAS 设置nextTable后有一个写屏障后续所有读nextTable的线程都能看到完整的数组引用。链表迁移高低位拆分锁住一个槽位就够了每个线程认领区间后对区间内每个槽位做迁移。以链表为例synchronized (f) { // f 是当前槽位的头节点只锁这一个槽位 if (tabAt(tab, i) f) { NodeK,V ln, hn; // ln lowNode低位链hn highNode高位链 if (fh 0) { // 普通链表 int runBit fh n; // 用扩容前容量 n 来判断新位置 // ... 遍历链表按 runBit 把节点分到 ln 和 hn 两条链 setTabAt(nextTab, i, ln); // 低位链放新数组原位置 setTabAt(nextTab, i n, hn); // 高位链放新数组原位置n setTabAt(tab, i, fwd); // 旧数组当前位置换为 ForwardingNode } } }注意这里锁的粒度——synchronized (f)锁的是当前槽位的头节点不是整个数组。线程 A 在迁移槽位 50线程 B 可以同时在迁移槽位 30互不干扰。只有同一个槽位的并发才需要排队这跟 Hashtable 锁全表的设计是天壤之别。链表重新哈希的逻辑也很巧。扩容到 2 倍后节点的位置要么不变低位要么加 n高位因为(n-1) hashvs(2n-1) hash的区别只在于第 log₂(n) 位是否被 hash 命中。判断依据是hash n如果为 0 原位置不动为 1 移到原位置加 n。Doug Lea 还做了一个 lastRun 优化——如果链表尾部有一段连续的节点 runoffBit 相同这一段不需要逐个复制直接整段挂过去。红黑树迁移拆分可能退化如果槽位里是红黑树迁移逻辑会更复杂一些else if (f instanceof TreeBin) { TreeBinK,V t (TreeBinK,V)f; TreeNodeK,V lo null, loTail null; // 低位链表 TreeNodeK,V hi null, hiTail null; // 高位链表 // 遍历树节点按 hashn 分到 lo 和 hi 两条链 for (NodeK,V e t.first; e ! null; e e.next) { int h e.hash; TreeNodeK,V p new TreeNodeK,V(h, e.key, e.val, null, null); if ((h n) 0) { // 加入低位链 } else { // 加入高位链 } } // 如果低位链节点数 ≤ 6退化为链表否则重建红黑树 setTabAt(nextTab, i, ln); setTabAt(nextTab, i n, hn); setTabAt(tab, i, fwd); }红黑树迁移比链表多了一步判断拆分后如果某条链的节点数少于等于 6UNTREEIFY_THRESHOLD就不重建红黑树了退化回链表。因为节点太少的时候链表的遍历开销比树小——红黑树虽然查找是 O(log n)但维护成本高节点少的时候得不偿失。面试中最容易答偏的地方就在这里——很多人说扩容会把链表变红黑树搞反了。扩容不会让链表变树那是 treeifyBin 干的条件是链表长度 ≥8 且数组长度 64扩容只会把树拆小可能退化成链表。读操作 get()扩容期间完全无锁的秘密这是整个设计最漂亮的点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) { // 头节点直接匹配 } else if (eh 0) { // hash 0 说明头节点是 ForwardingNode 或 TreeBin // 调用节点的 find() 方法 p e.find(h, key); } else { // 链表遍历 } } return null; }get() 里没有任何锁。它先读 table 引用volatile定位到槽位取出头节点。如果头节点的 hash 等于 MOVED-1说明这个槽位已经迁移完成头节点是 ForwardingNode。此时调用e.find(h, key)——前面说了ForwardingNode 重写了 find直接去新数组里查。关键问题读线程看到的是旧数组还是新数组答案是——取决于 table 引用的读到时间。如果 get() 读到的 table 还是旧数组它按旧数组定位如果槽位刚好被 ForwardingNode 覆盖了它透明地跳到新数组。这两种情况对读线程来说都是一次寻址加一次查找没有任何等待。对比 Hashtable 的 get()——扩容期间get 直接被锁挡在门外。这就是 ConcurrentHashMap 碾压 Hashtable 的根本原因读操作永远不被阻塞。sizeCtl一个字段三种语义- 初始化阶段sizeCtl-1表示有线程在初始化- 正常运行sizeCtln×0.75扩容阈值- 扩容阶段sizeCtl0高 16 位存储扩容标记低 16 位存储参与扩容的线程数加 1。每加入一个扩容线程 sizeCtl1每退出一个 sizeCtl-1。最后一个检测到归零时执行收尾一个 32 位 int承载初始化标志、扩容阈值、扩容线程计数三种完全不同用途的数据零额外内存开销。少一个字段就是少一次内存访问并发场景下少一次缓存行失效。再展开说说低 16 位的计数逻辑。扩容开始时 sizeCtl 被置为一个负数高 16 位是扩容版本戳resizeStamp(n) RESIZESTAMPSHIFT低 16 位初始值是 2因为发起扩容的线程算一个加上初始值 1。后续每来一个线程帮忙扩容通过 CAS 把低 16 位加 1。线程完成自己的迁移区间后 exit低 16 位减 1。最后一个线程发现减完等于版本戳1即只剩发起线程的基础计数说明其他线程都干完了由它执行收尾工作检查所有槽位是否都有 ForwardingNode、把 table 指向新数组、把 nextTable 置空。面试时如果你能说出 sizeCtl 的低 16 位是参与扩容的线程计数器 而不是 sizeCtl 负了就是扩容中面试官会在笔记本上画一个加号。helpTransfer()写线程变扩容工人else if ((fh f.hash) MOVED) tab helpTransfer(tab, f);当 put 线程到达一个槽位发现头节点的 hashMOVED 时它不阻塞等待也不试图自己往这个槽位写——直接调用 helpTransfer() 加入扩容。每加入一个扩容线程计数器加 1线程认领自己的区间、迁移数据、完成后计数器减 1。读操作看到 ForwardingNode 则通过其内部引用直接去新数组读取——读操作完全无锁从头到尾零阻塞。这个设计的反直觉之处在于线程越多扩容越快。helpTransfer 内部有一个精妙的保护它不会让线程无限加入扩容。如果当前参与扩容的线程数已经超过了 CPU 核心数的某个上限或者扩容已经完成nextTable 被置空了helpTransfer 就直接返回。防止在高并发下几十个线程全挤进迁移循环互相踩踏 CPU 缓存行。再深入一点helpTransfer 还会检查 sizeCtl 的高 16 位——如果扩容版本戳和当前的不一样说明扩容已经完成了新一轮别人触发了一个新的扩容这时候就不能再参与旧的迁移。这个细节对应一个生产环境的真实 bug两次扩容间隔极短的情况下后到的线程可能基于过期的 transferIndex 往错误的新数组写数据。 面试标准回答ConcurrentHashMap 1.8 的扩容是协同式的核心方法是 transfer()。触发扩容时第一个线程创建两倍大的新数组按步长 stride 把旧数组槽位区间分配给参与线程。分配的机制是 CAS 竞争 transferIndex 全局指针——每个线程 CAS 认领一段区间认领到了就负责迁移这段的链表或红黑树。迁移完的槽位替换为 ForwardingNodehash-1后续 put 看到 MOVED 不阻塞而是调用 helpTransfer 加入扩容get 看到 MOVED 直接通过 ForwardingNode 去新数组读——读完全无锁。sizeCtl 用位编码复用三种语义。跟 Hashtable 比Hashtable 扩容时全表上锁读写全部阻塞1.8 的设计是线程越多扩容越快化敌为友——这是并发编程里很高级的设计哲学。唠点键盘之外的我第一次真正理解协同扩容是在黑五线上事故之后——5000 QPS 下 ConcurrentHashMap 每 30 秒扩容一次但 CPU 没涨请求延迟个位数。几百个线程在同时帮忙扩容本身是一瞬间的事。那一次我才真切感受到协同两个字在真实流量场景下的分量。Doug Lea 不是在写数据结构他是在给互联网高并发系统造一颗不需要维护的小型引擎。私信回复「666」一次性领走面试宝典Java 高频考点速查表、HashMap/ConcurrentHashMap 源码笔记、JVM 调优案例、Spring Boot 面试 50 问AI 编程工具箱Cursor/Copilot/Codex 六工具对比表、10 个 Prompt 模板、Debug 万能公式、Cursor 速查手册、AI 图片生成入门、30 效率工具包一份资料包两个专栏都能用。「唠点键盘之外的」只讲干货。