Java多线程进阶-深入synchronized与CAS

文章目录

2025-8-13优化记录:

  1. 补充了CAS的硬件实现原理,重新整理了关于getAndIncrement 的描述
  2. 增加了ABA问题解决方案的"场景回顾",解释了如何避免ABA问题
  3. 补充了ABA知识点相关的面试题内容

2. Java多线程进阶:深入synchronized与CAS

在上一篇Java多线程初阶-线程协作与实战案例笔记中,我们从宏观上探讨了各种锁策略的思想。今天,我们将深入到更底层的实现,去探寻两个在现代并发编程中至关重要的知识内容:synchronized 的锁升级机制无锁编程CAS

CAS (Compare-and-Swap) 是实现"乐观锁"的原子操作,也是JUC(java.util.concurrent)包中许多高性能类的幕后英雄。而 synchronized,这个我们最熟悉的关键字,其内部为了追求极致性能而进行的优化和演进,恰恰就是对CAS等思想的应用。可以说,理解了它们,我们才能真正看懂Java并发性能优化的精髓所在。


一、无锁编程------CAS (Compare-and-Swap)

在实际开发中,我们很少直接使用CAS,但它的思想和应用无处不在,是理解JUC并发包许多工具类的关键。

1. 什么是 CAS?一个原子的"比较并交换"操作

CAS,全称 Compare and Swap,顾名思义,就是"比较并交换"。它是一种特殊的原子操作,涉及到三个核心操作数:

  • V (Value):内存中要被修改的那个变量的当前值。
  • A (Expected):我们"期望"内存中变量的当前值应该是多少。
  • B (New):如果期望成真,需要将变量更新成的新值。

整个操作逻辑可以概括为一句话:"我认为 V 应该是 A,如果是,那就把它改成 B。"

它的执行流程如下:

  1. 比较 :从内存地址 V 中读取当前值,判断它是否与我们的预期值 A 相等。
  2. 交换 :如果相等,说明在我们准备修改的这段时间里,没有其他线程动过这个变量。此时,就放心地将新值 B 写入内存地址 V
  3. 返回:无论成功与否,都返回操作结果。

为了更直观地理解,我们可以用一段伪代码来描述这个过程:

java 复制代码
// CAS伪代码,仅用于理解流程
// 重点:真实的CAS是由一个原子的硬件指令完成的!
boolean CAS(address, expectValue, swapValue) {
    // --- 以下所有操作,在硬件层面是一个不可分割的整体 ---
    if (内存中address处的值 == expectValue) {
        将内存中address处的值更新为swapValue;
        return true; // 成功
    }
    return false; // 失败
    // --- 原子操作结束 ---
}

小思考:CAS的原子性到底来自哪里?

看到上面的伪代码,我们很容易产生一个疑问:这个 if判断再加一个赋值操作,这不就是典型的"Check-and-Set"嘛,它怎么可能是原子的?

没错,在软件层面,if { ... } 这样的结构确实不是原子的,它完全可能在 if判断成功后、赋值操作执行前被其他线程打断。这正是我们在多线程基础部分反复强调的i++(Read-and-Update)那样的线程不安全操作。

CAS的牛逼在于,它并非由软件实现,而是由CPU硬件层面的一条或几条特殊指令直接支持的。CPU保证了这个"读取-比较-写入"的复合操作在执行期间不被任何其他操作打断,从而实现了真正的原子性。

简单来说,Java中的CAS调用链大致是这样的:

  • Java代码(如AtomicInteger)调用 Unsafe 类的CAS方法。
  • Unsafe 类的方法是 native 的,它会调用到JVM的C++代码。
  • JVM会根据不同的操作系统,生成对应的汇编指令,例如在x86架构下就是带有 lock 前缀的 cmpxchg 指令。
  • 最终,由CPU硬件来保障这个操作的原子性。
    所以,正是因为有硬件层面的支持,我们才能在软件层面享受到CAS带来的高效并发控制。

当多个线程同时对某个资源进行CAS操作,只能有一个线程操作成功,但它并不会阻塞其他线程,其他线程只会收到操作失败的信号。因此,CAS 可以视为是一种乐观锁的实现方式

