# ConcurrentHashMap 深度分析
作者:Ethan.Yang
博客:https://blog.ethanyang.cn (opens new window)
基于JDK11进行分析, 大部分公司已经开始使用JDK11
ConcurrentHashMap 是 Java 并发包中核心的线程安全哈希表实现,设计用于高并发环境下的读写访问,避免全表锁的性能瓶颈。
它采用了分段锁思想的演进版本,底层结构和实现细节较 HashMap 有较大区别。
# 一、继承体系
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable
2
| 类/接口 | 作用 | 说明 |
|---|---|---|
AbstractMap | 抽象骨架 | 提供基本的 Map 实现骨架 |
ConcurrentMap | 并发行为规范 | 定义线程安全的 Map 额外方法 |
Serializable | 接口能力 | 支持序列化 |
# 二、底层数据结构解析
ConcurrentHashMap采用了 数组 + 链表 + 红黑树 结构(JDK8 及以后版本)- 核心是对数组中每个桶节点进行无锁读取和分段锁写入的设计,提升并发性能
- JDK11 已经抛弃了 JDK7 的分段锁(Segment)设计,改用 CAS + synchronized 结合 Node链表/树 的方式实现细粒度同步
# 核心属性
transient volatile Node<K,V>[] table; // 主数组,volatile 保证可见性
transient volatile Node<K,V>[] nextTable; // 扩容时新数组引用
transient volatile long baseCount; // 元素计数,部分线程竞争时辅助计数
transient volatile int sizeCtl; // 控制扩容的阈值或初始化标志
transient volatile int transferIndex; // 扩容时分配给线程的迁移桶索引
2
3
4
5
# Node 节点定义
static class Node<K,V> implements Map.Entry<K,V> {
final int hash; // key 的哈希值
final K key;
volatile V val; // value 使用 volatile 保证可见性
volatile Node<K,V> next; // 下一个节点
}
2
3
4
5
6
和
HashMap的Node类似,但val和next都是volatile,保证读写线程安全Node本身不含锁,采用 CAS 操作实现无锁插入和链表修改
红黑树相关知识在HashMap章节已经充分介绍, 此处不再介绍
# 三、主要设计亮点
# CAS + synchronized 混合锁策略
- 无锁读(直接读取 volatile 变量)
- 写操作中,先通过 CAS 尝试插入(无锁快路径),失败时才用
synchronized加锁对应链表头节点或树节点,避免全表锁 - 扩容时,多个线程协同迁移桶,通过
transferIndex变量分配任务,支持多线程并发扩容
# 延迟初始化
- 数组延迟初始化,直到第一次插入才创建底层数组,减少资源浪费
# 树化与链表策略
- 同
HashMap,当链表长度超过阈值时(默认 8),转成红黑树 - 树化条件更复杂,保证并发安全的同时提升查询效率
# 扩容机制
- 扩容为原容量的两倍
- 使用多线程协作迁移元素(与
HashMap仅单线程扩容不同) - 迁移过程中,使用特殊节点
ForwardingNode作为标记,指示该桶已迁移,帮助新线程正确访问新表
# 四、核心方法解析(JDK11)
# 增
put(K key, V value)
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
// 扰动函数
int hash = spread(key.hashCode());
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh; K fk; V fv;
if (tab == null || (n = tab.length) == 0)
// 初始化数组 延迟加载
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// // 桶位为空,尝试CAS直接插入
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value)))
break;
}
// 扩容检测与协助
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else if (onlyIfAbsent // check first node without acquiring lock
&& fh == hash
&& ((fk = f.key) == key || (fk != null && key.equals(fk)))
&& (fv = f.val) != null)
return fv;
// 加锁处理链表或树结构
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,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;
}
Node<K,V> pred = e;
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value);
break;
}
}
}
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
扰动计算(spread)
首先,通过 spread(key.hashCode()) 计算 key 的哈希值,这个方法会对原始 hash 码做一次扰动处理(异或高16位),使得哈希分布更加均匀,减少哈希冲突。
初始化数组
接下来进入循环中,尝试获取当前的哈希表数组 table。如果 table 为空或长度为 0,则调用 initTable() 来初始化容量(默认16),保证数组存在,准备存放节点。
定位桶槽
使用 (n - 1) & hash 快速定位 key 应该存放的桶槽下标 i,利用数组长度减一与哈希值做位与运算,快速定位槽位。
空桶CAS插入
判断 tab[i] 位置是否为空。如果为空,利用 CAS(Compare And Swap)原子操作尝试将新节点直接插入桶位。这个步骤是关键优化,避免无谓的锁操作,提高并发性能。
扩容检测与协助
如果发现当前桶的头节点 f 的 hash 值等于 MOVED(特殊标志,代表正在扩容),说明哈希表正在进行扩容迁移。此时不会插入,而是调用 helpTransfer 方法协助扩容完成,避免阻塞写操作。
加锁处理链表或树结构 若桶槽非空且非扩容中,进入同步块,锁住桶头节点,避免并发修改。
- 链表情况:遍历链表,查找是否已有相同 key 的节点。如果找到了,且不是只在不存在时插入(
onlyIfAbsent == false),则更新该节点的 value 并返回旧值。 - 如果链表中没有相同 key 节点,则在链表尾部插入新节点。
- 同时计数桶中节点数,用于后续判断是否转换为红黑树。
红黑树插入
如果桶头是 TreeBin(树形结构)节点,则调用红黑树的 putTreeVal 方法插入新节点或更新已有节点,保证插入后树结构的平衡和有序。
更新计数器与扩容检查
插入或更新节点后,调用 addCount(1, binCount) 方法,原子地增加元素计数器,同时根据链表或树节点数判断是否需要触发扩容操作(默认链表超过8个节点会转树,大小超过阈值会扩容)。
返回结果 如果是更新已有 key 的值,返回旧值;如果是新插入,则返回 null。
# 删
public V remove(Object key) {
return replaceNode(key, null, null);
}
final V replaceNode(Object key, V value, Object cv) {
// 扰动函数
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
// 若 tab 未初始化或该桶为空,直接 break 返回 null(没有要删除的元素)。
if (tab == null || (n = tab.length) == 0 ||
(f = tabAt(tab, i = (n - 1) & hash)) == null)
break;
// 如果桶头 f.hash == MOVED,说明该桶正在被迁移(扩容过程中),
//本线程会调用 helpTransfer 协助迁移,然后重新取 tab 再试。这样能避免阻塞并尽快完成迁移。
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
boolean validated = false;
// 桶头 f 为锁, 加上同步块
synchronized (f) {
if (tabAt(tab, i) == f) {
// 处理链表
if (fh >= 0) {
validated = true;
for (Node<K,V> e = f, pred = null;;) {
K ek;
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
V ev = e.val;
if (cv == null || cv == ev ||
(ev != null && cv.equals(ev))) {
oldVal = ev;
if (value != null)
e.val = value;
else if (pred != null)
pred.next = e.next;
else
setTabAt(tab, i, e.next);
}
break;
}
pred = e;
if ((e = e.next) == null)
break;
}
}
// 处理树
else if (f instanceof TreeBin) {
validated = true;
TreeBin<K,V> t = (TreeBin<K,V>)f;
TreeNode<K,V> r, p;
if ((r = t.root) != null &&
(p = r.findTreeNode(hash, key, null)) != null) {
V pv = p.val;
if (cv == null || cv == pv ||
(pv != null && cv.equals(pv))) {
oldVal = pv;
if (value != null)
p.val = value;
else if (t.removeTreeNode(p))
setTabAt(tab, i, untreeify(t.first));
}
}
}
else if (f instanceof ReservationNode)
throw new IllegalStateException("Recursive update");
}
}
if (validated) {
// 找到了目标并已删除/替换
if (oldVal != null) {
if (value == null)
addCount(-1L, -1);
return oldVal;
}
// 若没有找到(oldVal == null),break 出循环并最终返回 null(表示未命中)。
break;
}
}
}
return null;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
无锁快速路径:
tabAt、CAS 在空桶直接插入/替换时使用(这是 put 的优化;remove 则在空桶直接返回)。桶粒度锁:对链表/树的多步修改使用
synchronized(f),锁粒度很小(单个桶),降低竞争范围。二次校验:加锁后用
if (tabAt(tab, i) == f)校验,避免在加锁前后桶头被其他线程替换导致错误(防 ABA)。MOVED 与 helpTransfer:遇到扩容迁移,当前线程会协助
helpTransfer,保证迁移并发安全与进度。setTabAt / volatile 保证可见性:对桶头的写入通过
setTabAt(volatile 写)确保其他线程能立即看到结果;链表内部的next指针通常通过锁操作保证可见性。返回语义:若找到并删除/替换,返回找到的旧值;否则返回
null。删除成功会减少计数。
# 查
public V get(Object key) {
Node<K,V>[] tab; Node<K,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;
}
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
- 没啥好说的, 比较简单, 但是需要注意在并发场景下查询可能同一个key先后查询的值不一样, 因为变更是加锁操作不可见, 这是正常的现象, 本身
ConcurrentHashMap是为了保证高并发+线程安全, 没有保证强一致性, 如果要保证强一致性会导致性能下降。
# 再分配
final Node<K,V>[] helpTransfer(Node<K,V>[] tab, Node<K,V> f) {
Node<K,V>[] nextTab; int sc;
if (tab != null && (f instanceof ForwardingNode) &&
(nextTab = ((ForwardingNode<K,V>)f).nextTable) != null) {
int rs = resizeStamp(tab.length);
while (nextTab == nextTable && table == tab &&
(sc = sizeCtl) < 0) {
if ((sc >>> RESIZE_STAMP_SHIFT) != rs || sc == rs + 1 ||
sc == rs + MAX_RESIZERS || transferIndex <= 0)
break;
if (U.compareAndSetInt(this, SIZECTL, sc, sc + 1)) {
transfer(tab, nextTab);
break;
}
}
return nextTab;
}
return table;
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
再分配代码也比较简单, 但是触发时机和HashMap不同
// HashMap
put(...) {
// 插入节点
if (++size > threshold) {
resize(); // 一次性搬迁整个表
}
}
// ConcurrentHashMap
putVal(...) {
if (table == null) initTable();
if (tab[i] is ForwardingNode) {
helpTransfer(tab, f); // 看到正在迁移,来帮忙
}
// 插入(空位用 CAS,非空用 synchronized(f))
if (needExpand) { // 线程A触发扩容
// 将 sizeCtl 设为负,建立 nextTable 并开始 transfer()
// 但 transfer() 会划分区间,其他线程也能来帮助
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
HashMap
putVal/put内有if (++size > threshold) resize();,resize()会创建新数组并对所有旧节点做重哈希搬迁(一次性完成)。
ConcurrentHashMap
putVal里有if (tab == null) initTable()、并且当遇到f.hash == MOVED会helpTransfer(tab, f)。helpTransfer()用sizeCtl、resizeStamp、transferIndex、ForwardingNode.nextTable等字段协调多线程搬迁(见helpTransfer实现)。transfer()本身包含对transferIndex的 CAS 划分任务、只搬一个区段的逻辑。
# 五、常见问题
# 为什么 put 方法中部分用 CAS,部分用 synchronized?
原因
- 空桶插入:如果对应桶位置是
null,则直接使用 CAS(compareAndSet) 将新节点放入,这样避免加锁,提高并发性能。 - 桶中已有节点:需要遍历链表或红黑树来插入新元素,此时必须保证遍历+修改是原子性的,所以对桶的头节点加
synchronized锁(锁粒度仅限于单个桶)。
优点
- CAS 无锁化空桶插入,减少锁竞争。
- 分段锁(桶锁)而非全表锁,提高并发度。
# 查询 (get) 为什么不加锁?
原因
ConcurrentHashMap内部节点(Node)的val和next都是volatile修饰,保证可见性。- 读操作只会读取节点的引用和
val,不会修改结构,因此不需要加锁。 - 如果有写线程在并发修改,读线程可能会读到旧值或新值,但不会读到中间状态(结构一致性由
volatile和写锁保证)。
影响
- 多线程并发读同一个 key,有可能 A 读到旧值,B 读到新值,这是允许的,因为它是弱一致性设计(以换取性能)。
- 对于需要强一致性的场景,需要额外的同步机制。
# 为什么在 put 方法中没看到明显的 resize 调用?
原因
putVal方法中确实会触发扩容,但不是像HashMap一样直接resize()一次性完成。- 当检测到需要扩容时(
binCount >= TREEIFY_THRESHOLD或size > sizeCtl):- 会初始化
nextTable。 - 把
sizeCtl置为负值,标记“正在扩容”。 - 把桶位置设置成
ForwardingNode(特殊节点),表示该桶已经迁移或正在迁移。 - 执行
transfer(),搬运部分桶的数据。
- 会初始化
# ConcurrentHashMap 的分布式扩容是怎么做的?
核心机制
- 多线程协作扩容:不是由一个线程搬完全部数据,而是多个线程分段搬迁。
- 关键字段:
transferIndex:全局递减计数器,标记剩余未搬迁的桶索引。sizeCtl:负值表示正在扩容,低 16 位是并发搬迁的线程数,高 16 位是resizeStamp(扩容唯一标识)。ForwardingNode:桶迁移占位符,表示数据已搬走或正在搬走。
协作过程(helpTransfer 方法)
- 线程 A 触发扩容 → 创建
nextTable→ 设置标志位。 - 线程 B 访问到
ForwardingNode,调用helpTransfer参与扩容。 - 每个线程通过 CAS 争夺一段区间(
transferIndex)并搬迁。 - 所有桶搬迁完成后,将
nextTable设为table并清理标志。
优点
- 搬迁工作分摊给多个线程,减少单线程阻塞时间。
- 在迁移期间,读写仍然可以进行(写会优先迁移目标桶的数据)。
# 为什么 ConcurrentHashMap 允许 Key/Value 为 null 的行为和 HashMap 不一样?
- Key 不允许为 null:
- 无法区分
map.get(key) == null是 key 不存在,还是 value 为 null。
- 无法区分
- Value 不允许为 null:
- 也是为了简化并发条件下的逻辑判断,避免
null引起的二义性。
- 也是为了简化并发条件下的逻辑判断,避免
# ConcurrentHashMap 和 HashMap 扩容的最大不同?
| 特性 | HashMap | ConcurrentHashMap |
|---|---|---|
| 扩容触发 | put 时判断 size > threshold | put 时判断 binCount 或 sizeCtl |
| 扩容方式 | 单线程一次性完成 | 多线程分段搬迁(渐进式) |
| 扩容期间读写 | 全表阻塞(如果外部加锁) | 允许读,写会协助迁移 |
| 性能 | 低并发场景简单高效 | 高并发场景扩容无长暂停 |
# 为什么扩容期间不会出现死循环或数据丢失?
- 使用
ForwardingNode占位,防止新旧表同时被访问时造成链表环或重复迁移。 - 每个桶只会被一个线程迁移一次,迁移完成后立即替换成
ForwardingNode。 volatile+ CAS 保证迁移的可见性和原子性。