ConcurrentHashMap
Java 集合,HashMap、Hashtable、ConcurrentHashMap 这几个类很容易被混在一起。
表面上看,它们都像是“键值对容器”,但如果往并发场景里一放,差别就会立刻出来:
HashMap线程不安全Hashtable是线程安全的,但锁太粗ConcurrentHashMap既要保证线程安全,又希望尽量把并发性能保住
所以我后来觉得,理解 ConcurrentHashMap 最关键的不是死记源码细节,而是先把它到底在解决什么问题想清楚:
它想做的事情,是在并发读写 Map 时,尽量避免“整张表一把大锁”,而是把简单路径做成无锁或少锁,把真正复杂的冲突路径再加锁处理。
如果先抓住这条主线,后面再去看 CAS、synchronized、volatile、扩容迁移、ForwardingNode,会顺很多。
一、先看 ConcurrentHashMap 到底想解决什么问题
如果只看最粗的一层,可以把三者先放在一起对比:
HashMap:单线程下用起来简单,性能也高,但并发场景不安全Hashtable:通过大量同步方法保证线程安全,但基本可以理解成“整表锁得很粗”ConcurrentHashMap:希望在保证线程安全的前提下,把锁的影响尽量缩小
所以 ConcurrentHashMap 的核心设计思想可以先记成一句话:
能用 CAS 解决的地方先用 CAS,必须修改复杂桶结构时再加锁,读操作尽量走无锁路径。
这句话其实已经把它最核心的取舍讲清楚了。
它并不是“完全无锁”,也不是像 Hashtable 那样“整表一把锁”,而是一种更细粒度的并发设计。
二、JDK 1.8 里的整体结构是什么样的
JDK 1.8 中,ConcurrentHashMap 的主体结构和 HashMap 1.8 已经比较接近了,底层核心还是桶数组:
Node<K,V>[] table每个桶位上可能出现几种情况:
- 空桶
- 链表
- 红黑树
- 扩容中的特殊节点
ForwardingNode
如果只看这张图,可以粗略记成:
table
├─ bucket 0 -> null
├─ bucket 1 -> Node -> Node -> Node
├─ bucket 2 -> TreeBin
└─ bucket 3 -> ForwardingNode而线程安全主要依赖三种机制一起完成:
CAS:处理简单原子更新,例如空桶插入、初始化竞争synchronized:处理链表 / 红黑树这种复杂结构修改volatile:保证可见性,让读线程看到最新结构和最新值
所以它的并发模型,不是“只有锁”,也不是“只有 CAS”,而是:
- 简单路径优先无锁
- 冲突路径局部加锁
- 读路径尽量无锁
三、JDK 1.7 和 JDK 1.8 的区别到底在哪
这个点几乎是 ConcurrentHashMap 的入门高频问题。
1. JDK 1.7:分段锁
JDK 1.7 的核心思路是分段锁,也就是:
Segment[] + HashEntry[] + 链表每个 Segment 都可以看成一个“小 HashMap”,并且每个 Segment 自带一把锁。多个线程如果操作的是不同 Segment,就可以并发执行。
所以 JDK 1.7 版本的关键词通常是:
SegmentReentrantLock- 分段锁
2. JDK 1.8:桶级别并发控制
JDK 1.8 取消了 Segment,改成了更接近 HashMap 1.8 的结构:
Node[] + 链表 + 红黑树线程安全不再依赖“段锁”,而是改成:
- 空桶插入优先使用
CAS - 桶非空时,对当前桶头加
synchronized - 读操作依赖
volatile尽量无锁 - 扩容时允许多个线程协助迁移
所以两者最核心的区别,可以直接总结成:
- JDK 1.7:锁的是段
- JDK 1.8:锁的是桶
再往前走一步理解,其实 JDK 1.8 的目标就是:
把分层结构简化掉,让数据结构和并发控制更统一,同时把锁粒度继续往下压。
四、为什么说 ConcurrentHashMap 不是“完全无锁”
很多人第一次看 ConcurrentHashMap,容易把它简单记成“无锁 Map”。这其实不准确。
更准确的理解应该是:
ConcurrentHashMap只是尽量让读路径和简单写路径不加锁,但只要涉及链表、红黑树、迁移中的复杂结构修改,它仍然会加锁或者进入协作逻辑。
也就是说,它的重点从来不是“彻底不用锁”,而是:
- 不该加锁的地方尽量不加
- 必须加锁的地方只锁必要范围
这一点后面在 get() 和 put() 两条路径里会特别明显。
五、get() 为什么通常可以不加锁
get() 是理解 ConcurrentHashMap 的第一条关键路径。
大致流程可以先记成:
- 计算 key 的 hash
- 定位到对应桶
- 先看桶头是不是目标节点
- 如果不是,再继续查链表、红黑树或特殊节点
简化后的伪代码大致是这样:
V get(Object key) {
// 先对 key 做扰动,减少低位冲突
int h = spread(key.hashCode());
// 拿到当前 table 引用
Node<K,V>[] tab = table;
// 通过 (n - 1) & hash 定位桶位
Node<K,V> f = tabAt(tab, (tab.length - 1) & h);
// 桶为空,说明 key 不存在
if (f == null) return null;
// 先快速判断桶头是不是目标节点,命中则直接返回
if (f.hash == h && (f.key == key || key.equals(f.key))) {
return f.val;
}
// hash < 0 通常说明这里不是普通链表头,可能是 TreeBin 或 ForwardingNode
if (f.hash < 0) {
// 交给特殊节点自己的查找逻辑处理
Node<K,V> e = f.find(h, key);
return e == null ? null : e.val;
}
// 否则按普通链表继续向后遍历
for (Node<K,V> e = f.next; e != null; e = e.next) {
if (e.hash == h && (e.key == key || key.equals(e.key))) {
return e.val;
}
}
// 整个桶都没找到,返回 null
return null;
}1. 为什么它通常不加锁
核心原因有三点:
get()本身是读操作,不直接修改结构- 关键字段依赖
volatile保证可见性 - 桶头读取、节点值读取、链表遍历这些路径都尽量按可见性安全来设计
所以大多数情况下,读线程不需要像 Hashtable 那样先抢整张表的锁。
2. “通常不加锁”比“绝对无锁”更准确
这里最好不要把话说得太满。
更准确的说法应该是:
ConcurrentHashMap的get()走的是尽量无锁的读取路径,但它依赖的是底层可见性保证和并发结构设计,并不是说内部完全没有同步语义。
这也是为什么很多资料会说它“读操作基本无锁”或者“通常无锁”,而不是简单写成“绝对无锁”。
3. 如果只记一句话
可以把 get() 记成:
get()的关键不是“不加锁”这四个字,而是“读路径不修改结构,所以可以尽量依赖 volatile 和已有结构安全地读取”。
六、put() 为什么会是 CAS + synchronized 的组合
put() 才是真正把 ConcurrentHashMap 设计思想暴露得最清楚的地方。
大致主线可以先记成:
- 先算 hash
- table 没初始化就先初始化
- 空桶直接 CAS 插入
- 桶非空就锁桶头处理冲突
- 如果遇到正在扩容的桶,就帮助扩容
- 插入后更新计数,并判断是否需要扩容
简化后的伪代码大致如下:
V put(K key, V value) {
// 先对 key 做扰动,得到更均匀的 hash
int h = spread(key.hashCode());
// 自旋重试:CAS 失败、初始化完成、扩容协助后都可能重新来一轮
for (;;) {
// 拿到当前 table 引用
Node<K,V>[] tab = table;
// table 还没初始化时,先尝试初始化
if (tab == null || tab.length == 0) {
tab = initTable();
continue;
}
// 计算当前 key 应该落到哪个桶
int i = (tab.length - 1) & h;
Node<K,V> f = tabAt(tab, i);
// 桶为空:这是最简单的情况,直接 CAS 把新节点塞进去
if (f == null) {
if (casTabAt(tab, i, null, new Node(h, key, value))) {
// 插入成功后更新计数
addCount(1L);
return null;
}
// CAS 失败说明有别的线程抢先插入了,重试即可
continue;
}
// 遇到 MOVED,说明这个桶正在扩容迁移,当前线程去帮忙迁移
if (f.hash == MOVED) {
tab = helpTransfer(tab, f);
continue;
}
V oldVal = null;
// 桶非空且不是迁移状态:进入桶内临界区,处理链表/红黑树冲突
synchronized (f) {
// 再次确认桶头没被别的线程替换,避免锁住的是旧节点
if (tabAt(tab, i) == f) {
if (f.hash >= 0) {
// 普通链表:查重、覆盖旧值,或尾插新节点
} else {
// 红黑树:按树结构做查找、插入或覆盖
}
}
}
// oldVal != null 说明是覆盖已有 key,返回旧值
if (oldVal != null) {
return oldVal;
}
// 走到这里说明插入了新节点,需要更新元素个数
addCount(1L);
return null;
}
}1. 为什么空桶可以直接用 CAS
因为空桶插入本质上只是一次非常简单的原子更新:
把数组某个槽位从
null改成一个新节点。
这类单点更新,非常适合用 CAS 来做。成功就说明插入完成,失败说明有其他线程抢先了,重新再试就可以。
2. 为什么桶非空时不能只靠 CAS
一旦桶非空,问题就不再是“往一个数组格子里塞一个节点”这么简单了,而是要处理:
- 链表查重
- 相同 key 覆盖旧值
- 尾插新节点
- 红黑树插入或调整
这类操作涉及多个节点关系,不是单次 CAS 能安全解决的。
所以这时就必须退回到另一种策略:
对当前桶加锁,在局部临界区里完成复杂结构修改。
这也是为什么 ConcurrentHashMap 会表现成一种很典型的组合式设计:
- 简单修改用 CAS
- 复杂修改用锁
3. 为什么锁的是桶头,而不是整张表
因为它的目标从来不是“让所有写都串行”,而是:
只把真正冲突的那个桶保护起来,让其他桶上的并发操作尽量不受影响。
这也是 JDK 1.8 之后它相比老式粗粒度同步更有性能优势的根源之一。
七、size 统计为什么不能简单用一个 size++
这个点也很容易在面试或源码阅读里被问到。
如果是单线程容器,直接:
size++看起来没什么问题。
但放到高并发场景里,会立刻遇到两个问题:
size++不是原子操作,会丢失更新- 就算换成单个原子计数器,在高并发下也会变成热点
所以 ConcurrentHashMap 不能简单依赖一个 volatile int size 去统计元素个数。
它更接近 LongAdder 的思路:
- 先尝试更新
baseCount - 如果竞争变激烈,再把压力分散到
CounterCell[]
这样做的核心目的就是:
不把所有线程都压到同一个计数点上,而是把统计冲突分散掉。
所以如果只记一句话,可以记成:
ConcurrentHashMap的 size 统计之所以复杂,不是因为作者想写复杂,而是因为“高并发下准确计数”本来就是个有竞争成本的问题。
八、扩容时到底发生了什么
扩容是 ConcurrentHashMap 里另一个非常关键的部分。
1. 扩容后元素位置怎么变化
和 HashMap 1.8 一样,ConcurrentHashMap 扩容后,节点的新位置通常只可能是两种:
- 原索引
- 原索引 +
oldCap
判断依据是:
(e.hash & oldCap) == 0- 为
0:留在原位置 - 不为
0:移动到原位置 + oldCap
这背后的本质原因是:
扩容是 2 倍扩容,旧容量
oldCap对应的那一位是否为 1,就决定了元素是否需要挪到“原位置 + oldCap”。
2. ConcurrentHashMap 和 HashMap 扩容最大的不同
最大的不同不是“也会扩容”,而是:
ConcurrentHashMap支持多个线程一起协助迁移。
也就是说,扩容时不一定只有一个线程傻等着搬完整张表;其他线程如果碰到扩容中的桶,也可以参与迁移工作。
这就是它所谓的 help transfer 机制。
九、ForwardingNode 到底是什么
扩容里最关键的特殊节点就是 ForwardingNode。
可以先把它理解成一个“迁移完成标记 + 路标节点”。
它的作用主要有两个:
- 告诉其他线程:这个旧桶已经迁移过了
- 引导其他线程去新表继续查找,或者参与扩容
所以当线程在 get() 或 put() 过程中遇到 ForwardingNode 时,含义不是“这里有普通数据节点”,而是:
这个桶在旧表里的工作已经结束了,你不能再按普通桶逻辑处理,而应该转去新表或迁移逻辑。
如果只让我用一句话总结它:
ForwardingNode是并发扩容时用来协调“旧桶已经迁走”这一事实的特殊标记节点。
十、几个特别容易混的高频点
1. 为什么不能存 null key 和 null value
这是 ConcurrentHashMap 最经典的问题之一。
如果允许这样做:
map.put("a", null);那么再执行:
map.get("a") == null和:
map.get("b") == null都会得到 null。
在单线程里,你还可以再补一个 containsKey() 去区分:
- key 不存在
- key 存在,但 value 就是
null
但到了并发场景里,这两个动作之间,Map 可能已经被别的线程改掉了,这个判断就不再可靠。
所以 ConcurrentHashMap 干脆直接规定:
- 不允许
null key - 不允许
null value
这本质上是在避免并发语义上的歧义。
2. 迭代器是 fail-fast 吗
不是。
ConcurrentHashMap 的迭代器属于:
弱一致性(weakly consistent)
这意味着:
- 遍历时不会像很多普通集合那样轻易抛
ConcurrentModificationException - 可以看到遍历期间的一部分修改,也可能看不到全部
- 但不会因为并发修改而把结构读崩
所以它既不是 fail-fast,也不是绝对静态快照,而是一种更适合并发容器的折中视图。
3. 树化机制和 HashMap 一样吗
大体思路是接近的:
- 链表长度达到阈值
8 - 数组长度至少达到
64 - 才考虑树化
如果数组还太小,通常优先扩容,而不是急着把桶转成红黑树。
十一、概括 ConcurrentHashMap
ConcurrentHashMap不是完全无锁,而是尽量把锁缩小到必要范围- JDK 1.7 主要是分段锁,JDK 1.8 改成了桶级别并发控制
get()主要依赖可见性和结构设计,通常走无锁读取路径put()在空桶时优先 CAS,发生桶内冲突时再对当前桶加锁- 扩容时支持多线程协助迁移,
ForwardingNode用来标记旧桶已迁走 - size 统计不能简单做成单点自增,而是要分散并发计数压力
- 不允许
null key和null value,本质上是为了避免并发语义歧义
如果再压缩成一句话,就是:
ConcurrentHashMap的核心价值,不是“完全不加锁”,而是“在并发安全前提下,把锁用在真正需要的地方”。
十二、小结
把这条线整理完之后,我觉得 ConcurrentHashMap 最重要的理解方式其实不是背很多零散结论,而是先抓住下面这条主线:
- 它要解决的是并发 Map 的线程安全问题
- 但又不想退回到
Hashtable那种整表粗锁 - 所以它才会组合使用
CAS、synchronized、volatile - 让读路径尽量轻,写路径按桶控制,扩容路径支持协作迁移
这样再回头去看源码里的 tabAt、casTabAt、helpTransfer、CounterCell、ForwardingNode,就不会只觉得它们是一堆零散技巧,而会知道它们其实都服务于同一个目标:
在高并发下,尽量用更小的同步成本,换到足够安全、足够稳定的 Map 访问语义。
