很久以前第一次翻开 ConcurrentHashMap 源码的时候,我的状态基本是:找到 JDK 安装目录,解压 src.zip,双击某个类,然后盯着 UnSafe、CAS、ForwardingNode、红黑树这些词半小时缓不过来。这篇源码学习笔记想帮你解决一个很现实的问题:如何把 ConcurrentHashMap 这个并发容器从数据结构到核心方法真正读懂,同时搞清楚它的性能和一致性边界在哪里。我按 JDK 8 版本的源码主线来拆,涉及底层结构、put 流程、扩容机制、无锁读的实现,以及实际项目里容易踩的坑。适合准备深入 Java 并发开发的开发者,也适合面试前系统梳理并发容器知识的同学。
1. 从 HashMap 的并发软肋说起:ConcurrentHashMap 为何必须存在
1.1 HashMap 并发写入丢数据:问题出在哪
正常情况下,HashMap 的 put 操作就是先根据 hash 定位数组下标,然后把 Node 挂到对应桶里。单线程下完全没问题,多线程下问题就大了,主要体现在两个地方。
第一个是并发写同一个桶。两个线程同时发现 table[i] 位置是空的,分别 new 出两个 Node,然后各自执行 table[i] = newNode。这一步赋值不是原子的,后写的线程就会把先写的节点直接覆盖掉,数据凭空消失。不过这里更常见的情况是,在 put 一个链表比较长的桶时,两个线程同时遍历链表,一个走到最后一个节点准备追加,另一个也走到最后一个节点准备追加,最后两个节点只能留下一个。
第二个是并发扩容。JDK 7 的 resize 在并发下可能让链表形成环,之后某个 key 的查找一旦落到环上,线程就死循环卡住。JDK 8 改成尾插法解决了成环问题,但并发扩容时的数据覆盖仍然存在。两个线程同时搬同一个桶,把一个节点从旧表挪到新表,互相覆盖之后节点就丢了。
这里最容易被忽视的一点是:HashMap 并发不安全不是单点问题,而是写操作和扩容两个环节都不安全。所以不能指望通过简单加几行 synchronized 来缝缝补补,必须重新设计一个并发专用的 Map。
1.2 Hashtable 和同步包装 Map 为何不够用
Hashtable 给 put、get、remove 这类方法全部加了 synchronized,锁的就是整个对象。Collections.synchronizedMap 内部用 mutex 锁整个 Map,原理差不多。这两种方案线程安全没问题,但读写操作全部串行化,任何两个线程想同时操作 Map,都要排着队等同一把锁。
读多写少的时候,这种设计显得非常浪费。因为 get 本身只是读数组里的元素,理论上完全没必要阻塞其他读线程;即使是写,如果写的是不同桶,也不应该互相干扰。整个图书馆只有一张桌子,所有人都只能到这张桌子上看书,效率天花板一眼就能看到。
在并发量稍微上来之后,这种全局锁方案会成为系统的性能瓶颈,这也是后面分段锁、细粒度锁出现的直接原因。
1.3 JDK 7 分段锁到 JDK 8 细粒度锁的演进
JDK 7 的 ConcurrentHashMap 引入了 Segment 分段锁。默认 16 个 Segment,每个 Segment 继承自 ReentrantLock,相当于把一整把大锁拆成 16 把小锁。Key 通过 hash 定位到某个 Segment,不同 Segment 之间可以并行写入。这个设计比 Hashtable 提升巨大,但锁粒度还是有点粗,毕竟一个 Segment 内部仍然可能包含很多桶。
JDK 8 则更进一步,直接去掉了 Segment,改用两种更细的机制:
- 桶为空时,用 CAS 无锁插入。
- 桶非空时,用 synchronized 锁住桶头节点。
锁的粒度从“16 段”细到“单个桶”,并发度基本跟着数组长度走。这种设计变化是整个 ConcurrentHashMap 源码的核心背景,后面看到的所有代码,本质上都是在为这条思路服务。
2. 看源码前的预备课:JDK 8 中的底层结构与关键字段
在追代码之前,先把几个最基础的结构弄清楚,否则后面很容易被各种字段绕晕。
2.1 Node 数组与 Node 节点结构
底层核心存储是一个 Node 数组:
transient volatile Node<K,V>[] table;Node 内部字段如下:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; volatile V val; volatile Node<K,V> next; }看到 volatile 要留个心:table 声明为 volatile,只保证数组引用本身的可见性,不保证数组里每个元素的可见性。也就是说,直接写table[i] = node这种赋值,其他线程不一定能立刻看到。
所以源码里面读取桶元素时,不会直接用table[i],而是通过tabAt方法配合Unsafe.getObjectVolatile来读;向空桶写入时,用casTabAt配合compareAndSwapObject。这种做法绕开了普通数组元素写入的可见性问题。
Node 自身的 val 和 next 都声明为 volatile,这保证了单个节点的值在并发读写下的可见性。key 和 hash 是 final,节点一旦创建,key 就固定不变,这也是后续 get 可以不加锁读取的前提之一。
2.2 sizeCtl 的五种状态
sizeCtl 是 ConcurrentHashMap 里最“烧脑”的字段,它身兼多职,靠正负号和具体数值区分不同状态:
| sizeCtl 值 | 含义 |
|---|---|
| -1 | table 正在初始化 |
| -(1+n) | 正在进行扩容,n 表示参与扩容的线程数 |
| 0 | table 尚未初始化且未指定初始容量 |
| 正数 | table 已经初始化,此时表示扩容阈值,约等于容量 x 0.75 |
初始化或扩容时,多个线程就是通过 CAS 抢着修改 sizeCtl 来决定谁干活。谁把 sizeCtl 从 0 改成 -1,谁就负责初始化数组;扩容时谁把 sizeCtl 从正整数改成负数,谁就发起了扩容。
需要提醒的是,里面的数值只是状态快照,不是一个绝对精确的线程数。因为 CAS 是并发修改的,你读到的 -(1+n) 可能略微滞后,但这不影响正确性。
2.3 spread 散列与桶定位
看 put 之前,先看一个静态方法:
static final int spread(int h) { return (h ^ (h >>> 16)) & HASH_BITS; }HASH_BITS 是 0x7fffffff,作用是清掉符号位,保证结果为正数。核心操作是把 hash 的高 16 位和低 16 位做异或,让高位信息也参与到低位计算中。
为什么要这么麻烦?因为数组定位用的是:
i = (tab.length - 1) & hash;tab.length - 1本质是一个低位掩码。如果直接用原始 hashCode,只有低几位参与定位,高位信息全被浪费,冲突概率会明显增加。spread 把高位的随机性“挤压”到低位,在数组容量还比较小的时候,也能让元素分布得更均匀一些。
2.4 hash & n:一个位运算拆分高低链
扩容时有一个非常关键的位运算:对每个节点判断hash & n(n 是旧数组长度,一定是 2 的幂)。
因为新数组长度是 2n,比旧数组多了一个二进制位。节点在新数组中的位置只有两种可能:
- 如果
hash & n == 0,说明多出来的最高位是 0,新位置还是原来的下标 i。 - 如果
hash & n == n,说明多出来的最高位是 1,新位置变成 i + n。
这个性质让扩容拆分极其高效。只需要对每个节点做一次位判断,就能把一条链表无损地拆成两条,不需要重新计算 hashCode,也不需要重新散列。理解这一点之后,再看 transfer 里的高低链拆分就轻车熟路了。
3. putVal 逐行拆解:初始化、空桶 CAS、非空桶加锁
ConcurrentHashMap 的 put 调用链是put->putVal。putVal 是整个写入逻辑的核心,我建议把它完整读一遍,这里我按关键阶段拆开讲。
3.1 initTable:用 CAS 裸循环完成数组初始化
putVal 的第一步是看 table 是否初始化,如果没有,就进入 initTable():
private final Node<K,V>[] initTable() { Node<K,V>[] tab; int sc; while ((tab = table) == null || tab.length == 0) { if ((sc = sizeCtl) < 0) Thread.yield(); else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) { try { // 创建 Node 数组并计算新阈值 } finally { sizeCtl = sc; } break; } } return tab; }这个自旋逻辑拆开看很有意思:
- 第一个线程读到 sizeCtl 不是负数,尝试用 CAS 把它从当前值改成 -1,成功后创建数组。
- 其他线程读到 sizeCtl 是负数,说明已经有人抢到初始化权,调用
Thread.yield()让出 CPU,等初始化完成。 - 如果第一个线程的 CAS 失败,说明发生竞争,外层 while 继续自旋重试。
为什么不用单独一把锁?因为初始化是低频操作,用 CAS 裸循环成本更低,而且失败线程会主动让出 CPU,不会形成锁竞争。初始化完成后,sizeCtl 会被设置为新阈值,等于初始容量减去四分之一,也就是 0.75 倍的容量。这借鉴了 HashMap 负载因子的设计。
3.2 空桶插入:CAS 失败就继续自旋
拿到 table 之后,先根据 hash 定位下标:
int i = (n - 1) & hash; Node<K,V> f = tabAt(tab, i); if (f == null) { if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null))) break; }tabAt是 volatile 读数组元素,casTabAt是 CAS 写入。如果目标桶是空的,就尝试直接把新 Node 放进去,整个过程不需要加锁。
CAS 成功说明插入完成,直接跳出循环;CAS 失败说明这瞬间有其他线程抢先写入,外层while (true)会重新执行,重新读桶、重新判断。这种无锁尝试是 ConcurrentHashMap 高并发下性能好的重要原因:大量插入实际命中空桶,用一次 CAS 就完成了,根本不走锁。
3.3 锁住桶头:链表遍历与红黑树写入
如果桶不为空,代码先看头节点的 hash:
int fh = f.hash; if (fh == MOVED) { helpTransfer(tab, f); } else { synchronized (f) { if (tabAt(tab, i) == f) { if (fh >= 0) { // 普通链表 } else if (f instanceof TreeBin) { // 红黑树 } } } }MOVED 是 -1,表示这个桶已经被标记为 ForwardingNode,正在扩容迁移中。当前线程不能再在这个位置直接写,而是要调用 helpTransfer 去帮忙扩容。
加锁的对象是桶头节点 f,不是整个数组。这意味着多个线程可以同时往不同桶里写,互相之间完全不影响。
锁住之后,还要做一次 double-check,重新用 tabAt 判断当前桶头是否还是 f。因为等待锁期间,其他线程可能已经在扩容过程中把这个桶替换成 ForwardingNode 了。如果不重新检查,就可能基于一个过期的头节点继续遍历,数据会写错地方。
锁内分两种情况:
- 普通链表:从头遍历,key 相同就根据 onlyIfAbsent 决定是否替换 value;遍历到结尾都没找到,就 new 一个 Node 挂在链尾。
- TreeBin:说明该桶已经树化,调用 putTreeVal 走红黑树插入。TreeBin 内部还有一套读写协调机制,能保证读线程不会阻塞写线程,这里不展开,但值得单独去看。
3.4 树化条件与 MIN_TREEIFY_CAPACITY 的先后顺序
链表长度达到 8 之后,会调用 treeifyBin。但 treeifyBin 不是无脑把链表变成红黑树,它第一件事是检查数组容量:
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY) tryPresize(n << 1); else if (binCount >= TREEIFY_THRESHOLD) // 真正树化MIN_TREEIFY_CAPACITY 是 64。如果数组长度小于 64,即使某个链表已经超过 8 个节点,也先扩容而不是树化。
原因一句话就能说清楚:容量太小时链表变长,很可能是因为桶太少而不是数据分布太差。扩容让桶数量翻倍之后,大量节点自然会散开,这比建红黑树更划算。只有容量足够大,链表还继续变长,才说明散列函数对这批 key 的分布确实不理想,此时树化才有意义。
树化阈值 8 和退化阈值 6 之间特意留了 2 的缓冲,目的是避免链表和红黑树在边界上来回横跳。这个设计在 HashMap 里也是一样的。
3.5 addCount 计数与扩容检查
putVal 的最后调用了 addCount:
private final void addCount(long x, int check) { CounterCell[] as; long b, s; if ((as = counterCells) != null || !U.compareAndSwapLong(this, BASECOUNT, b = baseCount, s = b + x)) { // 走 counterCells 分散计数 } if (check >= 0) { // 检查是否需要扩容 } }优先用 CAS 给 baseCount 加 1。竞争激烈时 CAS 总是失败,就把计数分散到 counterCells 数组里,每个线程或 CPU 核心维护自己的计数值,最后统一累加。这实际上是 LongAdder 的计数思路,目的是避免多线程同时修改一个计数器造成无限重试。
check 参数大于 0,说明本次插入涉及链表变更,可能让元素数量逼近阈值,所以 addCount 内部会检查是否触发扩容。如果 sizeCtl 已经小于 0,说明其他人正在扩容,当前线程不会干等,而是加入帮忙。这种“发现别人在干活就搭把手”的协作机制,是扩容流程里最有意思的设计。
4. transfer 扩容方法与 ForwardingNode:并发搬家的核心
扩容是整个 ConcurrentHashMap 源码里最复杂的部分,也是面试喜欢深挖的地方。
4.1 扩容步长 STRIDE 与 transferIndex 任务分配
transfer 方法开头,有一个非常朴素的逻辑:
int stride = (NCPU > 1) ? (n >>> 3) / NCPU : n; if (stride < MIN_TRANSFER_STRIDE) stride = MIN_TRANSFER_STRIDE;stride 代表一个线程一次领取的连续桶数量。CPU 核数多,每个线程可以少领一些;核数少就多领一些,最少 16 个桶。这样能让多核机器上的并发迁移更细粒度,避免某个线程一次领太多区域,导致其他线程闲置。
负责分配任务的全局变量是 transferIndex,初始值是旧数组长度 n。线程通过 CAS 把 transferIndex 减掉 stride,拿到一段连续桶区间,从高下标往低下标处理。CAS 失败就重新读 transferIndex 再试,直到抢到任务。
这个分配过程本质上是一个并发栈顶指针的 CAS 递减,没有锁,却能让多个线程各自领取互不重叠的区间。
4.2 ForwardingNode 的意义
迁移时,每处理完一个桶,就在旧数组这个位置放一个 ForwardingNode:
static final class ForwardingNode<K,V> extends Node<K,V> { final Node<K,V>[] nextTable; ForwardingNode(Node<K,V>[] tab) { super(MOVED, null, null, null); this.nextTable = tab; } }它把 hash 固定为 MOVED(-1),内部保存着 new 出来的新数组引用。
作用是两层:
- 标记这个桶已经迁移完成。之后的写线程看到 MOVED,就不会再尝试修改旧桶,而是去 helpTransfer 帮忙扩容。
- 提供跳转能力。读取线程如果碰到 ForwardingNode,通过它的 find 方法会到新表 nextTable 里继续查,保证 get 在扩容期间依然能读到数据。
理解了 ForwardingNode,就能理解 MOVED 这个状态在 put 和 get 里为什么都要特殊处理。它不是一个普通的业务节点,而是扩容过程的状态标记。
4.3 单桶数据拆分:低位链、高位链与 lastRun
锁住桶头后,对旧链表做一次遍历,把节点分成两条链:
for (Node<K,V> p = f; p != null; p = p.next) { int b = p.hash & n; if (b == 0) { // 连到低位链 ln } else { // 连到高位链 hn } }判断依据就是前面说过的hash & n。低位链放到新数组的 i 位置,高位链放到新数组的 i + n 位置。
这里有个 JDK 8 引以为傲的 lastRun 优化。源码在遍历时会记录最后一个hash & n结果变化的位置,如果链表尾部有连续一串节点都属于同一条链,就直接复用最后这一段,不用为这些节点重新 new 对象。对于长链表,这个优化能显著减少扩容时的 Node 分配数量。
迁移过程中,节点仍然在锁内处理,所以不会出现同一个节点被两个线程同时操作的情况。单个桶的迁移是原子的,多线程之间通过分配不同区间实现并行。
4.4 红黑树节点迁移与退化判断
如果桶头是 TreeBin,不能简单拆链表,因为红黑树的节点之间还有父子关系。源码先把 TreeBin 里的 TreeNode 按 next 指针串成链表,再遍历做拆分。拆完之后,如果某条链的节点数小于等于退化阈值 6,就直接用 untreeify 转回普通链表;如果仍然超过阈值,就 new 一个 TreeBin 重新构造红黑树。
这里可以对照 HashMap 的 split 方法,思路几乎一样,只是多了“树退化”的判断。注意,拆分后两条链的根节点不能直接复用,因为新链表顺序已经变了,必须按新顺序重新建树。
4.5 transfer 如何判断整个扩容结束
每个线程处理完自己领的区间后,会检查 transferIndex 是否已经被其他线程领完。如果领完了,当前线程就可能是最后一个完成者,源码用 finishing 标志进入第二次循环,重新扫描整个旧数组,确认所有桶都已经是 ForwardingNode。
确认完成后,执行收尾:
table = nextTab; nextTable = null; sizeCtl = (n << 1) - (n >>> 1);新容量是 2n,sizeCtl 变成新容量的 0.75 倍,也就是 1.5n。到这一步,整个扩容才算真正结束。
整个扩容过程里,不同线程并行处理不同区间,每个桶的迁移在锁内完成,没有数据竞争。线程通过 CAS 竞争 transferIndex 获取任务,谁领到谁干,干完自己的再帮别人,这种协作方式和任务窃取算法有神似之处。
5. 读操作为什么可以不加锁:get、size、compute 背后的设计
ConcurrentHashMap 最反直觉的一点就是 get 全程无锁,却依然能拿到正确数据。
5.1 无锁 get 为什么安全:volatile 字段可见性
get 方法的逻辑可以简化成这样:
tabAt(tab, (n - 1) & h) 拿到第一个节点; if (first.hash == h && key 相等) return first.val; if (first.hash < 0) return first.find(h, key); for (Node e = first.next; e != null; e = e.next) { ... }整个过程没有锁,安全来自于三层保证:
- Node 的 val 和 next 都是 volatile,读线程能看见锁内写入的最新值。
- 数组元素读取走的是 getObjectVolatile,能看见 CAS 写入的新节点。
- 如果桶头是 ForwardingNode,find 方法会跳到新表继续查,扩容期间要么在旧表读到,要么跳去新表读到。
所以并发读的核心不是锁,而是“volatile 保证可见性,CAS 保证原子性,ForwardingNode 保证跳转正确”这三者的组合。这也是很多初次读源码的人会困惑的地方:为什么明明没有 synchronized,却能读到线程安全的数据。
5.2 弱一致性遍历:能看到什么,看不到什么
ConcurrentHashMap 的迭代器是弱一致性的。它不会像 ArrayList 那样在并发修改时抛 ConcurrentModificationException,遍历过程中其他线程修改了 Map,可能看到旧数据,也可能漏掉新数据,但程序不会中断。
这个特点经常被忽略。如果业务要求遍历时拿到某个时间点的完整快照,ConcurrentHashMap 的迭代器不适合直接用于这种场景。需要先复制一份再遍历,或者用 synchronizedMap 替代。
这种设计取舍和 get 一样,是用“弱一致性”换并发性能。
5.3 sumCount 与 counterCells:size() 只是近似值
调用 size() 最终走到 sumCount:
long sum = baseCount; CounterCell[] as = counterCells; if (as != null) { for (CounterCell c : as) { if (c != null) sum += c.value; } } return sum < 0L ? 0L : (sum > (long)Integer.MAX_VALUE ? Integer.MAX_VALUE : (int)sum);累加 baseCount 和 counterCells 时没有加锁,所以任何一个瞬间读到的总和都只是当时数据的近似值。
并发写很频繁时,size() 和真实值可能有偏差。JDK 官方注释也建议大家用mappingCount()而不是size(),前者返回 long,避免超大 Map 在 int 上溢出。
实际业务里,如果只是展示缓存大小、判断是否为空,size() 够用;如果要用它做精确限流或对账,就不太靠谱。
5.4 computeIfAbsent 的双检锁思路
computeIfAbsent 在 JDK 8 里的实现很值得我们借鉴。它先走一次无锁 get,如果 key 已存在就直接返回;如果不存在,进入自旋,在锁内又检查一遍头节点:
- 如果桶还是空的,用 CAS 直接写入新节点。
- 如果桶非空,锁住头节点再走一遍链表或树查找。
这套逻辑和单例模式的双重检查锁非常像:先无锁快速路径,再加锁确认。好处是绝大多数 key 已经不存在的场景都不会真正加锁。
但用 computeIfAbsent 时要特别注意:传给它的 mappingFunction 必须是无副作用、幂等的操作。因为并发下多个线程可能同时执行这个函数,最终只有一个结果被采纳。如果函数里做数据库查询、发送消息这类重量级操作,会被重复执行,而且执行结果可能还没被采用。
6. 源码学习的实践心得:从原理到编码的落地点
读源码不只是为了面试,最终还是要落到实际编码里。这一部分是我自己使用和踩坑后的总结。
6.1 key 和 value 为 null 的限制原因
ConcurrentHashMap 从一开始就不允许 null key 和 null value,这是和 HashMap 最大的一个区别。
如果像 HashMap 那样允许 null,get 返回 null 就有两种解释:key 不存在,或者 value 本身就是 null。单线程下你可以再用 containsKey 判断,但并发环境下,containsKey 和 get 之间其他线程可能插入数据,你无法确定到底属于哪种情况。另一个原因是,支持 null 就需要额外的状态标记,这会污染并发设计。
所以从 HashMap 迁移到 ConcurrentHashMap 时,第一件事是检查 key 和 value 是否可能为 null,否则线上会遇到奇怪的 NPE。
6.2 size() 不是精确值:哪些场景要小心
前面说过 size() 只是近似值。用同一个 ConcurrentHashMap 统计在线用户数、设备数、任务数的时候,多线程持续上下线,size() 返回的数字可能和你预期差几条。大多数看板类场景不在乎,但一旦涉及“这个数字达到阈值就触发下游动作”,就要非常谨慎。
如果业务确实需要精确并发计数,更合适的方案是用 LongAdder 或者 AtomicLong 单独维护一个计数器。ConcurrentHashMap 负责存对象,计数由专门组件负责,职责分离之后逻辑也清晰很多。
6.3 从读源码到设计并发代码的迁移
读完 ConcurrentHashMap,最大的收获不该只是记住几个常量,而是学会了一套并发设计套路:
- 能用无锁就用无锁。空桶插入用 CAS,避免不必要的锁开销。
- 必须加锁时,把锁粒度控制到最小。锁头节点而不是锁整个 Map。
- 让多个线程在复杂任务里协作。扩容不是单线程包办,而是多线程通过 CAS 分任务、互相帮忙。
- 用状态字段表达生命周期。sizeCtl 表达初始化、扩容、稳定期,ForwardingNode 标记桶已迁移。
这套思想可以迁移到很多代码设计里,比如实现一个并发缓存、任务队列,或者分段加载器。
6.4 JDK 9+ 版本的差异与阅读建议
JDK 9 之后,ConcurrentHashMap 内部从 Unsafe 逐步转向 VarHandle,比如 compareAndSet、getVolatile 这些底层方法都换成了 VarHandle 调用。语义没有变化,只是底层实现换了。如果在较新 JDK 上找源码,看到 VarHandle 字样不要慌,和 JDK 8 的 Unsafe 逻辑是一一对应的。
阅读顺序建议:先读 Node 和 sizeCtl,再读 putVal,再读 transfer,最后回头补 get 和 compute。不建议从头到尾逐行读,很容易迷失在 CounterCell 和 TreeBin 的细节里。我自己的做法是写一个简单多线程压测小工具,用 CyclicBarrier 让几十个线程同时往 Map 里塞数据,然后在 IDE 里给 transfer 和 putVal 打断点,观察 sizeCtl 和 transferIndex 的实时变化。纸上得来终觉浅,看到实际并发下的状态跳转,对源码的理解会立刻深一层。
最后说一点个人体会。在读完整套源码之后,我写并发代码时多了一个习惯:先问自己能不能用 CAS 避免锁,再问如果必须加锁,能不能只锁最小范围。ConcurrentHashMap 的价值不只是提供了一个好用的并发 Map,它本身就是一个可参考的并发设计范本。希望这篇源码学习笔记能让你在看懂每个方法的同时,也把这种设计思路带走。