一、前言
在上一篇文章中,我们了解到 JDK1.8 的 ConcurrentHashMap 摒弃了分段锁,转而采用CAS 无锁操作 + 桶级 synchronized 锁的组合方案实现并发安全。这两种机制并非独立工作,而是通过精准的分工与协同,在保证线程安全的同时,最大化提升并发性能。
本文将从 CAS 算法的基础原理入手,深度拆解 CAS 与 synchronized 在 ConcurrentHashMap 中的协同逻辑,同时对比其与传统 ReentrantLock 的性能差异,带你吃透高并发 Map 的锁机制精髓。
二、CAS 算法的基础原理
CAS(Compare-And-Swap,比较并交换)是实现乐观锁的核心算法,也是 ConcurrentHashMap 无锁操作的基础。它通过硬件级别的原子指令,保证多线程下的操作原子性,无需加锁即可实现安全的变量修改。
1. CAS 的核心三要素
CAS 操作依赖三个核心参数,通常表述为 CAS(V, A, B) :
-
V:要修改的内存地址(目标变量);
-
A:变量的预期旧值;
-
B:变量的新值。
2. CAS 的执行逻辑
-
线程读取内存地址 V 中的当前值,与预期旧值 A 进行比较;
-
若两者相等,说明无其他线程修改过该变量,将 V 的值更新为新值 B ,操作成功;
-
若两者不相等,说明变量已被其他线程修改,当前线程放弃更新(或自旋重试),操作失败。
整个过程由 CPU 的原子指令(如 cmpxchg )保证,不会被线程调度器中断,天然具备原子性。
3. CAS 的优势与缺陷
优势
-
**无锁开销:**无需加锁和释放锁,避免了线程阻塞与唤醒的性能损耗;
-
**高并发友好:**适用于冲突较少的场景,能最大化利用多核 CPU 的并行能力;
-
**原子性保障:**硬件级指令保证操作原子性,比手动加锁更可靠。
缺陷
-
**ABA 问题:**变量从 A 变为 B 再变回 A 时,CAS 会误判为未被修改;可通过版本号(如 AtomicStampedReference)解决;
-
**自旋消耗:**若高并发下 CAS 频繁失败,线程会不断自旋重试,导致 CPU 占用过高;
-
**仅支持单个变量:**CAS 只能保证单个变量操作的原子性,无法处理多个变量的组合操作。
三、CAS 在 ConcurrentHashMap 中的应用场景
在 JDK1.8 的 ConcurrentHashMap 中,CAS 主要用于无竞争场景下的原子操作,避免不必要的锁开销,核心应用在以下三个场景:
1. 空桶节点的原子插入
这是 CAS 最核心的应用场景。当线程向空桶插入节点时,通过 CAS 操作直接完成原子性写入,无需加锁。
核心源码(简化版)
java
// 获取桶数组中索引i的节点(volatile读,保证可见性)
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
// CAS替换桶数组中索引i的节点
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 省略哈希计算等逻辑
if ((tab = table) == null || (n = tab.length) == 0)
tab = initTable(); // 初始化数组(也用到CAS)
if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 桶为空,通过CAS插入新节点,无需加锁
if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
break; // CAS成功,直接退出
}
// 桶不为空,后续走synchronized加锁逻辑
}
执行逻辑
-
线程通过 tabAt 获取目标桶的节点,确认桶为空;
-
调用 casTabAt 尝试将新 Node 写入桶的内存地址,预期旧值为 null ,新值为待插入的 Node;
-
若 CAS 成功,说明无其他线程竞争,直接完成插入;若失败,说明已有线程抢先插入节点,当前线程进入 synchronized 加锁流程。
2. 哈希桶数组的初始化
ConcurrentHashMap 的数组初始化( initTable 方法)采用 CAS + 自旋的方式,保证多线程下仅能初始化一次,避免重复创建数组。
核心源码(简化版)
java
private final Node<K,V>[] initTable() {
Node<K,V>[] tab; int sc;
while ((tab = table) == null || tab.length == 0) {
// 若sizeCtl<0,说明其他线程正在初始化,当前线程让出CPU
if ((sc = sizeCtl) < 0)
Thread.yield();
// CAS将sizeCtl从默认值(0或初始容量)改为-1,标记为初始化中
else if (U.compareAndSwapInt(this, SIZECTL, sc, -1)) {
try {
// 再次校验,避免重复初始化
if ((tab = table) == null || tab.length == 0) {
int n = (sc > 0) ? sc : DEFAULT_CAPACITY;
@SuppressWarnings("unchecked")
Node<K,V>[] nt = (Node<K,V>[])new Node<?,?>[n];
table = tab = nt;
sc = n - (n >>> 2); // 计算扩容阈值(0.75*n)
}
} finally {
sizeCtl = sc; // 恢复sizeCtl为扩容阈值
}
break;
}
}
return tab;
}
执行逻辑
-
多线程同时进入 initTable 时,通过 CAS 竞争 sizeCtl 的修改权;
-
成功修改 sizeCtl 为 - 1 的线程,负责创建数组并初始化;
-
其他线程检测到 sizeCtl<0 时,会通过 Thread.yield() 让出 CPU,等待初始化完成,保证数组仅初始化一次。
3. 扩容时的线程协作标识
ConcurrentHashMap 扩容时,会通过 CAS 修改 sizeCtl 的值,标识扩容状态,同时允许其他线程协助迁移元素,提升扩容效率。
核心逻辑
-
扩容开始时,通过 CAS 将 sizeCtl 改为 (resizeStamp(n) << RESIZESTAMPSHIFT) + 2 ,标记为扩容中;
-
其他线程操作时检测到扩容标识,会主动参与元素迁移;
-
扩容完成后,通过 CAS 恢复 sizeCtl 为新的扩容阈值。
四、synchronized 局部锁的应用与触发时机
虽然 CAS 能处理无竞争场景,但当桶不为空(存在哈希冲突)或需要修改复杂结构(如红黑树)时,仍需依赖锁机制。JDK1.8 的 ConcurrentHashMap 选择桶级 synchronized 锁,将锁粒度细化到单个桶,最大化降低锁竞争。
1. synchronized 锁的触发场景
synchronized 锁仅在以下场景触发,保证 "按需加锁":
-
**桶不为空且 CAS 插入失败:**多个线程同时向同一桶插入节点,CAS 竞争失败后,转为对桶首节点加锁;
-
**桶内存在哈希冲突:**需要遍历链表或红黑树,处理 Key 重复、插入新节点等操作;
-
**红黑树结构修改:**红黑树的插入、删除、旋转等操作,需锁定根节点保证结构安全;
-
**扩容时的元素迁移:**迁移单个桶的元素时,需对桶加锁,避免迁移过程中数据被修改。
2. 桶级 synchronized 锁的核心执行逻辑
以 putVal 方法中处理非空桶的逻辑为例,synchronized 锁的核心流程如下:
核心源码(简化版)
java
final V putVal(K key, V value, boolean onlyIfAbsent) {
// 省略空桶CAS插入逻辑
else {
V oldVal = null;
// 对桶首节点f加synchronized锁,锁粒度为桶
synchronized (f) {
// 再次校验桶首节点,避免加锁期间被修改
if (tabAt(tab, i) == f) {
if (f.hash >= 0) { // 桶内是链表结构
int binCount = 0;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// Key已存在,覆盖旧值
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.value;
if (!onlyIfAbsent)
e.value = value;
break;
}
Node<K,V> pred = e;
// 遍历到链表尾部,插入新节点(尾插法)
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key, value, null);
// 检查是否需要转红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
treeifyBin(tab, i);
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.value;
if (!onlyIfAbsent)
p.value = value;
}
}
}
}
// 省略后续逻辑
}
return oldVal;
}
执行逻辑
-
线程 CAS 插入失败后,对桶首节点 f 加 synchronized 锁,保证同一桶内的操作互斥;
-
加锁后再次校验桶首节点(防止加锁期间桶结构被修改);
-
根据桶内结构(链表 / 红黑树)执行对应的插入或覆盖逻辑;
-
操作完成后,自动释放 synchronized 锁,无需手动解锁。
3. 红黑树的加锁逻辑
当红黑树需要修改时,ConcurrentHashMap 会对红黑树的封装节点 TreeBin 加 synchronized 锁,而非直接锁定 TreeNode:
-
TreeBin 是红黑树的容器类,内部维护红黑树的根节点和读写状态;
-
对 TreeBin 加锁,可同时保护红黑树的结构修改和查询操作,避免并发导致的树结构错乱。
五、CAS 与 synchronized 的协同工作流程
以 put 操作为例,我们串联起 CAS 与 synchronized 的完整协同逻辑,清晰展示两者如何分工保障并发安全:
-
**哈希计算与桶定位:**线程计算 Key 的哈希值,确定目标桶的索引 i ;
-
**空桶 CAS 无锁插入:**若桶为空,通过 CAS 尝试插入新节点,成功则直接完成操作,失败则进入下一步;
-
**非空桶 synchronized 加锁:**对桶首节点加 synchronized 锁,防止其他线程修改当前桶;
-
**桶内元素操作:**遍历链表或红黑树,处理 Key 重复(覆盖旧值)或插入新节点(尾插法),必要时触发链表转红黑树;
-
**释放锁与扩容检查:**操作完成后释放 synchronized 锁,检查元素数量是否达到扩容阈值,若达到则触发扩容;
-
**扩容时的 CAS 协作:**扩容过程中通过 CAS 标记扩容状态,允许其他线程协助迁移元素,提升扩容效率。
整个流程中,CAS 负责处理无竞争的简单场景,避免锁开销;synchronized 负责处理有竞争的复杂场景,保证操作原子性,两者协同实现 "无锁优先、加锁兜底" 的高效并发方案。
六、CAS+synchronized 与 ReentrantLock 分段锁的性能对比
JDK1.7 的 ConcurrentHashMap 采用 ReentrantLock 分段锁,JDK1.8 则使用 CAS+synchronized,两种方案的性能差异主要体现在以下维度:
1. 锁粒度与并发度
-
**ReentrantLock 分段锁:**锁粒度为 Segment 级别,一个 Segment 对应多个桶,同一 Segment 内的桶操作会产生锁竞争,并发度由 Segment 数量决定(默认 16);
-
**CAS+synchronized:**锁粒度为桶级别,仅同一桶的操作会竞争锁,理论并发度等于桶的数量(默认 16,扩容后可增加),且无竞争场景可通过 CAS 实现无锁并发,并发度远高于分段锁。
2. 锁开销与性能
-
**ReentrantLock:**作为显式锁,存在锁的获取 / 释放、AQS 队列维护等开销,且高并发下的锁竞争会导致线程阻塞;
-
**synchronized:**JDK1.6 后对 synchronized 做了大量优化(偏向锁、轻量级锁、锁消除等),桶级锁大多处于轻量级锁状态,开销远低于 ReentrantLock;配合 CAS 的无锁操作,整体性能优势明显。
3. 场景适配性
**ReentrantLock:**支持公平锁、可中断锁、条件变量等高级特性,适用于需要复杂锁控制的场景,但 ConcurrentHashMap 并未用到这些特性,属于 "功能过剩";
**CAS+synchronized:**仅保留必要的锁能力,适配 ConcurrentHashMap 的并发需求,且硬件级 CAS 指令和优化后的 synchronized,能更好地平衡性能与安全性。
七、锁机制设计的核心思想:按需加锁与无锁优先
ConcurrentHashMap 的锁机制设计,体现了高并发编程的两大核心思想:
1. 无锁优先
对于无竞争或低竞争的简单操作(如空桶插入、数组初始化),优先使用 CAS 无锁操作,避免锁的上下文切换开销,最大化提升并发吞吐量。
2. 按需加锁与锁粒度最小化
仅在必要时(如哈希冲突、结构修改)加锁,且将锁粒度细化到 "桶" 级别,而非全表或分段,最大限度降低锁竞争对并发性能的影响。
3. 协作式扩容
通过 CAS 标识扩容状态,允许所有线程参与元素迁移,将扩容的性能损耗分散到多个线程,避免单线程扩容导致的性能瓶颈。
八、结语
本文深度拆解了 JDK1.8 ConcurrentHashMap 中 CAS 与 synchronized 的协同工作原理,明确了两者的分工与应用场景。这种 "无锁 + 细粒度锁" 的组合方案,既保证了高并发下的线程安全,又通过精细化控制实现了性能最大化。
下一篇文章,我们将基于源码逐行拆解 ConcurrentHashMap 的 put/get 方法,完整梳理其并发安全的执行流程,带你从代码层面吃透每一个细节,敬请关注!