
00:00:00
外卖卷:非常划算 扫码领劵 省点小钱钱
volatile 是 Java 虚拟机提供的一种轻量级的同步机制,它主要解决了 JMM(Java Memory Model)中的三大特性问题:
volatile 变量的值,这个新值会立即被刷新到主内存中;当其他线程读取这个 volatile 变量时,它们会强制从主内存中获取最新值,而不是使用自己工作内存中的旧副本。volatile 变量的读写操作本身是原子的(单个读或单个写),但复合操作(如 i++,它包含 读取 i、i 加 1、写入 i 三个步骤)不保证原子性。在复合操作中,仍然可能存在竞态条件。volatile 关键字可以禁止 JVM 编译器和 CPU 对其修饰的变量进行指令重排序。它通过插入内存屏障来确保特定操作的执行顺序。性能对比:
volatile 变量进行读操作,与普通变量的读取性能差异不大。volatile 变量进行写操作,会相对慢一些,因为 JVM 需要插入内存屏障,这会增加一些开销。然而,这种开销通常比使用 synchronized 或 Lock 来加锁的开销要小得多。synchronized 为什么能保证有序性和可见性?
synchronized 块内的代码,由于在任何时刻只有一个线程能获取锁并执行,因此在逻辑上形成了单线程执行的错觉。对于单线程,指令重排序会确保不改变程序的最终执行结果。所以,synchronized 隐式地保证了其保护的代码块的有序性。synchronized 确保了共享变量在加锁和解锁时的可见性。指令重排是编译器和处理器为了优化程序性能,在不改变单线程程序执行结果的前提下,调整指令执行顺序的一种行为。
volatile 修饰的变量,可以禁用针对该变量的指令重排。
指令重排示例 1 (单线程):
public void mySort() {
int x = 11; // 语句 1
int y = 12; // 语句 2
x = x + 5; // 语句 3 (依赖于 x)
y = x - x; // 语句 4 (依赖于 x)
}1 -> 2 -> 3 -> 4 或 2 -> 1 -> 3 -> 4。4 -> 3 -> 2 -> 1,因为语句 3 依赖语句 1 的 x 初始化,语句 4 依赖语句 3 的 x 更新。这种依赖关系会阻止重排。指令重排示例 2 (多线程与可见性):
int num = 0;
boolean ready = false;
// 线程 1 执行此方法(消费者)
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
// 线程 2 执行此方法(生产者)
public void actor2(I_Result r) {
num = 2; // 写入 num (语句 A)
ready = true; // 写入 ready (语句 B)
}ready = false,结果 r.r1 = 1。num = 2,但还没来得及执行 ready = true,线程 1 执行,ready 仍为 false,结果 r.r1 = 1。num = 2 和 ready = true (且都同步到主内存),线程 1 执行,进入 if 分支,结果 r.r1 = 4。ready = true (语句 B) 先于 num = 2 (语句 A) 或者 num 的写入未同步。此时线程 1 看到 ready 为 true,但 num 仍为旧值 0,结果 r.r1 = 0。这就是指令重排和可见性问题导致的错误。使用 volatile 修饰的共享变量,JVM 会在底层通过插入特定的内存屏障 (Memory Barrier / Memory Fence) 来实现其语义。
volatile 变量时: sfence)。volatile 变量)都立即刷新到主内存中。volatile 变量时: lfence)。内存屏障的三个主要作用:
保证可见性:
写屏障 (sfence):好比“提交保存”,在它之前的所有改动,都必须立刻“同步”到主内存。
public void actor2(I_Result r) {
num = 2; // 1. 修改普通变量
ready = true; // 2. 修改 volatile 变量 -> 这里会插入【写屏障】
// ------ 写屏障 (sfence) ------
}ready = true,后执行 num = 2(指令重排)。或者 num = 2 只停留在 CPU 的高速缓存里,没进主内存。num = 2 必须先于 ready = true 完成,并且在 ready 写入的那一刻,num 的最新值也会被强制刷新到主内存。读屏障 (lfence):好比“刷新页面”,在它之后的所有读取,都必须去主内存拿“最新版”,而不是用自己手里的旧缓存。
public void actor1(I_Result r) {
// ------ 读屏障 (lfence) ------
// 当代码执行到 volatile 变量 ready 的读取时,会触发读屏障
if(ready) {
r.r1 = num + num; // 这里的 num 保证是从主内存读到的最新值 2
} else {
r.r1 = 1;
}
}actor1 可能觉得自己的缓存里 num 还是 0,直接就用了。ready 之后,CPU 必须强制让本地缓存失效,去主内存重新加载 num 的值。保证有序性:
不保证原子性:
volatile 变量的写屏障只保证它之后的读操作能读到最新结果,但不能防止其他线程的写操作跑到写屏障之前。volatile 变量相关的代码不被重排序。volatile 无法保证复合操作的原子性。例如 i++:volatile int i = 0;
// 线程 A: i++;
// 线程 B: i++;i++ 对应的字节码(或汇编指令)至少包含三个步骤:
getfield i:从主内存读取 i 的值到工作内存(由 volatile 保证可见性)。iadd:在工作内存中将 i 的值加 1。putfield i:将工作内存中修改后的 i 的值写回主内存(由 volatile 保证可见性和写屏障)。如果两个线程同时执行 i++:
i (0),加 1 得到 1。i (0,因为 A 的修改可能还没写回主内存,或者 A 刚读完还没写回),加 1 得到 1。最终 i 的值是 1,而不是期望的 2。这就是因为 volatile 无法确保复合操作的原子性。
对于 volatile 修饰的变量,JMM 规定了更严格的内存交互规则:
读操作规则:强制“实时拉取”
对于 volatile 变量,JMM 规定 use 操作必须与 load 和 read 捆绑出现。
read(读取主存) load(载入工作内存) use(交给 CPU 指令)。写操作规则:强制“即时推送”
对于 volatile 变量,JMM 规定 assign 操作必须与 store 和 write 连续执行。
assign(变量赋值) store(存储到工作内存) write(写回主存)。Double-Checked Locking:双端检锁机制
DCL 是一种用于实现单例模式的优化手段,旨在兼顾懒惰初始化和高并发性能。
public final class Singleton {
private Singleton() { } // 私有构造器
private static Singleton INSTANCE = null; // 单例实例
public static Singleton getInstance() {
// 第一次检查 (无需同步,性能高)
if(INSTANCE == null) {
// 首次访问时才进入同步块
synchronized(Singleton.class) { // 对类对象加锁,确保只有一个线程能初始化
// 第二次检查 (在同步块内再次检查,防止多线程排队等待时重复初始化)
if (INSTANCE == null) {
INSTANCE = new Singleton(); // 实例化单例
}
}
}
return INSTANCE;
}
}实现特点:
getInstance() 时才创建单例实例。getInstance() 的调用都会直接通过第一个 if 检查返回 INSTANCE,无需进入同步块,性能很高。volatile 修饰的 DCL 在多线程环境下不一定是线程安全的,其根本原因在于指令重排。为什么不对 INSTANCE 加锁?
INSTANCE 在初始化之前是 null,而 synchronized 需要一个非 null 的对象引用作为锁对象。INSTANCE 被赋值了,如果用 INSTANCE 做锁,那么在 INSTANCE 重新赋值时,锁对象会变化,可能导致问题。INSTANCE = new Singleton(); 这行代码看起来是一个简单的操作,但在 JVM 层面,它至少可以分解为以下三个步骤:
memory = allocate():为 Singleton 对象分配内存空间。ctorInstance(memory):调用 Singleton 的构造方法,初始化对象(填充字段)。instance = memory:将 instance 变量指向刚刚分配并初始化的内存地址。1 -> 3 -> 2。instance 指向这块内存(3),但此时对象尚未完成初始化(构造方法还没执行完)。1 -> 3,此时 INSTANCE 已经不为 null,但对象还未完全初始化。if (INSTANCE == null) 检查,发现 INSTANCE 不为 null,于是直接返回 INSTANCE。INSTANCE 是一个半初始化对象,当它尝试使用这个对象时,可能会访问到未初始化的字段,导致程序出错(空指针异常或其他逻辑错误)。指令重排只会保证串行语义的执行一致性(即单线程内执行结果不变),但并不会关心多线程间的语义一致性。
为了解决 DCL 的指令重排问题,需要引入 volatile 关键字:
public final class SingletonDemo { // [修正] 类名规范化
private SingletonDemo() { }
// 使用 volatile 修饰 INSTANCE,确保写入 INSTANCE 后立即刷新到主内存,
// 并禁止 INSTANCE = new SingletonDemo(); 这行代码发生指令重排。
private static volatile SingletonDemo INSTANCE = null;
public static SingletonDemo getInstance() {
if(INSTANCE == null) {
synchronized(SingletonDemo.class) {
if (INSTANCE == null) {
INSTANCE = new SingletonDemo(); // 这一行会因 volatile 获得内存屏障保护
}
}
}
return INSTANCE;
}
}volatile 的作用:
INSTANCE = new SingletonDemo(); 发生写操作时,volatile 会插入写屏障,确保在将 INSTANCE 指向内存地址之前,SingletonDemo 对象已经完全初始化完毕。volatile 保证了 INSTANCE 的可见性,即一旦 INSTANCE 被赋值,其他线程就能立即看到并读取到这个完全初始化的最新值。通过这种方式,volatile 关键字彻底解决了 DCL 在多线程环境下的线程安全问题。
评论