当前位置: 首页 > news >正文

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

文章目录

  • 2. Java多线程进阶:深入synchronized与CAS
    • 一、无锁编程——CAS (Compare-and-Swap)
      • 1. 什么是 CAS?一个原子的“比较并交换”操作
      • 2. CAS 的应用
        • 1) 实现原子类
        • 2) 实现自旋锁
      • 3. 深度剖析:CAS 的 ABA 问题及其解决方案
        • 解决方案:版本号机制
    • 二、`synchronized` ——从偏向锁到重量级锁的演进
      • 1. `synchronized` 的多重身份回顾
      • 2. 锁升级之路:偏向锁 -> 轻量级锁 -> 重量级锁
        • 1) 偏向锁
        • 2) 轻量级锁
        • 3) 重量级锁
      • 3. JVM 的智能优化:锁消除与锁粗化
        • 锁消除 (Lock Elision)
        • 锁粗化 (Lock Coarsening)
    • 本篇核心要点总结 (Key Takeaways)

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
  • 旧的预期值 A
  • 需要修改的新值 B

其操作流程是:

  1. 比较:判断内存中的值 V 是否与旧的预期值 A 相等。
  2. 交换:如果相等,就将新值 B 写入 V
  3. 返回操作是否成功。

核心要点: 这整个“比较并交换”的过程是由一条CPU硬件指令完成的,因此它是原子的,不会被其他线程中断。

// CAS伪代码,仅用于理解流程
// 真实的CAS是由一个原子的硬件指令完成的
boolean CAS(address, expectValue, swapValue) {// --- 以下是不可分割的原子操作 ---if (内存中address处的值 == expectValue) {将内存中address处的值更新为swapValue;return true;}return false;// --- 原子操作结束 ---
}

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

2. CAS 的应用

1) 实现原子类

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

在这里插入图片描述

count++ 这样的操作(写操作)本身是线程不安全的,通常需要加锁解决。但加锁效率较低,而使用基于CAS的原子类,可以在保证线程安全的同时,获得更好的性能。

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

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

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

CAS自旋过程的通俗理解:

为了彻底搞懂这个过程,我们来模拟一个经典的“双线程抢占资源”的场景。假设 count 的初始值为 0,线程A和线程B都想执行 count++

  1. 各自的“小算盘”

    • 线程A 率先启动,它执行 get() 方法,从主内存中读取到 count 的值是 0。它在自己的工作内存中记下:“嗯,当前值是 0,我待会儿要把它变成 1。”
    • 就在线程A准备提交更新(执行 compareAndSet)之前,操作系统发生了线程调度,线程A被挂起,线程B 登场。
  2. 线程B“抢跑”成功

    • 线程B 也执行 get(),它从主内存读到的值同样是 0。它也打好了自己的算盘:“当前值是 0,我要把它更新成 1。”
    • 接着,线程B执行 compareAndSet(0, 1)。它向CPU申请一个原子操作:“请检查一下主内存里的 count 是不是 0?如果是,就把它改成 1。”
    • 检查通过!主内存中的值确实是 0。于是,CAS操作成功count 的值被更新为 1。线程B满意地完成了任务,退出了循环。
  3. 线程A的“意外”与“重试”

    • 现在,线程A被唤醒,继续它未完成的工作。它也准备执行 compareAndSet(0, 1)。它信心满满地提出原子请求:“请检查主内存的 count 是不是 0?如果是就改成 1。”
    • 然而,此时主内存中的 count 已经是 1 了!线程A的预期值 0 与主内存的当前值 1 不匹配。因此,这次CAS操作失败了。
    • compareAndSet 返回 falsewhile 循环的条件 !false 变成了 true。线程A意识到:“看来在我发呆的时候,有人已经把值改了。我得重新来过。”
  4. 线程A的第二次尝试

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

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

2) 实现自旋锁

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

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问题,核心思路是引入版本号。在 CAS 比较数据当前值和旧值的同时, 也要比较版本号是否符合预期。

  • 每次修改数据时,不仅更新数据,也让版本号 +1
  • CAS操作时,同时检查 (value, version) 是否都与预期相符。

在 Java 标准库中提供了 AtomicStampedReference<E> 类来解决ABA问题,它内部就维护了一个类似版本号的“时间戳”(stamp)。


二、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),就会直接将这个锁消除掉。

// 在单线程环境中,这些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等带有版本号机制的工具来解决。
http://www.dtcms.com/a/331744.html

相关文章:

  • RS232串行线是什么?
  • 考研408《计算机组成原理》复习笔记,第五章(1)——CPU功能和结构
  • C#WPF实战出真汁01--搭建项目三层架构
  • 解决 pip 安装包时出现的 ReadTimeoutError 方法 1: 临时使用镜像源(单次安装)
  • LeetCode 1780:判断一个数字是否可以表示成3的幂的和-进制转换解法
  • 基于 LDA 模型的安徽地震舆情数据分析
  • 相机Camera日志实例分析之十四:相机Camx【照片后置炫彩拍照】单帧流程日志详解
  • python——mock接口开发
  • CSS中的 :root 伪类
  • GitHub 仓库代码上传指南
  • svg 转 emf
  • MySQL 事务隔离级别深度解析:从问题实例到场景选择
  • Java 中实体类、VO 与 DTO 的深度解析:定义、异同及实践案例
  • 20道JavaScript进阶相关前端面试题及答案
  • 报数游戏(我将每文更新tips)
  • emqx tar包安装
  • DAY 22|算法篇——贪心四
  • 调整磁盘分区格式为GPT
  • 数据结构:优先队列 (Priority Queue)
  • 解剖HashMap的put <五> JDK1.8
  • 微信公众号推送文字消息与模板消息
  • 字节跳动 VeOmni 框架开源:统一多模态训练效率飞跃!
  • JAVA 抽象类可以实例化吗
  • 机器学习概述(一)
  • Spring Cloud系列—Alibaba Sentinel熔断降级
  • 第一章 随机事件与概率
  • 前端性能优化移动端网页滚动卡顿与掉帧问题实战
  • 前端开发常见问题及解决方案全解析
  • 解剖HashMap的put流程 <一> (JDK 1.8)
  • 22.Linux samba服务