ConcurrentHashMap 核心锁机制:CAS+Synchronized 的协同工作原理

一、前言

在上一篇文章中,我们了解到 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 的执行逻辑

  1. 线程读取内存地址 V 中的当前值,与预期旧值 A 进行比较;

  2. 若两者相等,说明无其他线程修改过该变量,将 V 的值更新为新值 B ,操作成功;

  3. 若两者不相等,说明变量已被其他线程修改,当前线程放弃更新(或自旋重试),操作失败。

整个过程由 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加锁逻辑
}

执行逻辑

  1. 线程通过 tabAt 获取目标桶的节点,确认桶为空;

  2. 调用 casTabAt 尝试将新 Node 写入桶的内存地址,预期旧值为 null ,新值为待插入的 Node;

  3. 若 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;
}

执行逻辑

  1. 多线程同时进入 initTable 时,通过 CAS 竞争 sizeCtl 的修改权;

  2. 成功修改 sizeCtl 为 - 1 的线程,负责创建数组并初始化;

  3. 其他线程检测到 sizeCtl<0 时,会通过 Thread.yield() 让出 CPU,等待初始化完成,保证数组仅初始化一次。

3. 扩容时的线程协作标识

ConcurrentHashMap 扩容时,会通过 CAS 修改 sizeCtl 的值,标识扩容状态,同时允许其他线程协助迁移元素,提升扩容效率。

核心逻辑

  1. 扩容开始时,通过 CAS 将 sizeCtl 改为 (resizeStamp(n) << RESIZESTAMPSHIFT) + 2 ,标记为扩容中;

  2. 其他线程操作时检测到扩容标识,会主动参与元素迁移;

  3. 扩容完成后,通过 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;
}

执行逻辑

  1. 线程 CAS 插入失败后,对桶首节点 f 加 synchronized 锁,保证同一桶内的操作互斥;

  2. 加锁后再次校验桶首节点(防止加锁期间桶结构被修改);

  3. 根据桶内结构(链表 / 红黑树)执行对应的插入或覆盖逻辑;

  4. 操作完成后,自动释放 synchronized 锁,无需手动解锁。

3. 红黑树的加锁逻辑

当红黑树需要修改时,ConcurrentHashMap 会对红黑树的封装节点 TreeBin 加 synchronized 锁,而非直接锁定 TreeNode:

  • TreeBin 是红黑树的容器类,内部维护红黑树的根节点和读写状态;

  • 对 TreeBin 加锁,可同时保护红黑树的结构修改和查询操作,避免并发导致的树结构错乱。

五、CAS 与 synchronized 的协同工作流程

以 put 操作为例,我们串联起 CAS 与 synchronized 的完整协同逻辑,清晰展示两者如何分工保障并发安全:

  1. **哈希计算与桶定位:**线程计算 Key 的哈希值,确定目标桶的索引 i ;

  2. **空桶 CAS 无锁插入:**若桶为空,通过 CAS 尝试插入新节点,成功则直接完成操作,失败则进入下一步;

  3. **非空桶 synchronized 加锁:**对桶首节点加 synchronized 锁,防止其他线程修改当前桶;

  4. **桶内元素操作:**遍历链表或红黑树,处理 Key 重复(覆盖旧值)或插入新节点(尾插法),必要时触发链表转红黑树;

  5. **释放锁与扩容检查:**操作完成后释放 synchronized 锁,检查元素数量是否达到扩容阈值,若达到则触发扩容;

  6. **扩容时的 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 方法,完整梳理其并发安全的执行流程,带你从代码层面吃透每一个细节,敬请关注!

相关推荐
柒许宁安2 小时前
在 Cursor 中运行 Android 项目指南
android·java·个人开发
任子菲阳2 小时前
学Javaweb第四天——springboot入门
java·spring·mybatis
毕设源码-赖学姐2 小时前
【开题答辩全过程】以 基于Springboot的球场管理平台的设计与实现为例,包含答辩的问题和答案
java·spring boot·后端
集芯微电科技有限公司2 小时前
DC-DC|40V/10A大电流高效率升压恒压控制器
c语言·数据结构·单片机·嵌入式硬件·fpga开发
C雨后彩虹2 小时前
HashMap的线程安全问题:原因分析与解决方案
java·数据结构·哈希算法·集合·hashmap
foo1st2 小时前
HTML中常用HASH算法使用笔记
javascript·html·哈希算法
有趣灵魂2 小时前
Java-Spingboot根据HTML模板和动态数据生成PDF文件
java·pdf·html
im_AMBER2 小时前
Leetcode 87 等价多米诺骨牌对的数量
数据结构·笔记·学习·算法·leetcode
BIBI20492 小时前
Windows 上配置 Nacos Server 3.x.x 使用 MySQL 5.7
java·windows·spring boot·后端·mysql·nacos·配置