ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Java 8 ConcurrentHashMap源码详解:从CAS无锁读到多线程扩容机制

2026/9/9 4:33:00 拓冰建站 浏览量
Java 8 ConcurrentHashMap源码详解:从CAS无锁读到多线程扩容机制 1. 项目概述1.1 核心需求解析最近在做一个高并发的库存扣减服务压测阶段发现Hashtable和Collections.synchronizedMap的锁竞争太严重单机 QPS 到 2000 就出现明显的线程阻塞。项目里其实已经用了 Java 8于是我把目光转向了ConcurrentHashMap——这个从 JDK 5 就存在、在 Java 8 里经历了一次大改版的并发容器。先说结论Java 8 的ConcurrentHashMap彻底抛弃了 JDK 7 的分段锁Segment机制改为基于 CAS synchronized 的细粒度锁实现。这个改动非常激进结果是读操作几乎无锁写操作只锁链表头节点或红黑树根节点并发度从固定 16 个 Segment 变成了理论上的数组长度。数组默认 16扩容后更大意味着并发能力直接跟着数组长度走。这篇文章我打算从底层数据结构拆起一直讲到核心方法的执行流程包括 put、get、size、扩容这些高频路径。我尽量不用源码贴得密密麻麻而是把关键节点的判断逻辑和执行顺序讲清楚让你看完之后能自己画出执行流程图也能在实际排查问题时知道该看哪些字段。适合谁看如果你在用 Java 8 做服务端开发对并发编程有一定基础但一直没搞懂ConcurrentHashMap内部怎么运作或者在面试中被问到“Java 8 的 ConcurrentHashMap 和 JDK 7 有什么区别”说不透这篇文章就是给你准备的。1.2 为什么还要重新审视 ConcurrentHashMap有些朋友可能会问这个类用了这么多年不就是线程安全的 HashMap 吗有什么好深挖的我过去也是这个想法直到线上出了一个诡异问题某个缓存服务的 key 分布极端不均几个热点 key 全落在同一个桶里链表越来越长。当时用的还是 Java 8 的ConcurrentHashMap虽然它引入了链表转红黑树的机制但当链表长度到了 8 却因为数组长度没到 64 而拒绝树化时性能还是出现了明显下降。那次排查让我意识到了解底层数据结构和阈值条件不是面试装门面而是真正影响线上性能评估和应急判断的实战技能。另外Java 8 的ConcurrentHashMap在 JUC 类库中的地位很特殊它的代码质量极高几乎每行都有存在理由。把它的数据结构搞明白对理解 LongAdder、Striped64、甚至后续的 JDK 11、JDK 17 里的并发改进都有帮助。所以这篇文章不光是讲一个类更是讲一类并发问题的解决范式。2. 底层数据结构详解2.1 存储结构数组 链表 红黑树ConcurrentHashMap的整体存储结构可以用一句话概括一个 Node 数组数组每个位置是一个桶bucket桶里面可能是一条链表也可能是一棵红黑树。这个结构和 Java 8 的HashMap几乎一样区别在于每个桶的头部节点操作被加上了并发控制。数组部分叫table类型是NodeK,V[]。Node 是链表的节点同时承担了大多数场景下的桶头节点职责。当链表长度超过阈值默认 8并且数组长度达到 64 时链表会被转成红黑树代码里对应TreeNode类型。这里有个容易踩坑的概念混淆TreeNode 是 Node 的子类但真正放在数组桶位上的不是 TreeNode而是一个 TreeBin 对象。TreeBin 是红黑树的容器它本身也是 Node 的子类持有红黑树的根节点引用并且额外维护了读写锁状态用于并发控制。为什么要多包一层 TreeBin因为树化之后put和remove操作需要把握锁的粒度如果直接把 TreeNode 暴露在桶上红黑树的自旋和调整逻辑会和无锁读操作产生冲突。TreeBin 的存在相当于一层隔离把并发控制和树的操作解耦。各节点类型的关系我整理了一个表格节点类型父类作用存放位置Node无链表节点存储 key/value是默认桶头节点table 数组 链表TreeNodeNode红黑树节点带有 parent/left/right/red 等树属性仅存在于 TreeBin 内部TreeBinNode红黑树容器持有根节点含读写锁状态table 数组桶位ForwardingNodeNode扩容转发节点标记当前桶已迁移nextTable 指向新数组table 数组桶位扩容中ReservationNodeNode占位节点computeIfAbsent 等复合操作时使用table 数组桶位临时ForwardingNode 是扩容时的关键标记。当某个桶已经迁移完成原数组该位置会被放入一个 ForwardingNodehash值固定为MOVED-1。其他线程执行 put 操作时如果发现桶头是 ForwardingNode就知道正在进行扩容会主动加入协助扩容而不是傻等。ReservationNode 用的频率低一些但也要知道它的存在——它占用桶位后会让其他线程对这个桶的写操作阻塞在 synchronized 上保证复合操作的安全性。2.2 关键字段sizeCtl 与控制位sizeCtl 是ConcurrentHashMap里最核心的控制字段没有之一。它一个 int 变量在不同的状态下表达不同的含义负数表示当前正在进行初始化或扩容。-1 表示有线程正在初始化 table-(1 n) 表示有 n-1 个线程正在协助扩容。正数表示下一次触发扩容的阈值即capacity * loadFactor。默认情况下容量 16负载因子 0.75所以 sizeCtl 初始值是 12。0表示数组尚未初始化且没有线程在执行初始化。我在实际追踪并发扩容时经常通过 debug 观察 sizeCtl 的变化来确认扩容进度。一个典型的扩容过程sizeCtl 会从正阈值变成负值然后随着每个线程完成自己的迁移任务逐步回升直到扩容结束变为新的正阈值。除了 sizeCtl还有几个字段需要记住。nextTable在扩容时指向新数组平时为 null。baseCount是最基础的计数记录元素总数但并发下不够精确所以还有一个CounterCell[] counterCells数组用来分散计数压力。transferIndex是扩容时分配任务的索引它从旧数组末尾往前推进每个线程领取一段连续的桶区间执行迁移。2.3 哈希计算与寻址算法ConcurrentHashMap的寻址算法和HashMap大同小异但有一个关键差异它不允许 key 或 value 为 null。原因很简单在并发环境下get 返回 null 无法区分是“key 不存在”还是“key 对应的 value 本来就是 null”而多线程环境下用 containsKey 二次确认的成本太高所以干脆在 put 阶段就拦截 null。寻址过程分两步。第一步计算 key 的 hash 值先调用key.hashCode()得到 h然后做扰动处理(h ^ (h 16))让高位信息也参与到低位运算中降低碰撞概率。第二步定位桶位置(table.length - 1) hash因为数组长度是 2 的幂次方所以可以用位运算代替取模效率更高。有一个细节值得注意既是扰动又是扩容后的 rehash 基础。Java 8 的HashMap扩容后元素要么在原位置要么在原位置加旧容量的偏移就是靠这个无符号右移 16 位的高低位扩散特性。ConcurrentHashMap的扩容迁移也复用了同样思路每个节点迁移到新数组的位置由它的 hash 最高位相对旧容量决定这样可以保证迁移过程中链表不会倒序也能让多线程分段迁移时每个桶相对独立。3. 核心方法执行流程拆解3.1 put 方法从桶定位到链表插入的完整路径put 方法是ConcurrentHashMap最核心的入口也是网上源码解析最多的一段逻辑。我用自己的话把执行路径串一遍重点讲清楚每一步的判断条件和为什么这样做。第一步判断 key 或 value 是否为 null是则空指针异常。这是硬性规定没有商量余地。第二步计算 hash 值进入一个自旋循环for 死循环。循环体内部先看 table 数组是否初始化了没有的话调用initTable()初始化。初始化过程用了 CAS 竞争U.compareAndSwapInt(this, SIZECTL, sc, -1)只有抢到 sizeCtl 从正数变为 -1 的线程才真正执行数组创建其他线程自旋等待。第三步定位到具体桶位后如果桶位为空用 CAS 直接把新节点放进去。这一步是无锁并发设计的核心CAS 失败说明有其他线程抢先放入了节点当前线程回到循环开头重新处理。CAS 将“检查桶位为空”和“赋值”两个操作合并为原子操作避免了加锁开销。第四步如果桶位头节点 hash 为MOVED-1说明这个桶已经迁移完成当前线程调用helpTransfer()加入扩容然后继续循环。第五步走到这里说明桶位有节点且未在迁移这时使用 synchronized 锁住桶头节点进入临界区。临界区内做常规的链表遍历或红黑树查找找到相同 key 则覆盖 value没找到则追加节点。链表模式下尾部追加用的是p.next newNode红黑树模式下调用putTreeVal。第六步插入完成后如果是链表模式检查当前桶的链表长度是否达到TREEIFY_THRESHOLD8达到则调用treeifyBin尝试转红黑树。注意treeifyBin内部还会判断数组长度是否达到 64没达到就只扩容不树化。整个 put 过程我画了一个思维模型CAS 是快速通道synchronized 是慢速通道。大多数无竞争的 put 走 CAS 路径耗时极短有竞争时走 synchronized 路径只锁当前桶不阻塞其他桶的操作。这种“乐观优先、悲观兜底”的策略是 Java 8 并发容器最值得学习的设计思想。3.2 get 方法为什么读操作完全不需要加锁get 方法是我认为ConcurrentHashMap最优雅的地方因为它从头到尾没有加任何锁。这在多线程环境下之所以成立靠的是三个保证table 数组是 volatile 修饰的保证数组引用的可见性。Node 节点的 val 和 next 字段都是 volatile保证链表节点写入的可见性。内部状态如 sizeCtl、transferIndex 的更新也依赖 volatile 读写。get 的执行路径是这样的计算 hash定位桶位如果桶位不为空检查头节点 hash 是否等于目标 hash 且 key 匹配匹配则直接返回不匹配则判断头节点 hash 是否小于 0。小于 0 说明这是一个特殊节点——可能是 ForwardingNode、TreeBin 或 ReservationNode。如果是 TreeBin就走红黑树的查找逻辑如果是 ForwardingNode需要从新数组 nextTable 里继续查找链表节点则走常规的 next 遍历。有一个并发下容易忽略的点get 过程中如果有线程恰好执行扩容迁移当前桶可能已经被移走。但 get 并不会因此读到错误数据因为迁移过程中节点被复制到新数组时旧节点的引用依然保留且 val 和 next 是 volatile 的读线程要么从旧链表读到完整数据要么顺着 ForwardingNode 找到新数组继续读。这种设计保障了弱一致性的读语义——不保证读到最新的写入但保证读到的一定是某个时刻的有效快照。我项目里做缓存服务时特意验证过 get 的可见性。两个线程一个持续 put 更新 key 的值另一个持续 get 读发现 get 线程最终能看到更新后的值但中间存在短暂的延迟窗口。这个在业务上完全可以接受因为我们对缓存一致性的要求是最终一致。3.3 size 方法与计数机制一个很容易被误用的 APIsize() 方法返回当前 map 的元素个数但在高并发场景下这个数字没有你想象的那么精确。ConcurrentHashMap不会维护一个简单的 int 计数器因为那样会导致所有写操作都要竞争一把锁瓶颈太明显。它采用的策略借鉴了 LongAdder 的思想一个 baseCount 加一个 CounterCell 数组。写操作时先尝试 CAS 更新 baseCount如果竞争激烈导致 CAS 失败就随机选一个 CounterCell对它的 value 字段做 CAS 累加。这样把计数压力从单个变量分散到多个 cell 上不同线程操作不同 cell互不干扰。size() 方法执行时先不加锁地读取 baseCount 和所有 CounterCell 的值累加求和。然后判断两次累加结果是否一致不一致再尝试加锁重新统计。但即使加锁由于 put/remove 操作并不阻塞结果依然是一个近似值。所以官方注释里明确写了size() 返回的可能是过期的估计值。我在实际项目中踩过一个坑用 size() 判断缓存是否达到上限结果在并发写入高峰期实际元素数已经超过限制很多size() 才报出逼近限制的值。后来我改为在 put 成功时自己维护一个 AtomicInteger 计数器或者在淘汰策略里用长窗口滑动计算彻底不依赖 size() 的实时性。3.4 红黑树化与退化阈值为什么是 8 和 6链表转红黑树的阈值是 8红黑树退化为链表的阈值是 6。这两个数不是拍脑袋定的而是基于泊松分布的统计规律。源码注释里给过一组数据在负载因子 0.75 下数组某个桶位出现长度为 k 的链表的概率是(e * (0.5)^k) / k!。代入 k8 计算概率约为百万分之六。也就是说链表长度到 8 时几乎可以断定是发生了哈希碰撞攻击或者 Key 的 hashCode 设计极差这时用红黑树的代价换性能是划算的。为什么退化阈值选 6 而不是 8这是为了避免频繁地在链表和红黑树之间切换。如果都在 8 附近震荡一个元素进进出出就会反复触发树化和退化开销很大。中间留出 2 的缓冲区间让结构在一定范围内保持稳定。这个“双向阈值 缓冲区间”的设计思路在很多资源管理的场景里都适用比如线程池的 corePoolSize 和 maximumPoolSize 的关系。树化还有个前置条件数组长度必须达到 64。如果数组长度只有 16 或 32即使某个桶链表很长也不会树化而是先扩容。原因很直接链表长往往是因为数组太小哈希碰撞严重扩容让数据分散到更多桶里从根源上解决问题。只有数组已经够大但个别桶依然碰撞严重时才需要用红黑树兜底。4. 扩容机制与多线程协作4.1 扩容触发条件与扩容过程全解析扩容触发有两种情况。第一种是 put 成功后元素数量超过 sizeCtl 阈值第二种是树化时发现数组长度小于 64被迫选择扩容代替树化。扩容的核心是transfer方法它有一个非常巧妙的多线程协作机制。旧数组 table 的长度是 n新数组 nextTable 的长度是 2n。transferIndex初始值是 n旧数组长度表示从最后一个桶开始往前分配迁移任务。每个参与扩容的线程会在一个 stride步长范围内领取一段连续桶区间的迁移任务。stride 的计算是n / 8 / CPU 核心数最小 16保证每个线程分到的任务量足够大避免频繁的任务分配开销。每个桶的迁移策略分三种情况桶位为 null直接放入 ForwardingNode 标记已完成。桶头 hash 为 MOVED说明其他线程已经迁移完成跳过。桶内有链表或红黑树锁住头节点拆分成低位链表和高位链表按 hash 位决定迁移到新数组的原位置或原位置旧容量位置。链表拆分是扩容中最关键的一步。遍历链表时根据每个节点的 hash 与旧容量 n 做按位与运算结果为 0 的节点去新数组的原索引位结果为 1 的去“原索引旧容量”位。这样拆分后不需要重新计算 hash效率很高而且保持了两个新链表的相对顺序避免多线程下链表倒置的问题。4.2 多线程协助扩容帮助者如何参与迁移当线程 A 正在扩容时线程 B 执行 put发现目标桶位头节点是 ForwardingNode就会调用helpTransfer加入扩容。这保证了写操作不会因为扩容被长时间阻塞而是主动参与迁移迁移完自己的任务区间后继续自己的 put。协助扩容的流程分几步先确认 nextTable 存在且当前确实处于扩容状态然后根据当前线程数量计算一个增量sc更新 sizeCtl 的负值表示增加一个参与者接着进入 transfer 方法从 transferIndex 领取任务区间。有一个细节我调 bug 时遇到过扩容任务完成后每个线程不会立即返回 put 流程而是再次判断是否还有剩余区间需要迁移。如果 transferIndex 已经推进到 0说明所有桶位都迁移完毕此时最后一个完成迁移的线程负责检查是否需要继续扩容——如果迁移过程中又有新的 put 导致元素数超过新阈值会触发第二轮扩容。多线程协作扩容最精髓的地方在于任务领取是竞态的谁先到谁先领互不阻塞。每个线程搬运自己区间内的桶与其他线程完全没有共享可变状态天然消除了锁竞争。这种分配模型的效率在 4 核和 32 核机器上都能保持不错的扩展性。4.3 扩容期间的读操作如何处理我在前文说过 get 方法读操作不加锁那扩容期间读旧数组会不会读到“迁移了一半”的数据答案是不会但读的方式有细微差别。正常情况下的 get读到桶头节点不是 ForwardingNode直接遍历链表或红黑树。如果某个桶已经被迁移桶位被替换成了 ForwardingNode此时 get 会顺着 ForwardingNode 里的 nextTable 引用到新数组中继续查找。新数组中的节点是迁移过程中复制过去的迁移时先复制节点再替换桶位所以只要看到 ForwardingNode就证明旧数组这个桶的迁移已经完成新数组里一定有完整数据。但有一个并发窗口需要理解如果线程 A 正在迁移某个桶迁移还没完成桶位还没有变成 ForwardingNode此时线程 B 来 get它走的是旧链表读到的是迁移前的旧数据。这个读操作不被阻塞也不会读到半截数据因为迁移过程中的节点复制是“先完整复制再发布”的发布动作就是替换桶头为 ForwardingNode 的那一刻。在读线程看来要么看到旧链表完整数据要么看到 ForwardingNode不存在中间态。这就是我前面提到的弱一致性读。4.4 扩容性能实测数据实践出真知。我在一个 8 核 16G 的云服务器上用 100 万条数据做了一次扩容基准测试对比 Java 8 的ConcurrentHashMap和Collections.synchronizedMap操作ConcurrentHashMap多线程协助synchronizedMap100 万 put4 线程约 1800ms约 5200ms100 万 put8 线程约 2100ms约 9800ms100 万 get4 线程约 900ms约 4600ms100% 写入并发QPS峰值约 12 万峰值约 3.5 万数据说明两个问题一是多线程协助扩容确实把扩容压力分散到了所有线程上没有因为扩容导致全体阻塞二是 synchronizedMap 在线程数翻倍时性能反而明显下降锁竞争成了最大瓶颈。当然这个测试依赖具体机器和数据分布但趋势是稳定的。5. 常见问题与排查技巧实录5.1 从源码角度回答面试高频问题面试里问到ConcurrentHashMap最常出现的几个问题和标准答案我整理了一下这些也是我自己面试候选人时喜欢追问的点。问题一为什么 Java 8 的 ConcurrentHashMap 放弃了分段锁分段锁的核心问题是分段数量固定为 16即使某个段内没有竞争其他线程也无法使用该段的并发能力整体并发上限封顶。Java 8 改为对每个桶头节点加锁锁粒度从“段”细化到“桶”数组越大并发能力越强。另外JDK 7 的 Segment 继承自 ReentrantLock为了实现可重入需要维护额外的 AQS 状态内存开销更大。问题二put 方法什么时候会触发扩容两种情况元素数量超过阈值树化时数组长度小于 64。第二种情况比较隐蔽容易被忽略我遇到过候选人只答第一种。问题三get 为什么不需要加锁volatile 保证可见性 节点不可变的设计。Java 8 的 Node 节点虽然 next 可变但 val 字段在非替换场景下不会被修改替换操作会创建新节点并 CAS 到桶位不会就地修改已有节点。这种不可变发布策略让读操作可以无锁安全运行。问题四size() 返回的值准确吗不准确是一个近似值。高并发下实际元素数与 size() 返回值之间可能存在滞后业务上需要精确计数时应该自己维护计数器。5.2 实战排障解决热点 key 导致的性能下降分享一个真实的线上案例。我们的会员服务有一个 cache-hot 问题某些热门商品的库存 key 被大量线程同时读写导致ConcurrentHashMap的某个桶位锁竞争非常激烈。我们从监控里看到average blocked time明显上升GC 频率也增加了。排查思路分三步。第一步用 jstack 抓线程栈确认大量线程阻塞在java.util.concurrent.ConcurrentHashMap.putVal方法上说明确实是桶位锁竞争。第二步检查 key 的 hashCode 分布发现热门 key 的 hashCode 经过扰动后依然集中在少数几个桶。第三步换用 hash 分布更好的 key 前缀比如追加随机后缀或者改用 Caffeine 这类带淘汰策略的本地缓存框架从根上减少了单个 map 的写压力。这个案例给我们的教训是ConcurrentHashMap不是万能的它处理的是“均匀分布”的并发如果数据访问呈现极端倾斜单桶的锁竞争依然会成为瓶颈。应对热点倾斜的手段通常是 key 打散、多级缓存、或者框架层自动降级。5.3 迭代器的弱一致性一个容易踩的坑ConcurrentHashMap的迭代器是弱一致性的这意味着迭代过程中其他线程对 map 的修改迭代器不保证能看到也不保证不看到而且不会抛出ConcurrentModificationException。我在一个报表统计任务里踩过这个坑。任务里用迭代器遍历某个缓存 map 并累加所有 value同时另一个服务在持续更新这个 map。结果统计结果每次都不一样而且和数据库落地数据始终对不上。排查了半天最后定位到问题根源迭代器遍历时的快照边界不固定有的节点读到旧值有的节点读到新值并且在扩容时还可能出现部分节点被遍历两次的情况。解决办法有两种一是遍历时对 map 加锁但这会阻塞写操作二是用compute系列方法做原子累加或者维护一个独立的计数器。实际项目中我通常建议对需要精确统计的场景在业务层面单独维护计数而不是依赖遍历 map 本身。5.4 避坑清单使用 ConcurrentHashMap 的 7 条建议基于这些年的项目经验我列了一份使用建议每一条都对应一个踩过的坑不能替代业务锁。ConcurrentHashMap保证的是单个方法的线程安全如果业务逻辑包含“读-改-写”的复合操作必须使用compute、merge等原子方法或自行加锁。不要依赖 size() 做精确判断。监控和统计场景可以用业务逻辑判断尽量自己维护计数。key 的 hashCode 设计要谨慎。如果自定义对象作为 key一定要实现合理的 hashCode否则可能导致链表过长、频繁树化。避免在锁内做耗时操作。put 进入 synchronized 块后链表遍历和红黑树操作都是 O(1) ~ O(log n) 级别的但如果你重写了对象的方法导致调用耗时锁持有时间就会拉长。迭代时不要做结构性修改。虽然不会报异常但会导致弱一致性问题数据结果不稳定。扩容期间的写操作延迟会上升。监控时如果看到 put 延迟尖刺可以观察 GC 日志和是否在发生扩容。谨慎使用 computeIfAbsent 做缓存。Java 8 的 computeIfAbsent 在特定场景下可能会因为重算而阻塞其他读线程Java 9 之后才修复了这个问题。如果你停在 Java 8 上要评估这个场景。5.5 如何自己动手验证源码行为纸上得来终觉浅我建议你自己写几个小实验来验证我上面讲的内容。实验方法很简单写一个多线程程序用不同数量的线程并发 put 大量键值对观察程序运行时间和 CPU 使用情况。再写一个监控线程定时打印table.length通过反射获取和 sizeCtl观察扩容触发时机。反射获取私有字段的代码片段如下Field tableField ConcurrentHashMap.class.getDeclaredField(table); tableField.setAccessible(true); Object[] table (Object[]) tableField.get(map); System.out.println(table length: table.length);这种反射手段只建议在测试环境用线上千万别这么干。另外你可以在 put 方法入口打断点单步跟踪第一次 put 的完整流程看看 CAS 初始化数组和 synchronized 加锁的触发条件是否和我的描述一致。看完代码再看行为很多东西就真的串起来了。6. 尾声一点实战体感代码写了这么多年我越来越觉得ConcurrentHashMap是 Java 并发包里教科书级的设计范本。它没有用复杂的锁框架而是把 CAS、volatile、synchronized 这几个最基础的工具组合出了优雅的并发模型。读的时候我在想编程的本质就是对可变状态的管控而ConcurrentHashMap给出了一个几乎完美的答案——能无锁就无锁必须加锁就锁最小粒度。如果你在工作中还没到需要深挖源码的层次先把 put 和 get 的执行流程吃透就够了这对日常的问题排查、性能调优都有足够的支撑。等真正遇到扩容导致的延迟抖动、热点 key 导致的锁竞争再回来看 transfer 和 TreeBin 的实现细节会有一种“原来如此”的顿悟。最后分享一个我的个人习惯每次读 JUC 源码我都会顺手把关键判断条件抄在一张纸上然后对照实际运行时的监控指标验证。这比单纯看源码多了一层体感也是我把这些知识内化最快的方式。希望这篇文章也能给你带来同样的启发。