JDK 1.8 ConcurrentHashMap源码解析:CAS与synchronized的完美配合

📅 发布时间:2026/9/9 20:33:11
JDK 1.8 ConcurrentHashMap源码解析:CAS与synchronized的完美配合
做 Java 并发开发这么久我越来越觉得 JDK 1.8 的 ConcurrentHashMap 是一份值得反复精读的源码教材。它在同一个类里同时用上了 CAS 和 synchronized但并不是简单堆砌两种并发手段而是把能不加锁就不加锁必须加锁就锁最小范围这个思路贯彻到了极致。这篇文章我会从源码角度把这条链路完整走一遍拆开 JDK 1.8 里 ConcurrentHashMap 的核心设计CAS 在哪些节点入场、synchronized 锁的到底是什么、扩容和计数又是怎么配合的。无论你是刚接触并发集合的新人还是写了好几年业务代码想回头补课的老开发这篇文章都值得耐心看完。1. 从分段锁到细粒度锁JDK 8 改 ConcurrentHashMap 的根本动机1.1 JDK 7 的 Segment 方案重在哪里想理解 JDK 8 的设计得先知道 JDK 7 里 ConcurrentHashMap 是怎么工作的。JDK 7 版本的核心是 Segment一个 Segment 内部维护一个 HashEntry 数组Segment 本身继承自 ReentrantLock。默认创建 16 个 Segment所以并发度上限就是 16。写操作先定位到某个 Segment然后锁住整个 Segment再去操作里面的 HashEntry 数组。这套方案在 JDK 5/6 时代已经算非常先进了它把一把大锁拆成了 16 把锁让不同 Segment 之间可以并发写。但问题也很明显一旦某个 Segment 内部发生冲突锁的范围是整个 Segment数据量大的时候这个 Segment 下面可能挂着成千上万个 Entry锁竞争仍然会很严重。而且并行度被死死限制在 16机器核数再多也发挥不出来。JDK 7 的 Segment 还有一个隐藏问题很多操作虽然只需要锁一个 Segment但为了保证跨 Segment 的一致性像 size() 这种全局操作需要先依次尝试获取所有 Segment 的锁失败后再重试。这在高并发下成本很高。1.2 synchronized 在 JDK 8 时代重新变得香了很多人有一个误解觉得 synchronized 是重量级锁性能一定不如 ReentrantLock。这个印象停留在 JDK 1.6 之前。JDK 1.6 开始对 synchronized 做了大规模优化引入了偏向锁、轻量级锁、锁膨胀、锁消除等机制。到了 JDK 1.8synchronized 在低竞争场景下几乎不输 ReentrantLock在高竞争场景下由 JVM 负责锁升级开发者不用手动控制。ConcurrentHashMap 作者 Doug Lea 之所以在 JDK 8 里放弃 ReentrantLock 改用 synchronized我理解有三点考虑锁粒度细了之后大多数锁竞争根本不会发生synchronized 的偏向锁和轻量级锁机制在这种场景下开销极低。synchronized 是 JVM 内置的后续 JVM 版本可以持续优化它但 ReentrantLock 的代码逻辑是固定的没办法借助 JVM 升级。代码可读性和维护成本更好不用像 Segment 那套逻辑一样额外维护一个锁对象的生命周期。换句话说不是 ReentrantLock 不好而是 JDK 8 之后 synchronized 在细锁粒度场景下已经足够好而且代码更简洁。1.3 锁粒度从一段变成一桶JDK 8 的 ConcurrentHashMap 直接取消了 Segment改成直接用一个 Node 数组保存数据也就是 table。Node 数组的每个位置叫一个哈希桶bin。并发控制的基本单位从一个段缩小到了一个桶。两个线程同时写不同的桶基本互不干扰即使 hash 冲突严重多个线程同时写同一个桶才会出现锁竞争。锁粒度的缩小带来的收益是巨大的。并发度不再受固定段数限制而是取决于哈希桶的数量和 hash 分布的均匀程度。默认情况下 table 初始容量是 16但实际上扩容之后可以到很大的规模理论上并发度远高于 JDK 7。但是缩小锁粒度不是平白无故就能做的。桶级别加锁必须解决一个关键问题两个线程同时发现同一个桶是空的都往里面放第一个节点这时候谁说了算如果也用 synchronized 锁整个 table 或某个全局对象那又回到了粗粒度。JDK 8 的答案是空桶插入这种原子操作根本不需要锁用 CAS 就够了。2. 走进内部结构Node、TreeBin 与 sizeCtl 的巧妙配合2.1 Node 数组与链表的存储方式JDK 8 的 ConcurrentHashMap 内部有一个transient volatile NodeK,V[] table这是最核心的存储结构。Node 是一个普通的链表节点包含 hash、key、value 和 next 四个字段其中 value 和 next 都声明为 volatile保证可见性。static class NodeK,V implements Map.EntryK,V { final int hash; final K key; volatile V val; volatile NodeK,V next; // ... }正常插入时如果 key 定位到的桶是空的就创建一个 Node 直接放进去如果桶里已经有节点就沿着链表往后找找到了就替换 value找不到就追加到链表尾部。链表长度超过阈值之后会转为红黑树结构此时桶里的头节点会变成一个 TreeBin 节点hash 值为 -2。TreeBin 并不是直接把红黑树节点暴露出来而是作为一个外壳节点内部维护红黑树的根节点并且持有自己的读写锁状态。后续对树的插入、删除、查询都要经过 TreeBin。2.2 为什么链表转红黑树的阈值是 8容量阈值是 64这是面试高频问题也是源码里非常经典的一处设计。链表转树涉及两个条件链表长度达到TREEIFY_THRESHOLD 8。table 容量不小于MIN_TREEIFY_CAPACITY 64。先解释 64。如果 table 容量还很小比如只有 16 或 32说明连扩容空间都没充分用起来此时与其把链表转成红黑树不如先扩容让元素分散到更多桶里。扩容是比树化更根本的解决办法。再解释 8。源码注释里给了概率分析在理想随机哈希函数下当负载因子为 0.75 时某个桶里链表长度达到 8 的概率大约是千万分之六。这个概率已经低到可以认为正常业务里几乎不会发生一旦出现链表长度到 8 的情况大概率是 key 的 hashCode 设计严重有问题或者发生了恶意 hash 碰撞。此时用红黑树把最坏情况下的查询复杂度从 O(n) 降到 O(log n)作为兜底方案非常合理。这也是一个非常好的设计思路大多数情况下用最简单的链表性能足够好只有极端情况才升级到复杂结构。而不是一开始就用红黑树徒增维护成本。2.3 sizeCtl一个变量管三件大事sizeCtl 是 ConcurrentHashMap 里一个非常关键的 volatile 变量它的不同取值代表了不同的状态sizeCtl 值含义-1正在初始化 table-(resizingThreadCount 1)正在扩容负数的绝对值减 1 表示参与扩容的线程数0尚未初始化默认容量 16正数下一次触发扩容的阈值约为当前容量 × 0.75我把 sizeCtl 理解成 ConcurrentHashMap 的门卫。初始化 table 时多个线程可能同时进入 initTable但只有 CAS 把 sizeCtl 从 0 改成 -1 成功的那个线程才有资格创建 table其他线程则自旋等待。扩容时sizeCtl 为负数表示已经有人在进行扩容了新加入的线程看到 MOVED 状态的节点后会进来帮忙每加入一个线程sizeCtl 的绝对值就加 1完成任务后减 1直到变为正数代表扩容结束。3. put 方法的完整作战流程两处关键并发控制3.1 前置准备spread 哈希与 table 初始化put 方法入口是putVal第一步计算 key 的 hash。JDK 8 里不是直接用key.hashCode()而是做了一个 spread 扰动static final int spread(int h) { return (h ^ (h 16)) HASH_BITS; }这一步把 hashCode 的高 16 位和低 16 位异或让高位的差异也能影响到底部位。因为 table 的长度是 2 的整数次幂定位桶时用的是(n - 1) hash只用到 hash 的低位。如果不做扰动只要 hashCode 的低位相同即使高位差异很大也会被映射到同一个桶容易产生碰撞。之后进入一个for (;;)自旋每一步都检查当前状态if (tab null || (n tab.length) 0) tab initTable();initTable 这个操作并发度很高多个线程可能同时调用。它的核心逻辑是 CAS 修改 sizeCtl 从当前值变成 -1抢到资格的线程创建 table其他线程 yield 自己后继续自旋等待else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { // 初始化 table }这里 CAS 最重要的意义在于不需要全局锁就能保证只有一个线程完成初始化。3.2 空桶插入CAS 一锤定音拿到 table 之后根据 hash 定位到桶的位置。如果桶为空说明这个位置没有竞争直接用 CAS 把新节点放进去else if ((f tabAt(tab, i (n - 1) hash)) null) { if (casTabAt(tab, i, null, new NodeK,V(hash, key, value, null))) break; }casTabAt底层是Unsafe.compareAndSwapObject也就是 CAS 指令。它会比较 table 第 i 个位置的引用是否为 null如果是则替换为新建的 Node替换成功就退出循环。这里是 JDK 8 设计的精髓空桶插入是一个原子操作完全不需要加锁。如果 CAS 失败说明有其他线程刚刚抢先插入了那么当前线程会重新循环走到下面分支。CAS 的代价比 synchronized 小得多没有线程阻塞和唤醒也没有锁对象头部的状态维护。所以尽量用 CAS 解决最轻量级的竞争是这套设计的第一准则。3.3 非空桶插入synchronized 锁头节点如果桶里已经有节点了说明存在竞争CAS 就派不上用场了。此时需要保证对这条链表的修改是互斥的。JDK 8 的做法是给这个桶的头节点加 synchronized 锁else { V oldVal null; synchronized (f) { if (tabAt(tab, i) f) { // 处理链表逻辑 } } }f 就是当前桶的头节点。锁住 f等于是锁住了这条桶对应的整条链表。为什么锁头节点就够因为所有要操作这条链表的线程都必须先拿到同一个头节点对象作为锁。其他桶的线程不受影响因为它们锁的是各自的头节点。这里还有一个容易忽略的关键操作进入 synchronized 块之后会再次执行tabAt(tab, i) f做双重检查。为什么需要因为当前线程是在进入同步块之前就拿到了 f但另一个线程可能在我们拿 f 之后、进入同步块之前通过 remove 或扩容把桶的头节点换掉了。如果不重新检查我们锁的可能是一个已经不在 table 里的旧节点而真正操作 table 的线程锁的却是新节点两个线程各锁各的线程安全就无从谈起。在 synchronized 块内部通过 hash 值区分两种情况。如果头节点的 hash 大于 0说明是普通链表节点就遍历链表for (NodeK,V e f;; binCount) { K ek; if (e.hash hash ((ek e.key) key || (ek ! null key.equals(ek)))) { oldVal e.val; if (!onlyIfAbsent) e.val value; break; } NodeK,V pred e; if ((e e.next) null) { pred.next new NodeK,V(hash, key, value, null); break; } }链表中如果找到了相同 key就替换 value如果遍历到链表末尾还没有就追加到尾部。这个逻辑跟 HashMap 很像只是外面多包了一层 synchronized。3.4 遇到红黑树走 TreeBin 分支如果头节点的 hash 等于 -2说明是 TreeBin 节点走树插入分支else if (f instanceof TreeBin) { NodeK,V p; binCount 2; if ((p ((TreeBinK,V)f).putTreeVal(hash, key, value)) ! null) { oldVal p.val; if (!onlyIfAbsent) p.val value; } }同时在链表的同步块之外putVal 的最后会检查链表长度是否达到树化阈值if (binCount ! 0) { if (binCount TREEIFY_THRESHOLD) treeifyBin(tab, i); // ... }treeifyBin 会再次判断 table 长度是否大于等于 64如果小于 64则先触发扩容而不是直接树化。3.5 插入完成后的 addCountputVal 最后调用了addCount(1L, binCount)这一步不知道有没有发现它是整个 put 流程非常精妙的设计。addCount 的职责有两件事一个是计数加 1另一个是判断是否需要扩容。这里的计数不是简单地对一个 long 变量做加法因为高并发下这个变量会成为巨大的竞争热点。我用一个生活化类比如果全公司几千人同时去前台签到前台一定会排长队。更好的办法是每个人先在自己部门签到最后汇总部门人数。ConcurrentHashMap 的 CounterCell 数组就是这个部门签到本多个线程各自更新自己的 cell最后 sum 汇总。addCount 里还有扩容逻辑if (check 0) { NodeK,V[] tab, nt; int n, sc; while (s (long)(sc sizeCtl) (tab table) ! null (n tab.length) MAXIMUM_CAPACITY) { ... if (sc 0) // 已有线程在扩容本线程参与协助 else if (U.compareAndSwapInt(this, SIZECTL, sc, sc 1)) // 本线程成为第一个触发扩容的线程调用 transfer } }扩容的时候sizeCtl 被 CAS 修改为负数后续线程看到负数就会进来帮忙。这也是 ConcurrentHashMap 和其他 Map 最大的不同扩容是支持多线程协作的。4. 读取与删除无锁读取背后的可见性保障4.1 get 方法为什么可以不加锁get 方法全程没有加锁它是如何保证并发安全性的看一下 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) { if ((ek e.key) key || (ek ! null key.equals(ek))) return e.val; } // ... 树查找 while ((e e.next) ! null) { // 链表查找 } } return null; }get 无锁的安全基础建立在三点table 数组本身是 volatile 的读线程能看到最新的数组引用。Node 的 val 和 next 字段都是 volatile 的读线程能读到最新写入的值。写线程修改链表结构时持有的是桶头节点的 synchronized 锁但读线程不加锁也能保证不会读到半修改状态严格说这里不是绝对保证而是通过 volatile 保证可见性同时通过写操作要么替换 val要么追加节点这种不可变发布的方式保证读线程永远能读到完整结构。Node 的 next 一旦发布通过 volatile 写或者 CAS之后就不会再变。链表修改最常见的是追加尾部追加完成后新节点对读线程可见。删除操作只是把前一个节点的 next 指针指向被删节点的下一个节点读线程要么读到旧链路上的节点值可能稍旧要么读到新链路上的节点绝不会读到损坏的结构。工程上我把这个方案称为无锁读 写者串行化同一个桶的写者之间互斥读者之间并行读者和写者之间不需要互斥靠 volatile 保证可见性。这在读多写少的场景下性能优势非常明显。4.2 remove 的逻辑锁住头节点再删remove 方法最终调用 replaceNode逻辑跟 put 类似定位桶用 synchronized 锁住头节点然后遍历链表找到匹配节点并删除。删除操作需要维护前驱节点的 next 指针if (pred ! null) pred.next e.next; else setTabAt(tab, i, e.next);如果删除的是头节点就用 CAS 直接更新桶位置为新头节点。否则修改前驱节点的 next。由于在 synchronized 块内所以同一时间只有一个线程在改这条链表不会出现 next 指针被并发修改的问题。删除红黑树的节点时TreeBin 内部有自己的同步机制。当节点数量比较少时会触发去树化untreeify把红黑树重新退化为链表阈值是 6。这里没有直接用 8是为了避免节点数量在 7 到 8 附近频繁地树化和去树化来回抖动影响性能。留一点裕度这是工程设计里常见的防抖思路。4.3 迭代器是弱一致性的不是快照式的并发集合的迭代器设计是所有使用者必须理解的。ConcurrentHashMap 的迭代器不会抛 ConcurrentModificationException也不会在创建时复制整个集合的快照而是直接引用底层的 table 来遍历。这种设计带来的效果是迭代器创建之后如果其他线程新增了节点迭代器可能看不到如果其他线程删除了节点迭代器可能经过已删除的节点已经遍历过的部分不会受影响没遍历到的部分也不保证看到最新状态。这种一致性级别叫弱一致性weakly consistent。很多初学者会把 ConcurrentHashMap 的迭代器误当成线程安全的快照集合这是不对的。如果业务场景要求迭代时看到的是某一时刻的稳定状态最简单可靠的办法是手动加锁或者先把数据复制到一个普通集合里再遍历。5. 扩容机制单线程任务如何被拆成多线程协作5.1 先从全局视角看扩容流程扩容是整个 ConcurrentHashMap 最复杂的部分没有之一。普通 HashMap 的扩容是单线程完成创建一个新数组把旧数组里所有元素重新哈希放进去。ConcurrentHashMap 为了保证并发性能把这份工作拆分成了很多小任务允许多个线程一起搬运。扩容从 addCount 或 treeifyBin 中触发进入tryPresize或直接调用transfer。transfer 是核心搬运方法。transfer 的第一步是把旧 table 的长度 n 分成若干段每段包含的桶数量通过stride计算默认最少是 16 个桶一段。然后通过一个transferIndex变量来分配任务。transferIndex 初始值是旧 table 的长度每次有线程来帮忙就通过 CAS 把 transferIndex 往前推进一段这段范围内的桶就归这个线程搬运。5.2 一个桶怎么搬低位高位拆分搬运单个桶时如果桶里是普通链表不会逐个节点重新 hash那样太慢了。JDK 8 用了一个巧妙的方法因为新 table 的长度是旧 table 的一半扩展比如旧长度是 16新长度是 32定位桶时从(16 - 1) hash变成(32 - 1) hash相当于多取了一位 hash 位。实际上JDK 7 就已经使用类似机制但 JDK 8 的 CHM 延续了这个思路对链表的每个节点判断(e.hash oldCap) 0。如果等于 0说明这个节点扩容后还在原来的位置 i如果不等于 0说明扩容后会移动到 i oldCap 的位置。这样一次遍历就能把链表拆成低位链和高位链各自放到新数组的对应位置。这里我还是用个例子说明假设旧数组长度是 16某个节点 hash 的低 4 位是 0101它原来在桶 5。新数组长度是 32hash 的低 5 位如果第 5 位是 0则是 00101 还是 5如果第 5 位是 1则是 10101 也就是 21正好是 5 16。所以扩容后链上的每个节点只可能去两个位置原位置或者原位置加 oldCap。搬运完成后旧桶位置会被替换为一个 ForwardingNode哈希值为 -1即 MOVED。这个节点的作用有两个一是告诉其他线程这个桶已经搬完了你可以跳过二是从旧数组访问该桶时可以通过 ForwardingNode 的 nextTable 引用去新数组里找。5.3 helpTransfer其他线程怎么搭把手当某个线程执行 put 或 remove 操作时如果发现桶的头节点是 ForwardingNode就会调用 helpTransfer 去帮忙else if ((fh f.hash) MOVED) tab helpTransfer(tab, f);helpTransfer 内部会先检查 nextTable 是否为空、sizeCtl 是否为负数确认确实有人在扩容然后 CAS 更新 sizeCtl 的绝对值表示多了一个参与者。接着就调用 transfer 加入搬运行列。这种多人协作的模式核心在于 task 分配和进度同步都依赖 sizeCtl 和 transferIndex 上的 CAS 操作不需要全局锁。最后一个完成搬运的线程负责检查所有桶是否都处理完把 table 引用指向新数组并且重新计算 sizeCtl 为新的扩容阈值。实际工作中扩容效率的提升可能没有想象中那么巨大因为锁竞争和 CAS 开销仍然存在但相比旧版本单线程迁移确实能明显缩短扩容导致的停顿时间。6. 计数与 size高并发下的计数方案6.1 为什么不能只用一个 long 变量计数直接用一个 long 类型的变量每次 put 加 1、每次 remove 减 1在低并发下没问题。但在高并发下所有线程都要 CAS 修改同一个变量CAS 失败后自旋重试会导致严重的缓存行竞争和总线风暴。Linux 下的 CPU 缓存一致性协议MESI会让多个 CPU 核心频繁地同步同一个缓存行性能损耗非常大。这和我们平时用的 LongAdder 是同一个道理不要所有线程都打同一个变量把变量拆成多个槽位每个线程只更新属于自己的槽位。6.2 CounterCell 数组与 fullAddCountConcurrentHashMap 在 baseCount 的基础上增加了一个 CounterCell 数组。addCount 的流程可以简化为先尝试 CAS 更新 baseCount。如果 CAS 失败说明竞争来了进入 CounterCell 数组逻辑。通过 ThreadLocalRandom.getProbe() 算出一个线程相关随机值定位到 CounterCell 数组的某个槽位。如果槽位为空创建一个 CounterCell 放进去。如果槽位不为空CAS 更新该 cell 的 value。如果 CAS 仍然失败说明这个槽位竞争太热调用 fullAddCount扩大 CounterCell 数组让线程分布到更多槽位。CounterCell 数组最大长度是 CPU 核心数因为再多的槽位也没有物理上的并行能力了。这种设计跟 LongAdder 如出一辙实际上 Doug Lea 写 ConcurrentHashMap 时确实借鉴了 LongAdder 的思想。6.3 size() 的读取成本size() 方法不是直接返回 baseCount而是把 baseCount 和所有 CounterCell 的 value 全部加起来public int size() { long n sumCount(); return (n 0L) ? 0 : (n Integer.MAX_VALUE) ? Integer.MAX_VALUE : (int)n; }sumCount 遍历 CounterCell 数组把所有 value 累加。这个过程没有加锁所以得到的是一个近似值在并发写入频繁时可能和真实值有偏差。如果你需要精确的、和某个时间点一致的 size这个实现并不适合。很多面试题会在这里挖坑问ConcurrentHashMap 的 size 准确吗答案是不保证强一致。不过在绝大多数场景下这个 API 已经够用了没必要为了精确 size 付出全局锁的代价。我们做业务的时候如果非要一个稳定的 size更常见的做法是额外维护一个原子计数器或者用 LongAdder。7. 工程里真正用得上的一些思考7.1 我们能从这套设计里学到什么ConcurrentHashMap 的源码我前前后后读过好几遍每读一遍都会有一些新的体会。它给我最大的启发不是某个具体的知识而是并发控制级别的选择如果能用原子操作解决就不要上锁。空桶插入用 CAS计数用 CAS这是所有竞争里最轻量级的一类。如果必须上锁锁的范围越小越好。JDK 8 只锁桶头节点而不是锁整个 table也不是锁一个 Segment。复杂的并发任务用 CAS 协调进度和状态用锁保护具体的数据修改。扩容里任务分配用 CAS链表修改用 synchronized两者各司其职。在日常业务开发中我经常看到有的同学一谈到线程安全就想加锁结果所有线程互相等待并发变成串行。其实很多场景先用原子变量再用细粒度锁性能会好很多。7.2 实际使用时的几点提醒基于我踩过的坑提几个使用 ConcurrentHashMap 时容易忽略的问题。第一key 的 hashCode 质量非常关键。如果 hashCode 写得很差比如大量 key 的 hashCode 值相等所有节点都会挤在同一个桶里锁竞争就没法避免。对于自定义对象作为 key一定要实现一个分散度高、稳定的 hashCode。第二computeIfAbsent方法里不要做耗时操作或者递归插入同一个 map。JDK 8 的 computeIfAbsent 在对应桶上加锁如果 lambda 里做了耗时逻辑其他访问同一桶的线程都会被阻塞。我见过有人在这个 lambda 里发 HTTP 请求线上直接卡死。可以把耗时操作放在外面或者使用 putIfAbsent 这种无回调的方法加上自己的初始化逻辑。第三允许 null 的问题。ConcurrentHashMap 不允许 null key 和 null value如果你往里放 null包装类型不会被拆箱直接抛 NullPointerException。原因很简单在并发环境下无法区分 value 是 null 还是不存在。这在写通用工具类时很容易踩尤其不要把接收到的集合直接转成 ConcurrentHashMap。第四使用迭代器做聚合计算时要明白弱一致性。统计数据可能不是实时的如果有强一致性要求用 synchronized 锁住整个 map或者先快照到普通 list。但这个快照操作也要小心直接在迭代时复制会暴露弱一致性问题且可能出现线程切换期间的数据变化。7.3 性能调优的一点建议对于高并发写入的业务初始化容量最好直接给足比如new ConcurrentHashMap(expectedSize / 0.75f 1)避免频繁扩容。扩容虽然支持多线程协作但毕竟需要大量搬迁性能开销不是免费的。如果并发量非常大且写多读少可以考虑用 LongAdder 单独维护业务指标的计数不要依赖 ConcurrentHashMap 的 size()因为它要遍历 CounterCell 数组高频调用时也有开销。JDK 8 之后ConcurrentHashMap 已经是一个可靠性非常高的组件绝大多数场景不需要自己造轮子。真正需要你投入精力的往往是理解它的行为然后在正确的场景里正确地使用它。这也是我写了这么多年并发代码之后最大的感悟。