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
:
- 初始状态
X
在主存中的值为0
- Core1和Core2的缓存中均无
X
- Core1 读取 X
- Core1 缓存
X
,状态变为 E (Exclusive) - 直接从主存加载
X=0
- Core1 缓存
- Core2 读取 X
- Core1 的
X
状态降级为 S (Shared) - Core2 也缓存
X
,状态为 S
- Core1 的
- Core1 修改 X=1
- Core1 发送 总线事务,使 Core2 的
X
缓存行失效(状态变为 I) - Core1 的
X
状态变为 M (Modified),并更新缓存值
- Core1 发送 总线事务,使 Core2 的
- 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
变量的读写操作进行重排序。
- 通过 内存屏障(Memory Barrier) 禁止 JVM 和 CPU 对
2.volatile的底层实现
-
内存屏障
-
写操作
- **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)
-
-
缓存一致性协议
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 bit | 1 bit 是否是偏向锁? | 2 bit 锁标志位 |
---|---|---|---|
无锁 | 0 | 01 | |
偏向锁 | 线程 ID | 1 | 01 |
轻量级锁 | 指向栈中锁记录的指针 | 此时这一位不用于标识偏向锁 | 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的所有权,即尝试获得对象的锁。
- 不是每个Java对象都有一个物理Monitor对象
- 只有进入重量级锁状态时才会创建真正的Monitor对象
- 偏向锁和轻量级锁阶段,锁信息存储在对象头中
- Monitor资源由JVM管理
- Monitor对象不是Java层面的对象
- 由JVM在需要时创建(通常位于C++层实现)
monitorenter
指令用于获取对象的监视器锁(Monitor lock),主要功能包括:
- 锁获取:尝试获取与对象关联的 Monitor
- 锁升级:根据竞争情况可能触发锁升级(偏向锁→轻量级锁→重量级锁)
- 重入计数:支持同一线程的锁重入
执行 monitorenter 时:
1. 检查对象头中的锁标志位- 如果是无锁状态(01):a. 尝试通过CAS将对象头Mark Word替换为当前线程指针(偏向锁)b. 成功则获取锁,失败则升级为轻量级锁- 如果是轻量级锁(00):a. 检查是否当前线程已持有锁(锁重入)b. 如果是,recursions+1c. 如果不是,自旋尝试获取或升级为重量级锁- 如果是重量级锁(10):a. 进入操作系统的互斥量等待队列
2. 获取成功后,对象头将记录锁状态和持有线程信息
monitorexit
指令用于释放对象的监视器锁,主要功能包括:
- 锁释放:释放对 Monitor 的持有
- 唤醒线程:在重量级锁状态下唤醒等待线程
- 重入处理:减少重入计数,只在完全释放时真正放开锁
执行 monitorexit 时:
1. 检查当前线程是否是锁的持有者- 如果不是,抛出 IllegalMonitorStateException
2. 减少重入计数(recursions)
3. 如果重入计数归零:a. 恢复对象头的无锁状态(轻量级锁)b. 或唤醒 EntryList 中的线程(重量级锁)
4. 如果是同步块结束处的 monitorexit:a. 正常退出同步区域
5. 如果是异常路径的 monitorexit:a. 仍然确保锁被释放b. 重新抛出异常
(1)代码块同步的实现
代码块同步是显式同步,通过monitorenter
和monitorexit
指令实现:
- 每个
monitorenter
必须有对应的monitorexit
- 编译器会为同步块生成异常处理表,确保异常发生时也能释放锁
- 可以针对任意对象进行同步
(2)方法同步的实现
方法同步是隐式同步,通过在方法访问标志中设置ACC_SYNCHRONIZED
标志实现:
-
调用方法时会隐式获取Monitor,没有显式的
monitorenter
和monitorexit
指令 -
方法正常完成或异常抛出时会隐式释放Monitor
-
同步的Monitor对象是方法所属的实例(非静态方法)或Class对象(静态方法)
-
JVM在方法调用时自动处理锁的获取和释放
特性 | monitorenter/monitorexit | ACC_SYNCHRONIZED |
---|---|---|
实现级别 | 字节码指令 | 方法访问标志 |
锁对象 | 显式指定任意对象 | 隐式使用 this 或 Class 对象 |
异常处理 | 显式生成 monitorexit | JVM 自动处理 |
可观察性 | 可在字节码中直接看到 | 只能通过访问标志识别 |
优化可能性 | 较难优化 | 更易被 JIT 优化 |
5,锁的升级与对比
锁可以升级但不能降级,意味着偏向锁升级成轻量级锁后不能降级成偏向锁。这种锁升级却不能降级的策略,目的是为了提高获得锁和释放锁的效率。
-
偏向锁:
-
设计目的:优化只有一个线程访问同步块的场景
-
实现原理:HotSpot 的作者经过研究发现,大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID,以后该线程在进入和退出同步块时不需要进行CAS操作来加锁和解锁,只需简单地测试一下对象头的Mark Word里是否存储着指向当前线程的偏向锁。如果测试成功,表示线程已经获得了锁。如果测试失败,则需要再测试一下Mark Word中偏向锁的标识是否设置成1(表示当前是偏向锁):如果没有设置,则使用CAS竞争锁;如果设置了,则尝试使用CAS将对象头的偏向锁指向当前线程。
-
升级触发条件:
- 另一个线程尝试获取该锁(产生竞争)
- 调用
hashCode()
方法(因为偏向锁会占用哈希码位置)
-
-
轻量级锁:
-
设计目的:优化线程交替执行同步块的场景最适合少量线程(建议≤2个活跃竞争线程)和短时间同步的场景
-
轻量级锁加锁:线程在执行同步块之前,JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,官方称为Displaced Mark Word。并将对象头中的Mark Word复制到锁记录中。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
-
轻量级锁解锁:轻量级解锁时,会使用原子的CAS操作将Displaced Mark Word替换回到对象头,如果成功,则表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
-
升级触发条件:
- CAS 操作失败(表示有竞争|两个线程的CAS操作出现重叠|竞争发生在同一时间窗口)
- 自旋获取锁超过一定次数
-
-
重量级锁
依赖于操作系统的互斥量(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,如何实现原子操作
- 总线锁
- 缓存锁:处理器标记该缓存行为 “锁定” 状态,阻止其他核心同时访问。
总线锁定把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只适合低烈度的并发。