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

volatile,synchronized,原子操作实现原理,缓存一致性协议

文章目录

  • 缓存一致性协议(MESI)
  • volatile
    • 1. volatile 的作用
    • 2.volatile的底层实现
    • 3,volatile 实现单例模式的双重锁(面手写)
  • synchronized
    • 1,基本用法
    • 2,可重入性
    • 3,Java对象头
    • 4,实现原理
      • (1)代码块同步的实现
      • (2)方法同步的实现
    • 5,锁的升级与对比
  • 原子操作的实现原理
    • 1,术语
    • 2,如何实现原子操作
    • 3,Java如何实现原子操作
        • CAS实现原子操作的三大问题

  • CAS及其损耗CPU性能

缓存一致性协议(MESI)

MESI 是四种缓存行状态的缩写:

状态英文全称说明
M (Modified)已修改缓存行已被当前CPU修改,与主存不一致,其他CPU不能持有该数据的有效副本
E (Exclusive)独占缓存行仅被当前CPU持有,与主存一致,其他CPU没有该数据的副本
S (Shared)共享缓存行被多个CPU共享,所有副本与主存一致
I (Invalid)无效缓存行数据已失效,必须从主存或其他CPU重新加载

MESI 的工作示例:

假设两个CPU核心(Core1和Core2)访问同一内存地址 X

  1. 初始状态
    • X 在主存中的值为 0
    • Core1和Core2的缓存中均无 X
  2. Core1 读取 X
    • Core1 缓存 X,状态变为 E (Exclusive)
    • 直接从主存加载 X=0
  3. Core2 读取 X
    • Core1 的 X 状态降级为 S (Shared)
    • Core2 也缓存 X,状态为 S
  4. Core1 修改 X=1
    • Core1 发送 总线事务,使 Core2 的 X 缓存行失效(状态变为 I
    • Core1 的 X 状态变为 M (Modified),并更新缓存值
  5. Core2 再次读取 X
    • 发现 X 缓存行无效(状态为 I
    • 向总线发送请求,Core1 将 X=1 写回主存,并降级为 S
    • Core2 重新加载 X=1,状态变为 S

volatile

volatile 是 Java 提供的一种轻量级同步机制,用于确保多线程环境下的 可见性禁止指令重排序,但它 不保证原子性

特性说明实现原理
可见性一个线程修改 volatile 变量后,其他线程立即可见新值内存屏障 + 缓存一致性协议(MESI)
有序性禁止 JVM 对 volatile 变量的读写操作进行重排序插入内存屏障指令
非原子性volatile 不能保证复合操作(如 i++)的原子性需配合 synchronized/CAS

1. volatile 的作用

(1) 保证可见性

  • 问题:普通变量在多线程环境下,一个线程修改后,其他线程可能无法立即看到最新值(由于 CPU 缓存)。
  • volatile 的解决方案
    • 写操作:立即刷新到主内存,并 使其他 CPU 缓存失效
    • 读操作:强制从主内存重新加载最新值。

(2) 禁止指令重排序

  • 问题:JVM 和 CPU 可能对指令进行优化重排,导致多线程环境下出现意外行为。
  • volatile 的解决方案
    • 通过 内存屏障(Memory Barrier) 禁止 JVM 和 CPU 对 volatile 变量的读写操作进行重排序。

2.volatile的底层实现

  1. 内存屏障

    • 写操作

      • **StoreStore 屏障:**位于volatile之前,确保 volatile 写之前的 所有普通写操作 都已完成(刷新到主内存)
      • **StoreLoad 屏障:**位于volatile之后,禁止当前 Store 与之后的 Load 重排序;强制刷新写缓冲区到主内存。
      // 线程1
      x = 1;                // 普通写
      StoreStoreBarrier();  // 确保 x=1 刷入内存
      volatileVar = 2;      // volatile 写
      StoreLoadBarrier();   // 确保 volatile 写对所有线程可见
      
    • 读操作

      • **LoadLoad 屏障:**位于volatile之后,防止 volatile 读与 后续的普通读操作 重排序
      • **LoadStore 屏障:**位于volatile之后,防止 volatile 读与 后续的普通写操作 重排序
      // 线程2
      int tmp = volatileVar; // volatile 读
      LoadLoadBarrier();    // 防止后续读重排序
      LoadStoreBarrier();   // 防止后续写重排序
      int b = x;            // 普通读(此时能看到线程1的 x=1)
      
  2. 缓存一致性协议

3,volatile 实现单例模式的双重锁(面手写)

双检锁/双重校验锁(DCL,即 double-checked locking)

**JDK 版本:**JDK1.5 起

**是否 Lazy 初始化:**是(即使用到这个变量时才会实例化)

**是否多线程安全:**是

**实现难度:**较复杂

**描述:**这种方式采用双锁机制,安全且在多线程情况下能保持高性能。
getInstance() 的性能对应用程序很关键。

实例

public class Singleton {  private volatile static Singleton singleton;  private Singleton (){}  public static Singleton getSingleton() {  if (singleton == null) {  synchronized (Singleton.class) {  if (singleton == null) {  singleton = new Singleton();  }  }  }  return singleton;  }  
}
  • 私有构造器:禁止外部实例化
  • 双重检查
    • 第一次检查(无锁)
      避免每次调用 getSingleton() 都进入同步块,提升性能。
    • 第二次检查(加锁后)
      防止多个线程同时通过第一次检查后重复创建实例。
  • 同步锁(synchronized)
    • 保证 实例化过程的原子性,防止多线程并发创建多个实例。
  • volatile 关键字
    • 解决 指令重排序问题,确保其他线程不会获取到未初始化的对象。

如果不使用 volatile 关键字,JVM 可能会对这三个子步骤进行指令重排。

  • 为 Singleton对象分配内存
  • 将对象赋值给引用 singleton
  • 调用构造方法初始化成员变量

这种重排序会导致 singleton 引用在对象完全初始化之前就被其他线程访问到。具体来说,如果一个线程执行到步骤 2 并设置了 singleton 的引用,但尚未完成对象的初始化,这时另一个线程可能会看到一个“半初始化”的 Singleton对象。

  • 线程 A 执行到 if (singleton == null),判断为 true,进入同步块。
  • 线程 B 执行到 if (singleton == null),判断为 true,进入同步块。

如果线程 A 执行 singleton = new Penguin() 时发生指令重排序:

  • 线程 A 分配内存并设置引用,但尚未调用构造方法完成初始化。
  • 线程 B 此时判断 singleton != null,直接返回这个“半初始化”的对象。

这样就会导致线程 B 拿到一个不完整的 Penguin 对象,可能会出现空指针异常或者其他问题。

于是,我们可以为 singleton 变量添加 volatile 关键字,来禁止指令重排序,确保对象的初始化完成后再将其赋值给 singleton。

synchronized

1,基本用法

  • 加在静态方法上:锁定的是类

  • 加在非静态方法:锁定的是方法的调用者,当前实例。

  • 修饰代码块:锁定的是传入的对象

    并发学习之synchronized,JVM内存图,线程基础知识-CSDN博客

2,可重入性

从互斥锁的设计上来说,当一个线程试图操作一个由其他线程持有的对象锁的临界资源时,将会处于阻塞状态,但当一个线程再次请求自己持有对象锁的临界资源时,这种情况属于重入锁,请求将会成功。

synchronized 就是可重入锁,因此一个线程调用 synchronized 方法的同时,在其方法体内部调用该对象另一个 synchronized 方法是允许的。

3,Java对象头

Java对象在内存中的布局分为三部分:对象头(Header)实例数据(Instance Data)和 对齐填充(Padding)。

对象头是synchronized实现的基础,它包含两部分信息:Mark Word(标记字段)和 Klass Pointer(类型指针,指向对象的类元数据的指针,JVM通过这个指针确定对象是哪个类的实例)。

Mark Word 的格式:

锁状态29 bit 或 61 bit1 bit 是否是偏向锁?2 bit 锁标志位
无锁001
偏向锁线程 ID101
轻量级锁指向栈中锁记录的指针此时这一位不用于标识偏向锁00
重量级锁指向互斥量(重量级锁)的指针此时这一位不用于标识偏向锁10
GC 标记此时这一位不用于标识偏向锁11

synchronized的同步是基于进入和退出Monitor对象实现的,每个Java对象都与一个Monitor相关联。

那什么是Monitor对象

在不同的锁状态下,Mark word会存储不同的信息,这也是为了节约内存常用的设计。当锁状态为重量级锁(锁标识位=10)时,Mark word中会记录指向Monitor对象的指针,这个Monitor对象也称为管程监视器锁

在这里插入图片描述

每个对象都存在着一个 Monitor对象与之关联。执行 monitorenter 指令就是线程试图去获取 Monitor 的所有权,抢到了就是成功获取锁了;执行 monitorexit 指令则是释放了Monitor的所有权。

4,实现原理

JVM规范中对于synchronized的实现分为两种方式:代码块同步和方法同步,它们底层采用了不同的实现策略,但最终都可以归结为对Monitor对象的操作。

monitorenter指令是在编译后插入到同步代码块的开始位置,而monitorexit是插入到方法结束处和异常处,JVM要保证每个monitorenter必须有对应的monitorexit与之配对。任何对象都有一个monitor与之关联,当且一个monitor被持有后,它将处于锁定状态。线程执行到monitorenter指令时,将会尝试获取对象所对应的monitor的所有权,即尝试获得对象的锁。

  1. 不是每个Java对象都有一个物理Monitor对象
    • 只有进入重量级锁状态时才会创建真正的Monitor对象
    • 偏向锁和轻量级锁阶段,锁信息存储在对象头中
  2. Monitor资源由JVM管理
    • Monitor对象不是Java层面的对象
    • 由JVM在需要时创建(通常位于C++层实现)

monitorenter 指令用于获取对象的监视器锁(Monitor lock),主要功能包括:

  1. 锁获取:尝试获取与对象关联的 Monitor
  2. 锁升级:根据竞争情况可能触发锁升级(偏向锁→轻量级锁→重量级锁)
  3. 重入计数:支持同一线程的锁重入
执行 monitorenter 时:
1. 检查对象头中的锁标志位- 如果是无锁状态(01):a. 尝试通过CAS将对象头Mark Word替换为当前线程指针(偏向锁)b. 成功则获取锁,失败则升级为轻量级锁- 如果是轻量级锁(00):a. 检查是否当前线程已持有锁(锁重入)b. 如果是,recursions+1c. 如果不是,自旋尝试获取或升级为重量级锁- 如果是重量级锁(10):a. 进入操作系统的互斥量等待队列
2. 获取成功后,对象头将记录锁状态和持有线程信息

monitorexit 指令用于释放对象的监视器锁,主要功能包括:

  1. 锁释放:释放对 Monitor 的持有
  2. 唤醒线程:在重量级锁状态下唤醒等待线程
  3. 重入处理:减少重入计数,只在完全释放时真正放开锁
执行 monitorexit 时:
1. 检查当前线程是否是锁的持有者- 如果不是,抛出 IllegalMonitorStateException
2. 减少重入计数(recursions)
3. 如果重入计数归零:a. 恢复对象头的无锁状态(轻量级锁)b. 或唤醒 EntryList 中的线程(重量级锁)
4. 如果是同步块结束处的 monitorexit:a. 正常退出同步区域
5. 如果是异常路径的 monitorexit:a. 仍然确保锁被释放b. 重新抛出异常

(1)代码块同步的实现

代码块同步是显式同步,通过monitorentermonitorexit指令实现:

  • 每个monitorenter必须有对应的monitorexit
  • 编译器会为同步块生成异常处理表,确保异常发生时也能释放锁
  • 可以针对任意对象进行同步

(2)方法同步的实现

方法同步是隐式同步,通过在方法访问标志中设置ACC_SYNCHRONIZED标志实现:

  • 调用方法时会隐式获取Monitor,没有显式的monitorentermonitorexit指令

  • 方法正常完成或异常抛出时会隐式释放Monitor

  • 同步的Monitor对象是方法所属的实例(非静态方法)或Class对象(静态方法)

  • JVM在方法调用时自动处理锁的获取和释放

特性monitorenter/monitorexitACC_SYNCHRONIZED
实现级别字节码指令方法访问标志
锁对象显式指定任意对象隐式使用 this 或 Class 对象
异常处理显式生成 monitorexitJVM 自动处理
可观察性可在字节码中直接看到只能通过访问标志识别
优化可能性较难优化更易被 JIT 优化

5,锁的升级与对比

锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。

在这里插入图片描述

  1. 偏向锁:

    • 设计目的:优化只有一个线程访问同步块的场景

    • 实现原理:HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程

    • 升级触发条件

      • 另一个线程尝试获取该锁(产生竞争)
      • 调用 hashCode() 方法(因为偏向锁会占用哈希码位置)
  2. 轻量级锁:

    • 设计目的:优化线程交替执行同步块的场景最适合少量线程(建议≤2个活跃竞争线程)和短时间同步的场景

    • 轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,官方称为Displaced Mark Word。并将对象头中的Mark Word复制到锁记录中。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。

    • 轻量级锁解锁:轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。

    • 升级触发条件

      • CAS 操作失败(表示有竞争|两个线程的CAS操作出现重叠|竞争发生在同一时间窗口)
      • 自旋获取锁超过一定次数
  3. 重量级锁

    依赖于操作系统的互斥量(mutex) 实现的,而操作系统中线程间状态的转换需要相对较长的时间,所以重量级锁效率很低,但被阻塞的线程不会消耗 CPU。

原子操作的实现原理

1,术语

  • 缓存行:缓存行是CPU缓存的最小读写单位,通常为 64字节。三级缓存就是由缓存行组成。

    • L1/L2:每个核心独占,减少多核竞争。
    • L3:多核共享,避免频繁访问内存。
  • CAS:比较并且交换。CAS需要两个值,一个旧值,一个新值。旧值用来比较操作期间是否发生变化,如果没有发生变化才会交换新值。

  • CPU流水线技术

    时间轴  |  指令1   |  指令2   |  指令3   |  指令4   |
    --------+----------+----------+----------+----------+
    Cycle1  |   IF1    |          |          |          |
    Cycle2  |   ID1    |   IF2    |          |          |
    Cycle3  |   EX1    |   ID2    |   IF3    |          |
    Cycle4  |   MEM1   |   EX2    |   ID3    |   IF4    |
    Cycle5  |   WB1    |   MEM2   |   EX3    |   ID4    |
    Cycle6  |          |   WB2    |   MEM3   |   EX4    |
    Cycle7  |          |          |   WB3    |   MEM4   |
    Cycle8  |          |          |          |   WB4    |
    
  • 内存顺序冲突:内存顺序冲突 是由于 CPU/编译器优化导致的 指令重排问题导致的内存访问顺序与程序逻辑顺序不一致,从而引发数据竞争、逻辑错误等问题。

2,如何实现原子操作

  1. 总线锁
  2. 缓存锁:处理器标记该缓存行为 “锁定” 状态,阻止其他核心同时访问。

总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。

有两种情况不适用缓存锁:

  • 操作的数据没有缓存在缓存行中,或者操作数据跨了多个缓存行会使用总线锁
  • 某些处理器不支持

3,Java如何实现原子操作

  • **AtomicInteger**等原子类

  • 使用volatile,synchronized关键字

  • 使用CAS循环实现原子操作

        /** * 使用CAS实现线程安全计数器 */private void safeCount() {for (;;) {int i = atomicI.get();boolean suc = atomicI.compareAndSet(i, ++i);if (suc) {break;}}}AtomicStampedReference<Integer> ref = new AtomicStampedReference<>(100, 0);
    ref.compareAndSet(100, 101, stamp, stamp + 1);  // 检查值和版本号
    
CAS实现原子操作的三大问题
  • ABA问题:CAS在操作值时,如果一个值由A变为B又变为A,那么使用CAS进行检查时会发现它的值没有发生变化,但是实际上却变化了。解决思路是使用版本号,如上AtomicStampedReference
  • 循环时间长CPU开销大,自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。
  • 只能保证一个共享变量的原子操作,如果是对多个共享变量操作时,循环CAS就无法保证操作的原子性,这个时候就可以用锁。当多个线程同时竞争同一变量时,大量 CAS 操作会失败,导致线程自旋(循环重试)。自旋期间线程持续占用 CPU,执行无效循环,消耗 CPU 周期。

假设有1000线程并且这个CPU切换比较快速,其中一个CAS成功了,那剩余的999个就都白计算了,还不如加锁禁止其他线程操作,这样不会造成CPU的剧烈浪费。所以CAS只适合低烈度的并发。

相关文章:

  • 基于Python学习《Head First设计模式》第四章 工厂方法+抽象工厂
  • “等待-通知”机制优化(一次性申请)循环等待
  • HarmonyOS5 仓颉入门:和 ArkTs 互操作
  • 初识vue3(vue简介,环境配置,setup语法糖)
  • RGB888色彩格式转RGB565格式
  • VMware安装Ubuntu全攻略
  • 记忆解码 | 从神经机制到记忆逻辑的科学探索
  • Google机器学习实践指南(TensorFlow六大优化器)
  • Python----目标检测(Ultralytics安装和YOLO-V8快速上手)
  • 基于STM32控制直流电机加减速正反转设计
  • 自适应流量调度用于遥操作:面向时间敏感网络的通信与控制协同优化框架
  • OpenRouter使用指南
  • DeepSeek模型高级应用:提示工程与Few-shot学习实战指南
  • Origin将杂乱的分组散点图升级为美观的带颜色映射的气泡图
  • web架构3------(nginx的return跳转,gzip压缩,目录浏览,访问控制和location符号优先级)
  • 配置刷新技术
  • DAY01:【ML 第三弹】基本概念和建模流程
  • JS手写代码篇---手写类型判断函数
  • Python中三种不同包读取csv文件数据的方式:numpy、pandas、csv
  • Shell
  • 网站联盟三要素/外链怎么发
  • 常德最新确诊人员名单/码迷seo
  • 建程网工程信息是否可靠/湖南网络优化服务
  • 高端网站建设知识/网站制作费用一览表
  • 做电影售票网站的难点/专业网络推广
  • wordpress位置/武汉关键词seo排名