2. CAS 的应用

1) 实现原子类

Java标准库 java.util.concurrent.atomic 包下的所有原子类,如 AtomicIntegerAtomicLong 等,都是基于CAS实现的。它们可以在不使用锁的情况下,保证基本数据类型的操作是线程安全的,性能远高于加锁。

我们知道,像 count++ 这样的复合操作在并发环境下是线程不安全的,常规的解决方案就是加 synchronized 锁。但加锁的性能开销较大,而基于CAS的原子类,如 AtomicInteger,则提供了一种更轻量、更高效的替代方案。

java 复制代码
// 使用原子类代替int,实现线程安全的计数器
private static AtomicInteger count = new AtomicInteger(0);
// 通过这个原子类就可以简单的解决我们之前多线程部分count++的线程安全问题
public void threadSafeIncrement() {
    count.getAndIncrement(); // 内部通过CAS循环实现原子自增
}

getAndIncrement 的内部实现,就是一个典型的"CAS自旋"循环。让我们通过一个多线程场景,来详细拆解它的执行过程:

java 复制代码
// AtomicInteger.getAndIncrement() 伪代码
public final int getAndIncrement() {
    int oldValue;
    do {
        // 步骤1: 读取主内存中的当前值
        oldValue = this.get(); 
    } while (!this.compareAndSet(oldValue, oldValue + 1)); // 步骤2: 循环尝试CAS更新
    return oldValue;
}

这个 do-while 循环就是所谓的 "CAS自旋" 。为了彻底搞懂它,让我们来情景重现一下两个线程(线程A、线程B)同时执行 getAndIncrement() 的全过程。

场景设定: count 初始值为 0

  1. 各自读取,心中有数

    • 线程A和线程B几乎同时启动,它们都执行 get() 方法,从主内存中读取到 count 的值为 0
    • 现在,在它们各自的线程栈里,都有一个局部变量 oldValue,值都是 0。它们各自的目标都是想把主内存的 count 更新为 1
  1. 线程A"抢跑"成功

    • 假设线程A的CPU时间片先到,它执行 compareAndSet(0, 1)
    • 它发起一次原子操作:"请检查主内存的 count 是不是 0?如果是,就把它改成 1。"
    • 检查通过!主内存中的值确实是 0。于是,CAS操作成功count 的值被更新为 1compareAndSet 返回 true,循环结束,线程A完成任务。
  2. 线程B的"意外"与"自旋"

    • 现在,轮到线程B执行 compareAndSet(0, 1)
    • 它也发起原子请求:"请检查主内存的 count 是不是 0?"
    • 然而,此时主内存中的 count 已经是 1 了!线程B的预期值 0 与主内存的当前值 1 不匹配。因此,这次CAS操作失败了。
    • compareAndSet 返回 falsewhile 循环的条件 !false 变成了 true。线程B意识到:"看来在我发呆的时候,有人已经把值改了。我得重新来过。"
    • 线程B进入下一次循环 (这就是"自旋")。它重新执行 get(),这次从主内存读到的是最新值 1,并更新了自己的 oldValue
  3. 线程B的第二次尝试

    • 线程B更新了自己的小算盘:"好的,现在值是 1 了,那我的目标就是把它更新成 2。"
    • 它再次发起 compareAndSet(1, 2) 请求。这一次,没有其他线程来捣乱,它的预期值 1 和主内存的值 1 完美匹配。
    • CAS操作成功 ,主内存的 count 值被更新为 2while 循环条件为 false,线程B也成功退出。

通过这个过程,我们可以看到,CAS的核心就是一种"乐观"的尝试。每个线程都乐观地认为自己可以修改成功,如果失败了,也不会立刻阻塞,而是像一个执着的程序员一样,不断地重试(自旋),直到成功为止。这种机制在并发度不高、锁占用时间很短的场景下,性能远超于需要操作系统介入的重量级锁。

2) 实现自旋锁

基于 CAS 也可以实现一个更灵活的自旋锁。

