【多线程篇22】:ConcurrentHashMap的并发安全原理剖析
文章目录
- 一、HashMap 的“不安全”:问题的根源
- 1. 数据结构回顾 (JDK 1.8)
- 2. 并发下的致命缺陷:`put` 操作
- 二、ConcurrentHashMap 的安全之道 (JDK 1.8+)
- 1. 核心数据结构
- 2. 安全的 `put` 操作:分场景精细化加锁
- 3. 安全的 `size()` 计算:并发计数
- 三、表格总结
在 Java 面试和日常开发中,
HashMap
和
ConcurrentHashMap
(以下简称 CHM) 是我们绕不开的两个核心类。我们都知道它们的关键区别是“线程安全”:
HashMap
是非线程安全的,而 CHM 是线程安全的。
那么,这种“安全”是如何实现的?CHM 到底比 HashMap
高明在哪里?为什么 JDK 1.8 的 CHM 性能远超早期版本?
本文将带你深入 JDK 1.8+ 的源码,彻底搞懂 CHM 并发安全的底层奥秘。
一、HashMap 的“不安全”:问题的根源
在分析 CHM 的精妙设计之前,我们必须先理解 HashMap
在并发环境下为什么会“翻车”。
1. 数据结构回顾 (JDK 1.8)
HashMap
的底层是 “数组 + 链表 / 红黑树”。
// HashMap.java
transient Node<K,V>[] table;
table
是一个 Node
数组。当多个元素的 hash
值冲突时,它们会以链表的形式存放在同一个数组索引(桶)下;当链表长度超过 TREEIFY_THRESHOLD
(默认为8) 且数组长度大于 64 时,链表会转化为红黑树以提高查询效率。
2. 并发下的致命缺陷:put
操作
HashMap
的所有操作都没有任何同步措施。我们以 putVal
方法为例,看看在并发下会发生什么。
// HashMap.java -> putVal() 核心逻辑简化
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;// 1. 检查 table 是否为空,如果为空则初始化 (resize)if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;// 2. 计算索引 i,检查该位置是否为空if ((p = tab[i = (n - 1) & hash]) == null)// 3. 如果为空,直接创建一个新节点放在这里tab[i] = newNode(hash, key, value, null);else {// ... 省略桶内已有元素时的处理逻辑}// ...return null;
}
经典的“数据覆盖”竞争条件 (Race Condition):
假设两个线程 T1 和 T2 同时向一个空桶 tab[i]
中 put
数据。
- T1 执行到第 2 步,
p = tab[i]
,发现p
是null
。 - 此时发生线程切换,T2 开始执行。
- T2 也执行到第 2 步,
p = tab[i]
,发现p
同样是null
。 - T2 继续执行第 3 步,成功将自己的新节点放入
tab[i]
。 - 线程切换回 T1。由于 T1 之前已经判断过
p
是null
,它不会再检查一次,而是直接执行第 3 步,将自己的新节点放入tab[i]
。 - 结果:T1 的数据覆盖了 T2 的数据,导致 T2 的写入操作丢失。
除了数据覆盖,并发下的 resize()
操作在 JDK 1.7 中甚至会因头插法导致链表形成环形结构,造成 get
操作时死循环。虽然 JDK 1.8 改为尾插法修复了此问题,但数据丢失等并发问题依然存在。
结论:HashMap
的不安全,源于其所有操作都是“非原子性”的,且缺乏内存可见性保障。
二、ConcurrentHashMap 的安全之道 (JDK 1.8+)
JDK 1.8 对 CHM 进行了革命性的重构,摒弃了 JDK 1.7 的分段锁(Segment
)机制,采用了更为精细的 CAS
+ synchronized
+ volatile
方案,实现了更高的并发性能。
1. 核心数据结构
// ConcurrentHashMap.java
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; // value 是 volatile 的volatile Node<K,V> next; // next 指针是 volatile 的
}
关键点:
table
数组被volatile
修饰:保证其可见性,当一个线程对table
进行扩容或修改后,其他线程能立刻看到。Node
的val
和next
指针被volatile
修饰:保证了节点值或链表结构的修改对其他线程的可见性。这是无锁读(get
操作)的关键基础。
2. 安全的 put
操作:分场景精细化加锁
CHM 的 put
方法是其并发设计的精髓所在。它通过 CAS
和 synchronized
的配合,将锁的粒度降到了最低。
我们来分析其核心方法 putVal
的源码:
// ConcurrentHashMap.java -> putVal() 核心逻辑简化
final V putVal(K key, V value, boolean onlyIfAbsent) {// ... key/value 不允许为 nullint hash = spread(key.hashCode());int binCount = 0;// (1) 无限循环,直到成功插入或找到已有keyfor (Node<K,V>[] tab = table;;) {Node<K,V> f; int n, i, fh;// (2) 场景一:table 未初始化,CAS 初始化if (tab == null || (n = tab.length) == 0)tab = initTable(); // CAS操作,只有一个线程能成功// (3) 场景二:目标桶为空,CAS 插入节点 (无锁)else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))break; // CAS 成功,直接跳出循环}// (4) 场景三:检测到正在扩容else if ((fh = f.hash) == MOVED)tab = helpTransfer(tab, f); // 帮助其他线程一起扩容// (5) 场景四:目标桶有值,锁住头节点else {V oldVal = null;synchronized (f) { // f 是桶的头节点if (tabAt(tab, i) == f) { // 再次确认头节点没变// 在同步块内,遍历链表或红黑树if (fh >= 0) {// ... 链表遍历逻辑 ...} else if (f instanceof TreeBin) {// ... 红黑树处理逻辑 ...}}}// ...break;}}addCount(1L, binCount); // 更新 sizereturn null;
}
剖析其安全机制:
-
无锁读(
get
操作):得益于table
、Node.val
、Node.next
都是volatile
的,get
操作不需要加任何锁。volatile
保证了读线程总能看到其他写线程对数据的最新修改,从而实现了高效的无锁读取。 -
put
操作的安全性分析:- 初始化安全 (场景二):
initTable()
方法内部使用CAS
操作来设置sizeCtl
状态位,保证了在多线程同时初始化时,只有一个线程能成功执行,其他线程则会yield
等待。 - 空桶插入安全 (场景三):当目标桶为空时,CHM 并未直接加锁,而是乐观地尝试使用
CAS
(casTabAt
) 操作将新节点放入。casTabAt
是一个原子操作,它会比较tab[i]
的内存值是否为null
,如果是,就将其更新为新节点。- 如果
CAS
成功,说明没有竞争,操作完成。 - 如果失败,说明在它操作的瞬间,有另一个线程捷足先登了。此时
for
循环会继续,重新读取tab[i]
的新值,进入下一个场景(通常是场景五)。
- 非空桶插入安全 (场景五):这是最关键的部分。当桶中已有节点时,不能再用
CAS
了(因为需要遍历链表)。此时,CHM 采用synchronized
关键字,但它锁住的不是整个table
,而是这个桶的头节点f
。- 锁粒度极小:只锁住当前要操作的桶,其他桶的操作完全不受影响,并发度极高。
- 为什么不锁
String
或Integer
:synchronized
锁的是对象。如果锁key
,当key
是常量池中的字符串时,可能会锁住一个全局共享的对象,造成意想不到的死锁。锁住桶的头节点Node
对象是最安全和高效的选择。
- 扩容安全 (场景四):当一个线程在
put
时发现桶的头节点hash
值为MOVED
,表示table
正在扩容。此时它不会等待,而是调用helpTransfer
加入到扩容大军中,帮助移动数据,充分利用了 CPU 资源,实现并发扩容。
- 初始化安全 (场景二):
3. 安全的 size()
计算:并发计数
HashMap
的 size
只是一个简单的成员变量 int size
。多线程下 ++size
是非原子操作,会导致计数不准。
ConcurrentHashMap
的 size()
实现非常巧妙,它避免了全局锁,以分散计数的方式实现。
baseCount
:一个基础计数值,在没有并发竞争时,优先通过CAS
更新这个值。CounterCell[]
:一个计数器单元数组。当CAS
更新baseCount
失败(说明存在竞争)时,线程会找到CounterCell
数组中的一个槽位,对这个槽位里的计数值进行更新。size()
计算:最终的大小是baseCount
加上所有CounterCell
数组中计数的总和。
这种条带化计数(Striped Counter) 的思想,将对单一 size
变量的竞争压力分散到多个 CounterCell
上,极大地提高了高并发下的计数性能。
三、表格总结
特性 | HashMap (JDK 1.8+) | ConcurrentHashMap (JDK 1.8+) |
---|---|---|
线程安全 | 否 | 是 |
核心数据结构 | Node[] table | volatile Node<K,V>[] table |
加锁机制 | 无锁 | CAS + synchronized (锁桶头节点) |
put 操作 | 非原子性,并发下有数据覆盖风险 | 1. 空桶:CAS 无锁插入 2. 非空桶: synchronized 锁住桶头节点 |
get 操作 | 非线程安全 | 无锁,通过 volatile 保证可见性 |
size() 计算 | int size 成员变量,非线程安全 | baseCount + CounterCell[] ,并发安全高效 |
扩容机制 | 单线程扩容,并发下危险 | 并发扩容,多线程可协同完成 |
Key/Value | 可为 null | 均不可为 null (避免二义性) |
性能 | 单线程下极高 | 高并发下性能优异,单线程下因 volatile 和 CAS 略逊于HashMap |
结论:
- 用
volatile
作为基石,保证了多线程间的内存可见性,是无锁读和后续操作的基础。 - 用
CAS
作为先锋,处理无竞争或低竞争场景(如初始化、空桶插入),避免了不必要的加锁开销。 - 用
synchronized
锁住桶头节点作为主力,在必须同步的场景下,将锁的粒度降到最低,实现了极高的并发度。 - 用并发扩容和并发计数等辅助策略,将并发思想贯彻到每一个细节,榨干硬件性能。