同步机制
更新: 1/25/2026 字数: 0 字 时长: 0 分钟
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)
}- 情况一:线程 1 先执行,
ready = false,结果r.r1 = 1。 - 情况二:线程 2 先执行
num = 2,但还没来得及执行ready = true,线程 1 执行,ready仍为false,结果r.r1 = 1。 - 情况三:线程 2 先执行
num = 2和ready = true(且都同步到主内存),线程 1 执行,进入if分支,结果r.r1 = 4。 - 情况四:线程 2 执行
ready = true(语句 B) 先于num = 2(语句 A) 或者num的写入未同步。此时线程 1 看到ready为true,但num仍为旧值0,结果r.r1 = 0。这就是指令重排和可见性问题导致的错误。
底层原理
缓存一致
使用 volatile 修饰的共享变量,JVM 会在底层通过插入特定的内存屏障 (Memory Barrier / Memory Fence) 来实现其语义。
- 写操作:当一个线程修改
volatile变量时:- JVM 会在该写指令后插入一个写屏障 (Store Barrier /
sfence)。 - 写屏障会强制把当前 CPU 缓存中所有已修改的数据(包括这个
volatile变量)都立即刷新到主内存中。 - 同时,这个操作会通过总线广播一个消息,使得其他 CPU 核心中所有对应的缓存行都失效。
- JVM 会在该写指令后插入一个写屏障 (Store Barrier /
- 读操作:当一个线程读取
volatile变量时:- JVM 会在该读指令前插入一个读屏障 (Load Barrier /
lfence)。 - 读屏障会强制当前 CPU 废弃自己缓存中对应的旧数据,并重新从主内存中加载最新数据。
- JVM 会在该读指令前插入一个读屏障 (Load Barrier /
内存屏障的三个主要作用:
- 确保可见性:强制将修改写入主内存,并使其他缓存失效,保证读取最新值。
- 阻止指令重排序:内存屏障会像一道“栅栏”,确保屏障前后的指令不会跨越屏障进行重排序。
- JMM 层面:强制把缓存中的脏数据写回主内存,让缓存行中相应的数据失效(这是实现可见性的机制)。
内存屏障
保证可见性:
写屏障 (
sfence):好比“提交保存”,在它之前的所有改动,都必须立刻“同步”到主内存。javapublic void actor2(I_Result r) { num = 2; // 1. 修改普通变量 ready = true; // 2. 修改 volatile 变量 -> 这里会插入【写屏障】 // ------ 写屏障 (sfence) ------ }- 如果没有屏障:CPU 为了快,可能会先执行
ready = true,后执行num = 2(指令重排)。或者num = 2只停留在 CPU 的高速缓存里,没进主内存。 - 有了屏障:它像一道墙。它保证
num = 2必须先于ready = true完成,并且在ready写入的那一刻,num的最新值也会被强制刷新到主内存。
- 如果没有屏障:CPU 为了快,可能会先执行
读屏障 (
lfence):好比“刷新页面”,在它之后的所有读取,都必须去主内存拿“最新版”,而不是用自己手里的旧缓存。javapublic 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++:
- 线程 A 读取
i(0),加 1 得到 1。 - 上下文切换。
- 线程 B 读取
i(0,因为 A 的修改可能还没写回主内存,或者 A 刚读完还没写回),加 1 得到 1。 - 线程 B 将 1 写回主内存。
- 线程 A 将 1 写回主内存。
最终 i 的值是 1,而不是期望的 2。这就是因为 volatile 无法确保复合操作的原子性。
交互规则
对于 volatile 修饰的变量,JMM 规定了更严格的内存交互规则:
读操作规则:强制“实时拉取”
对于
volatile变量,JMM 规定use操作必须与load和read捆绑出现。- 动作序列:
read(读取主存)load(载入工作内存)use(交给 CPU 指令)。 - 规则限制:线程不允许直接使用工作内存中缓存的旧值。
- 直观理解:这相当于在每次使用变量前,都强行点了一次“网页刷新”。即使你手里有副本,也必须去服务器重新下载最新版。
- 动作序列:
写操作规则:强制“即时推送”
对于
volatile变量,JMM 规定assign操作必须与store和write连续执行。- 动作序列:
assign(变量赋值)store(存储到工作内存)write(写回主存)。 - 规则限制:线程不允许将赋值后的结果暂存在缓存中。
- 直观理解:这相当于点击“保存”后,系统自动触发了“全服同步”。你的修改动作还没结束,主存里的值就已经变了。
- 动作序列:
双端检锁
DCL 机制
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重新赋值时,锁对象会变化,可能导致问题。
DCL 问题
INSTANCE = new Singleton(); 这行代码看起来是一个简单的操作,但在 JVM 层面,它至少可以分解为以下三个步骤:
memory = allocate():为Singleton对象分配内存空间。ctorInstance(memory):调用Singleton的构造方法,初始化对象(填充字段)。instance = memory:将instance变量指向刚刚分配并初始化的内存地址。
- 指令重排问题:JVM 允许这三个步骤发生指令重排,例如,执行顺序可能是
1 -> 3 -> 2。 - 重排后的顺序:先分配内存(1),然后将
instance指向这块内存(3),但此时对象尚未完成初始化(构造方法还没执行完)。 - 多线程问题:
- 线程 A 执行
1 -> 3,此时INSTANCE已经不为null,但对象还未完全初始化。 - 线程 A 暂时离开 CPU 调度,线程 B 进来。
- 线程 B 第一次
if (INSTANCE == null)检查,发现INSTANCE不为null,于是直接返回INSTANCE。 - 线程 B 拿到的
INSTANCE是一个半初始化对象,当它尝试使用这个对象时,可能会访问到未初始化的字段,导致程序出错(空指针异常或其他逻辑错误)。
- 线程 A 执行
解决方法
指令重排只会保证串行语义的执行一致性(即单线程内执行结果不变),但并不会关心多线程间的语义一致性。
为了解决 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 在多线程环境下的线程安全问题。