java 复制代码
public class SpinLock {
    // owner为null表示锁空闲,否则表示被某个线程持有
    private volatile Thread owner = null;

    public void lock(){
        // 如果锁是null(空闲),就尝试通过CAS把它设置为当前线程
        // CAS失败说明锁被别人占了,进入循环忙等(自旋)
        while(!CAS(this.owner, null, Thread.currentThread())){
            // a busy-wait loop
        }
    }

    public void unlock (){
        // 直接将owner置为null即可释放锁
        this.owner = null;
    }
}

3. 深度剖析:CAS 的 ABA 问题及其解决方案

CAS的核心逻辑是:只要内存值等于预期旧值,就认为数据没有被其他线程修改过。但这里存在一个逻辑漏洞:如果数据被其他线程从 A 改为 B,然后又改回 A,会发生什么?

这就是 ABA 问题。对于执行CAS的线程来说,它无法区分数据是"从未变过",还是"曾经变过又变回来了"。

在大部分情况下,ABA问题可能无害。但某些对数据变化过程敏感的场景,它会引发严重BUG。

一个经典的ABA问题场景:

假设滑稽老哥账户有 100 元存款。他想从 ATM 取 50 块钱。

  1. 取款线程1 获取到当前存款值为 100, 期望更新为 50。
  2. 在线程1执行CAS前,CPU切换到另一个高优先级任务:滑稽的朋友正好给他转账 50,账户余额先变成150,然后他又消费了50,账户余额最终又变回 100。
  3. 轮到线程1 执行了, 它发现当前存款为 100, 和它之前读到的 100 相同, 于是CAS操作成功,执行扣款!

尽管中间发生了多次交易,但线程1的CAS操作依然成功了,这在某些金融场景下是不可接受的。

解决方案:版本号机制

要解决ABA问题,核心思路很简单:不要只关心值是否变了,还要关心它变过几次。于是,**版本号(Version)**机制应运而生。

  • 在更新变量时,除了更新值,还要给版本号 +1
  • 执行CAS操作时,不再是 CAS(V, A, B),而是 CAS(V, (A, version), (B, version+1))
  • 现在,只有当内存中的 版本号 与预期完全一致时,操作才会成功。

在 Java 标准库中,AtomicStampedReference<E> 类就是这一思想的完美实现。它内部维护了一个"时间戳"(stamp),这个时间戳其实就是版本号。

场景回顾:带版本号的取款操作

让我们用版本号机制来重演一遍上面的取款故事:

  1. 初始状态 :账户余额为 (100, version=1)
  2. 取款线程1 读取数据,得知当前余额为 100,版本号为 1。它的期望是:如果余额还是 (100, version=1),就将其更新为 (50, version=2)
  3. 中间交易
    • 朋友转账50元,账户余额变为 (150, version=2)
    • 滑稽老哥消费50元,账户余额变为 (100, version=3)
  4. 取款线程1执行 :它发起CAS请求:"请检查账户余额是不是 (100, version=1)?"
  5. 检查结果 :CPU发现,当前账户的实际状态是 (100, version=3)。虽然值都是100,但版本号对不上了!
  6. 操作失败:CAS操作失败,取款线程1的扣款操作被阻止。这就完美规避了ABA问题。
相关面试题

面试官:谈谈你对CAS的理解?

参考回答:

CAS,全称是Compare and Swap,也就是"比较并交换"。它是一种无锁化的、基于乐观锁思想的并发控制技术。

它的核心是CAS(V, A, B)这样一个原子操作,包含三个操作数:内存地址V,预期值A,和新值B。执行时,它会原子地完成三件事:读取V处的值,与A比较,如果相等,就用B更新V的值。

它的原子性不是由软件代码保证的,而是由CPU的硬件指令(如lock cmpxchg)直接支持的,所以效率很高。像Java里的AtomicInteger等原子类,底层就是通过CAS的自旋操作来实现线程安全的,避免了使用重量级锁带来的线程挂起和上下文切换开销。

面试官:那CAS有什么缺点吗?比如ABA问题,你知道怎么解决吗?

参考回答:

是的,CAS有一个经典的ABA问题。就是说,一个值从A变成了B,又变回了A。CAS检查的时候会发现它的值没有变,但实际上这个值已经被其他线程修改过了。

在大多数情况下这可能没问题,但在一些需要严格追踪数据变更过程的场景(比如金融领域的账户余额操作),就会导致逻辑错误。

解决ABA问题的标准方法是引入版本号机制 。在每次修改数据时,除了修改数据本身,也递增版本号。这样,CAS操作就需要同时检查"数据值"和"版本号"是否都和预期一致。即使数据被改回了原样,版本号也已经变了,CAS操作就会失败,从而避免了ABA问题。在Java中,我们可以使用 AtomicStampedReference 这个类来实现带版本号的CAS操作。

二、synchronized ------从偏向锁到重量级锁的演进

理解了CAS和自旋锁,我们就能更好地揭开 synchronized 的神秘面纱。现代JVM为了极致的性能,赋予了 synchronized 多重身份和一套智能的锁升级机制。

1. synchronized 的多重身份回顾

结合上一篇的内容, 我们可以总结出, synchronized 具有以下特性(以现代JDK版本为例):

  1. 乐观与悲观的结合体:开始时是乐观锁, 如果锁冲突频繁, 就转换为悲观锁。
  2. 轻量与重量的动态切换:开始是轻量级锁实现, 如果锁被持有的时间较长, 就转换成重量级锁。
  3. 自旋锁的应用:实现轻量级锁的时候用到了自旋锁策略。
  4. 非公平锁
  5. 可重入锁
  6. 非读写锁

2. 锁升级之路:偏向锁 -> 轻量级锁 -> 重量级锁

JVM 为了极致的性能,将 synchronized 锁分为 无锁、偏向锁、轻量级锁、重量级锁 四种状态,并会根据竞争情况,进行依次升级,且此过程通常是不可逆的。

1) 偏向锁

当第一个线程尝试加锁时,JVM并不会立刻加锁,而是优先进入偏向锁状态。

偏向锁不是真的 "加锁", 只是在对象头中做一个 "偏向锁的标记", 记录下这个锁"偏爱"哪个线程。如果后续没有其他线程来竞争该锁, 那么持有偏向锁的线程在进出同步块时,就无需再进行任何同步操作了,极大地避免了加锁解锁的开销。

偏向锁本质上相当于 "延迟加锁"。能不加锁就不加锁, 尽量来避免不必要的加锁开销。

如果后续有其他线程来竞争该锁, 那就取消原来的偏向锁状态, 升级为轻量级锁状态

2) 轻量级锁

随着其他线程进入竞争, 偏向锁状态被消除, 进入轻量级锁状态。

此处的轻量级锁就是通过 CAS 来实现的。线程会尝试通过CAS将锁对象的对象头指向自己的线程栈中的记录。

  • 如果更新成功, 则认为加锁成功。
  • 如果更新失败, 则认为锁已被占用, 线程会进行自旋式的等待(并不放弃 CPU)。

自旋操作是一直让 CPU 空转, 比较浪费 CPU 资源。因此此处的自旋不会一直持续进行, 而是达到一定的时间或重试次数后(即所谓的"自适应自旋"),如果仍未获取到锁,就会再次升级。

3) 重量级锁

如果竞争进一步激烈, 自旋不能快速获取到锁状态, 锁就会"膨胀"为重量级锁

此处的重量级锁就是指动用操作系统内核提供的 mutex

  • 执行加锁操作, 线程会从用户态切换到内核态。
  • 在内核态判定当前锁是否已经被占用。
  • 如果该锁没有占用, 则加锁成功, 并切换回用户态。
  • 如果该锁被占用, 则加锁失败。此时线程会进入锁的等待队列, 挂起并放弃CPU, 等待未来被操作系统唤醒。

3. JVM 的智能优化:锁消除与锁粗化

除了锁升级,JVM在即时编译(JIT)阶段还会进行一些智能的锁优化。

锁消除 (Lock Elision)

编译器和JVM足够智能,能够判断出某些代码中的 synchronized 锁是否是多余的。如果发现一个锁不可能存在竞争(例如在单线程中使用 StringBuffer),就会直接将这个锁消除掉。

java 复制代码
// 在单线程环境中,这些append的锁都是不必要的
public void createString() {
    StringBuffer sb = new StringBuffer();
    sb.append("a"); // append是同步方法,有锁
    sb.append("b");
    sb.append("c");
}
// JIT编译器会优化为:
public void createString() {
    StringBuffer sb = new StringBuffer();
    // 锁被消除
    sb.append("a");
    sb.append("b");
    sb.append("c");
}
锁粗化 (Lock Coarsening)

如果一段逻辑中出现对同一个锁对象的多次、连续的加锁和解锁,编译器和JVM会自动将这些锁操作合并成一个更大范围的锁,以减少锁操作的次数。这被称为锁粗化

一个类比:

领导给下属交代工作任务:

  • 方式一 (锁粒度细): 打电话, 交代任务1, 挂电话。再打电话, 交代任务2, 挂电话。再打电话, 交代任务3, 挂电话。
  • 方式二 (锁粒度粗): 打电话, 一次性交代完任务1, 任务2, 任务3, 然后挂电话。

显然, 方式二是更高效的方案。JVM的锁粗化就是这个道理。


本篇核心要点总结 (Key Takeaways)

  • CAS是无锁编程 :CAS(Compare-and-Swap)是一种CPU原子指令,它可以在不使用锁的情况下实现线程安全的操作。它是AtomicInteger等原子类和JUC中许多并发工具的实现基础。
  • synchronized的智能进化synchronized并非一个简单的重量级锁,而是拥有一个从偏向锁 -> 轻量级锁 -> 重量级锁的智能升级过程,旨在尽可能降低无竞争或低竞争场景下的性能开销。
  • CAS与synchronized的内在联系synchronized轻量级锁阶段,就是通过"CAS + 自旋"来实现的,这避免了线程直接进入阻塞状态,提高了性能。
  • ABA问题的警惕 :在使用CAS时,需要注意ABA问题(值被改回原样),对于需要严格保证操作过程的场景,应使用AtomicStampedReference等带有版本号机制的工具来解决。
    -> 轻量级锁 -> 重量级锁**的智能升级过程,旨在尽可能降低无竞争或低竞争场景下的性能开销。
  • CAS与synchronized的内在联系synchronized轻量级锁阶段,就是通过"CAS + 自旋"来实现的,这避免了线程直接进入阻塞状态,提高了性能。
  • ABA问题的警惕 :在使用CAS时,需要注意ABA问题(值被改回原样),对于需要严格保证操作过程的场景,应使用AtomicStampedReference等带有版本号机制的工具来解决。
相关推荐
xiaowu0804 分钟前
策略模式-不同的鸭子的案例
开发语言·c#·策略模式
edjxj28 分钟前
Qt图片资源导入
开发语言·qt
qq_259297247330 分钟前
QT-事件
开发语言·qt
专注VB编程开发20年33 分钟前
CSS 的命名方式像是 PowerShell 的动词-名词结构,缺乏面向对象的层级关系
开发语言·后端·rust
古译汉书34 分钟前
嵌入式铁头山羊stm32-ADC实现定时器触发的注入序列的单通道转换-Day26
开发语言·数据结构·stm32·单片机·嵌入式硬件·算法
野犬寒鸦36 分钟前
力扣hot100:相交链表与反转链表详细思路讲解(160,206)
java·数据结构·后端·算法·leetcode
ytadpole1 小时前
揭秘设计模式:工厂模式的五级进化之路
java·设计模式
计算机毕业设计木哥1 小时前
计算机毕设选题:基于Python+Django的B站数据分析系统的设计与实现【源码+文档+调试】
java·开发语言·后端·python·spark·django·课程设计
失散131 小时前
分布式专题——1.2 Redis7核心数据结构
java·数据结构·redis·分布式·架构
陈陈爱java2 小时前
Spring八股文
开发语言·javascript·数据库