JUC并发编程10 - 内存(02) - volatile
同步机制
volatile 是 Java 虚拟机提供的轻量级的同步机制(三大特性)
- 保证可见性
- 不保证原子性
- 保证有序性(禁止指令重排)
性能:volatile 修饰的变量进行读操作与普通变量几乎没什么差别,但是写操作相对慢一些,因为需要在本地代码中插入很多内存屏障来保证指令不会发生乱序执行,但是开销比锁要小
synchronized 无法禁止指令重排和处理器优化,为什么可以保证有序性可见性
- 加了锁之后,只能有一个线程获得到了锁,获得不到锁的线程就要阻塞,所以同一时间只有一个线程执行,相当于单线程,由于数据依赖性的存在,单线程的指令重排是没有问题的
- 线程加锁前,将清空工作内存中共享变量的值,使用共享变量时需要从主内存中重新读取最新的值;线程解锁前,必须把共享变量的最新值刷新到主内存中(JMM 内存交互章节有讲)
volatile 是干啥的?
volatile
是 Java 提供的一种轻量级的同步机制,它不能代替锁,但能在某些场景下,让你的变量“更安全地被多线程访问”。
volatile 能保证“可见性”——大家看到的数据是一样的
什么是可见性?
在多线程环境下,每个线程都有自己的“工作内存”(可以理解为高速缓存),而变量的“主内存”是共享的。问题来了:如果线程 A 修改了一个变量,它可能只改了自己工作内存里的副本,还没来得及写回主内存。这时线程 B 去读这个变量,读到的还是旧值——这就叫不可见。
volatile 如何解决?
加了 volatile
的变量,一旦被修改,立刻写回主内存;其他线程读的时候,也必须从主内存重新读,不能用本地缓存。相当于:谁改了,马上广播通知所有人“我改了!你们别用旧的了!”
生活例子:微信群改时间
想象你在组织一个微信群会议:
- 没加 volatile:你私聊小王说“改到3点”,但没发群消息。其他人还以为是2点。
- 加了 volatile:你一改时间,就立刻发群公告:“所有人注意!时间改成3点了!” → 大家都看到了最新时间。
代码 Demo:不用 volatile 的问题
public class VolatileDemo {// 没有 volatileprivate static boolean running = true;public static void main(String[] args) throws InterruptedException {new Thread(() -> {System.out.println("子线程开始运行...");while (running) {// 空转}System.out.println("子线程结束");}).start();Thread.sleep(1000);running = false; // 主线程修改System.out.println("主线程已设置 running = false");}
}
子线程可能永远不退出!因为它一直在用自己的缓存里读 running = true
,不知道主线程已经改成了 false
。
private static volatile boolean running = true; // 加了 volatile
加上 volatile 就好了。现在主线程一改 running = false
,子线程马上就能“看到”,循环结束,程序正常退出。这就是 可见性 的体现。
volatile 不保证“原子性”——不能当锁用
一个操作要么全部完成,要么完全不执行,中间不能被打断。这就是原子性。
public class VolatileNotAtomic {private static volatile int count = 0;public static void increment() {count++; // 不是原子操作!}public static void main(String[] args) throws InterruptedException {Thread[] threads = new Thread[10];for (int i = 0; i < 10; i++) {threads[i] = new Thread(() -> {for (int j = 0; j < 1000; j++) {increment();}});threads[i].start();}for (Thread thread : threads) {thread.join();}System.out.println("最终结果:" + count); // 很可能 < 10000}
}
即使 count
是 volatile
,最终结果也很可能不到 10000,因为 count++
被多个线程打断了。正确做法:用 synchronized
或 AtomicInteger。
volatile 保证“有序性”——禁止指令重排
什么是指令重排?
为了提高性能,JVM 和 CPU 会自动调整代码执行顺序(只要单线程结果不变)。
比如:
int a = 1;
int b = 2;
int c = a + b;
可以重排成:
int b = 2;
int a = 1;
int c = a + b;
单线程没问题。但在多线程中,重排可能导致严重 bug!
经典例子:双重检查单例模式(DCL)
public class Singleton {private static volatile Singleton instance;public static Singleton getInstance() {if (instance == null) {synchronized (Singleton.class) {if (instance == null) {instance = new Singleton(); // 问题出在这里!}}}return instance;}
}
问题来了。new Singleton()
其实分三步:
- 分配内存空间
- 调用构造函数初始化对象
- 把 instance 指向这个对象
没加 volatile 时,可能被重排成:1 → 3 → 2。也就是说:instance
已经不为 null 了,但对象还没初始化完!这时如果有另一个线程进来,发现 instance != null
,直接返回一个半成品对象,程序就会崩溃。加了 volatile
后,禁止了重排,必须按 1 → 2 → 3 执行,安全!所以 volatile
的有序性,在 DCL 单例中至关重要!
synchronized 为什么也能保证可见性和有序性?
synchronized
不是不能禁止指令重排吗?为啥它也能保证线程安全?答案是:它不需要禁止重排,因为它用的是“独占锁”的思路。synchronized 的三大保障:
1.原子性:同一时间只有一个线程能进 synchronized 块。
2.可见性:加锁前:清空工作内存,强制从主内存读最新值。解锁前:必须把修改刷回主内存。相当于“进门先刷新,出门再保存”。
3.有序性:虽然 synchronized 块内的代码仍可能被重排,但由于同一时间只有一个线程执行,相当于“单线程环境”。而单线程中,只要保证数据依赖正确(比如 a=1; b=a; 不能颠倒),重排是安全的。
所以:synchronized
是靠“同一时间只让一个人干活”来间接保证有序和可见,而不是靠禁止重排。
指令重排
什么是指令重排?
指令重排就是:你写的代码,Java 和 CPU 为了让你的程序跑得更快,偷偷调整了执行顺序。
但前提是:在单线程下,结果不能变。可一旦到了多线程,这个“优化”就可能出大问题。
生活比喻:做煎饼果子
你点了个煎饼果子,老板要按顺序做:
- 打鸡蛋
- 涂酱
- 加生菜
- 加火腿
这四步有依赖关系,比如必须先打鸡蛋才能摊开,所以不能乱来。但有些步骤可以调换,比如:涂酱 和 加生菜,谁先谁后都行,不影响最终煎饼。这就像代码里的“无依赖语句”,JVM 和 CPU 会“重排”它们,让效率更高。但如果老板把“加火腿”放到“打鸡蛋”前面,那你就吃到一个没蛋的煎饼了,这就出问题了。
public void mySort() {int x = 11; // 语句1int y = 12; // 语句2x = x + 5; // 语句3y = x * x; // 语句4
}
可以重排的顺序:
1 → 2 → 3 → 4
2 → 1 → 3 → 4
(1 和 2 没依赖,谁先都行)1 → 3 → 2 → 4
(x 的操作先做完也行)
不可能重排成:4 → 3 → 2 → 1
为啥?因为:
- 语句4 要用到
x
和y
- 但
x
、y
都还没定义! - 这叫数据依赖,不能乱来。
结论:指令重排不会破坏“数据依赖”,但会调整“无关语句”的顺序,目的是提升性能。
int num = 0;
boolean ready = false;// 线程1:读数据
public void actor1(I_Result r) {if (ready) {r.r1 = num + num; // 如果 ready 为 true,就用 num} else {r.r1 = 1;}
}// 线程2:改数据
public void actor2(I_Result r) {num = 2;ready = true;
}
我们希望的是:
- 要么没准备好,返回 1;
- 要么准备好了,返回
2+2=4
。
但!如果线程2中发生指令重排,把这两行调换了顺序:
ready = true; // 先设为 true
num = 2; // 后赋值
会发生什么?
情况四(最危险):
- 线程2 执行:
ready = true
(但num
还是 0) - 切到 线程1:发现
ready == true
,进入if
分支 - 执行
r.r1 = num + num
→ 此时num
还是 0!所以结果是 0 - 再切回 线程2:执行
num = 2
最终结果:r.r1 = 0
—— 出 bug 了!明明 ready
都为 true 了,num
却还是旧值!
怎么办?用 volatile 禁止重排!
volatile boolean ready = false;
就相当于告诉 JVM 和 CPU:这个变量很关键!不准对它前面或后面的代码随便重排!具体来说,volatile
会插入一种叫内存屏障的东西,像一堵墙,挡住重排。
num = 2;
// 内存屏障 ← volatile 插入的“墙”
ready = true;
这堵墙保证:
num = 2
一定在ready = true
之前执行;- 其他线程一旦看到
ready == true
,就一定能读到num = 2
的最新值。
这样就避免了“看到标志位变了,但数据还是旧的”这种诡异问题。
底层原理
缓存一致
使用 volatile 修饰的共享变量,底层通过汇编 lock 前缀指令进行缓存锁定,在线程修改完共享变量后写回主存,其他的 CPU 核心上运行的线程通过 CPU 总线嗅探机制会修改其共享变量为失效状态,读取时会重新从主内存中读取最新的数据
lock 前缀指令就相当于内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障
- 对 volatile 变量的读指令前会加入读屏障
内存屏障有三个作用:
- 确保对内存的读-改-写操作原子执行
- 阻止屏障两侧的指令重排序
- 强制把缓存中的脏数据写回主内存,让缓存行中相应的数据失效
volatile
能让多个线程看到“最新的值”,靠的是:CPU 的“嗅探机制” + 内存屏障 + 强制刷缓存,它通过一条特殊的汇编指令 lock
,像发“广播”一样告诉其他 CPU:“我改数据了,你们的缓存过期了!”
现代 CPU 是怎么工作的?
现在的电脑都有多个 CPU 核心(比如 4 核、8 核),每个核心都有自己的高速缓存(Cache),就像每个人的“小本本”,记着自己常用的变量。而所有核心共享一个主内存(Main Memory),就像一个“公共大黑板”。
问题来了:
- 线程 A 在核心1上运行,把
x = 5
写进了自己的缓存; - 线程 B 在核心2上运行,它读的还是自己缓存里的
x = 0
; - 两人看到的
x
值不一样!——这就是缓存不一致。
CPU 是怎么解决“缓存不一致”的?—— 嗅探机制
生活比喻:微信群接龙
想象你们班有个“值日安排表”贴在教室墙上(主内存)。每个人记了个小抄(CPU 缓存)。有一天小明改了值日表:“我明天值日”,然后喊了一嗓子:“我改了!大家别看旧的小抄了!”其他人听到后,就把自己的小抄划掉,下次去看墙上的新表。这就是“嗅探机制”:有 CPU 核心都“监听”总线(相当于微信群),一旦发现有人改了某个变量,就自动把自己缓存里的副本标记为“失效”。下次读这个变量时,就不能用缓存了,必须去主内存重新读最新值。
volatile 是怎么触发这个机制的?—— lock 前缀指令
Java 里的 volatile
变量,编译后会生成一条特殊的汇编指令:lock
。
volatile int x = 0;
x = 10; // 写 volatile 变量
底层会变成类似这样的汇编:
lock mov [x], 10
这个 lock
指令干了三件事:
作用 | 解释 |
---|---|
强制写回主内存 | 把修改后的值立刻刷到主内存,不让它只待在缓存里 |
触发总线嗅探 | 其他 CPU 核心收到通知:“x 被改了!” |
让其他核心的缓存行失效 | 其他核心把自己缓存中的 x 标记为“过期”,不能再用 |
内存屏障
保证可见性:
写屏障(sfence,Store Barrier)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
public void actor2(I_Result r) {num = 2;ready = true; // ready 是 volatile 赋值带写屏障// 写屏障 }
- 读屏障(lfence,Load Barrier)保证在该屏障之后的,对共享变量的读取,从主存刷新变量值,加载的是主存中最新数据
public void actor1(I_Result r) {// 读屏障// ready 是 volatile 读取值带读屏障if(ready) {r.r1 = num + num;} else {r.r1 = 1;} }
全能屏障:mfence(modify/mix Barrier),兼具 sfence 和 lfence 的功能
保证有序性:
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
不能解决指令交错:
写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其他线程的读跑到写屏障之前
有序性的保证也只是保证了本线程内相关代码不被重排序
volatile i = 0; new Thread(() -> {i++}); new Thread(() -> {i--});
i++ 反编译后的指令:
0: iconst_1 // 当int取值 -1~5 时,JVM采用iconst指令将常量压入栈中 1: istore_1 // 将操作数栈顶数据弹出,存入局部变量表的 slot 1 2: iinc 1, 1
内存屏障就像是“施工警戒线”:它不让代码“跨线重排”(保证有序性),它要求“改完必须存档,看之前必须刷新”(保证可见性);但它不管两个人同时改同一个文件会不会打架(不解决原子性、指令交错)。
内存屏障的三种类型(SFENCE、LFENCE、MFENCE)
我们可以把它们想象成三种“警戒线”:
类型 | 名字 | 作用 | 生活比喻 |
---|---|---|---|
sfence | 写屏障(Store Barrier) | 确保“这条线前面的修改”都写回主内存 | “改完文件,必须先保存!” |
lfence | 读屏障(Load Barrier) | 确保“这条线后面的读取”都从主内存刷新 | “看文件前,必须先拉最新版!” |
mfence | 全能屏障 | 同时有上面两个功能 | “又保存又拉最新” |
写屏障(sfence):改完必须“存档”
public void actor2() {num = 2; // 普通变量赋值ready = true; // volatile 变量 → 这里自动加了写屏障//【写屏障】在这里
}
写屏障的作用是:在 ready = true
之前的所有写操作(比如 num = 2
),必须在这条线之前完成,并且刷新到主内存。
相当于:
- 你改完
num = 2
- 必须先“保存文件”
- 然后才能执行
ready = true
这样其他线程一旦看到 ready == true
,就知道:num
肯定已经改好了。
读屏障(lfence):读之前必须“拉最新”
public void actor1(I_Result r) {//【读屏障】在这里(在读 ready 之前)if (ready) { // ready 是 volatile,读它会触发读屏障r.r1 = num + num; // 此时 num 是最新的} else {r.r1 = 1;}
}
在读 ready
之前,必须先从主内存把所有共享变量的最新值‘拉下来’,不能用本地缓存的旧数据。
相当于:
- 你要看“项目是否启动”(
ready
) - 必须先
git pull
拉最新代码 - 然后再判断,这样就不会用过期的
num
全能屏障 mfence:又保存又拉最新
有些平台用 mfence
来实现更严格的同步,相当于:
// 【mfence】
// 1. 先把前面所有修改刷回主内存(sfence)
// 2. 再把后面要用的数据从主内存刷新(lfence)
不过在 Java 的 volatile
中,通常是组合使用 sfence
和 lfence
,不一定直接用 mfence
。
内存屏障如何保证“有序性”?—— 挡住重排
JVM 和 CPU 为了性能,会把代码顺序调来调去,只要单线程结果不变。但有了内存屏障,就像加了“不可跨越的墙”:
- 写屏障:它前面的代码,不能跳到它后面去
- 读屏障:它后面的代码,不能跳到它前面去
例子:屏障挡住重排
num = 2;
ready = true;
如果没有屏障,可能重排成:
ready = true;
num = 2; // 危险!其他线程看到 ready 为 true,但 num 还是 0
但因为 ready = true
是 volatile
,编译后加了写屏障:
num = 2;
// 写屏障(sfence)
// JVM/CPU 保证:num=2 一定在这之前完成
ready = true;
所以 num = 2
绝对不会被排到 ready = true
后面。同理,读的时候:
// 读屏障(lfence)
if (ready) {r.r1 = num + num; // 这行不会被提到屏障前面
}
所以 num + num
不会提前执行(那时可能 ready
还没读呢)!
内存屏障不能解决“指令交错”—— 因为它不锁线程
内存屏障只保证“本线程内的代码顺序和数据可见性”,但它不阻止其他线程并发执行!
例子:两个线程同时改 i++
volatile int i = 0;new Thread(() -> {i++; // 反编译后是多条指令
}).start();new Thread(() -> {i--;
}).start();
我们来看看 i++
到底发生了什么(反编译字节码):
0: iconst_1 // 把常量 1 压入操作数栈
1: iload_1 // 把 i 的当前值加载到栈顶
2: iadd // 栈顶两个数相加(i + 1)
3: istore_1 // 把结果存回 i
所以 i++
其实是4条指令,不是原子的!
问题来了:指令交错 假设两个线程同时执行:
时间 | 线程A(i++) | 线程B(i--) |
---|---|---|
1 | iload_1 → i=0 | |
2 | iconst_1 | iload_1 → i=0 |
3 | iadd → 0+1=1 | iconst_m1 (-1) |
4 | istore_1 → i=1 | iadd → 0+(-1)=-1 |
5 | istore_1 → i=-1 |
最终结果:i = -1
,但正确应该是 0。
虽然 i
是 volatile
,也有内存屏障,但挡不住两个线程的指令交错执行。为什么屏障没用?因为:
- 每个线程自己的
i++
和i--
都有各自的读写屏障; - 但它们是独立执行的,屏障只管“自己线程内的顺序”,不管“别人会不会插队”;
- 所以还是会出现中间状态被覆盖的问题。
正确做法:用 synchronized
或 AtomicInteger
:
AtomicInteger i = new AtomicInteger(0);new Thread(() -> i.incrementAndGet()).start();
new Thread(() -> i.decrementAndGet()).start();
交互规则
对于 volatile 修饰的变量:
- 线程对变量的 use 与 load、read 操作是相关联的,所以变量使用前必须先从主存加载
- 线程对变量的 assign 与 store、write 操作是相关联的,所以变量使用后必须同步至主存
- 线程 1 和线程 2 谁先对变量执行 read 操作,就会先进行 write 操作,防止指令重排
双端检锁
检测机制
Double-Checked Locking:双端检锁机制
DCL(双端检锁)机制不一定是线程安全的,原因是有指令重排的存在,加入 volatile 可以禁止指令重排
public final class Singleton {private Singleton() { }private static Singleton INSTANCE = null;public static Singleton getInstance() {if(INSTANCE == null) { // t2,这里的判断不是线程安全的// 首次访问会同步,而之后的使用没有 synchronizedsynchronized(Singleton.class) {// 这里是线程安全的判断,防止其他线程在当前线程等待锁的期间完成了初始化if (INSTANCE == null) { INSTANCE = new Singleton();}}}return INSTANCE;} }
不锁 INSTANCE 的原因:
- INSTANCE 要重新赋值
- INSTANCE 是 null,线程加锁之前需要获取对象的引用,设置对象头,null 没有引用
实现特点:
- 懒惰初始化
- 首次使用 getInstance() 才使用 synchronized 加锁,后续使用时无需加锁
- 第一个 if 使用了 INSTANCE 变量,是在同步块之外,但在多线程环境下会产生问题
我们想实现一个“懒加载的单例模式”:
- 要求1:只创建一次对象(线程安全)
- 要求2:第一次用才创建(延迟初始化)
- 要求3:之后访问不加锁(高性能)
于是有了 双检锁(DCL),顾名思义:两次检查,一次加锁。
public class Singleton {private Singleton() { }// 不加 volatile 的版本(有问题!)private static Singleton INSTANCE = null;public static Singleton getInstance() {// 第一次检查(无锁)——避免每次调用都进同步块if (INSTANCE == null) {// 首次访问才进锁synchronized (Singleton.class) {// 第二次检查(有锁)——防止多个线程同时创建if (INSTANCE == null) {INSTANCE = new Singleton();}}}return INSTANCE;}
}
检查 | 位置 | 作用 |
---|---|---|
第一次检查 | synchronized 外 | 快速返回已创建的实例,提升性能 |
第二次检查 | synchronized 内 | 确保只有一个线程能创建对象 |
看似完美:既懒加载,又高性能,还线程安全?错!它有个致命漏洞——DCL问题。
DCL问题
getInstance 方法对应的字节码为:
0: getstatic #2 // Field INSTANCE:Ltest/Singleton; 3: ifnonnull 37 6: ldc #3 // class test/Singleton 8: dup 9: astore_0 10: monitorenter 11: getstatic #2 // Field INSTANCE:Ltest/Singleton; 14: ifnonnull 27 17: new #3 // class test/Singleton 20: dup 21: invokespecial #4 // Method "<init>":()V 24: putstatic #2 // Field INSTANCE:Ltest/Singleton; 27: aload_0 28: monitorexit 29: goto 37 32: astore_1 33: aload_0 34: monitorexit 35: aload_1 36: athrow 37: getstatic #2 // Field INSTANCE:Ltest/Singleton; 40: areturn
- 7 表示创建对象,将对象引用入栈
- 20 表示复制一份对象引用,引用地址
- 21 表示利用一个对象引用,调用构造方法初始化对象
- 24 表示利用一个对象引用,赋值给 static INSTANCE
步骤 21 和 24 之间不存在数据依赖关系,而且无论重排前后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的
- 关键在于 0:getstatic 这行代码在 monitor 控制之外,可以越过 monitor 读取 INSTANCE 变量的值
- 当其他线程访问 INSTANCE 不为 null 时,由于 INSTANCE 实例未必已初始化,那么 t2 拿到的是将是一个未初始化完毕的单例返回,这就造成了线程安全的问题
INSTANCE = new Singleton();
看似是一步,其实是三步,而且 JVM 可能指令重排,导致其他线程拿到一个“半成品对象”!
new Singleton()
到底发生了什么?
这行代码拆开是:
- 分配内存:给 Singleton 对象分配一块内存空间
- 初始化对象:调用构造函数,设置成员变量等
- 赋值引用:把
INSTANCE
指向这块内存地址
在单线程中,JVM 为了性能,可能重排成:
1 → 3 → 2
也就是:
- 先分配内存
- 然后
INSTANCE = 地址
(此时对象还没初始化!) - 最后才调用构造函数
这样就有问题了。
17: new #3 // 1. 分配内存,引用入栈
20: dup // 复制引用
21: invokespecial #4 // 2. 调用构造函数(init)
24: putstatic #2 // 3. INSTANCE = 引用(赋值)
invokespecial
(21):初始化putstatic
(24):赋值
这两步没有数据依赖,JVM 允许重排成 17→20→24→21
,即先赋值,再初始化!而 if (INSTANCE == null)
在同步块外(第0行 getstatic
),可以越过锁直接读到这个“未完成”的引用!
解决方法:加 volatile 禁止重排
用 volatile
给 INSTANCE
变量加一道“写屏障”,确保构造函数必须在 INSTANCE 赋值之前完成。
public class Singleton {private Singleton() { }// 加上 volatile,禁止指令重排private static volatile Singleton INSTANCE = null;public static Singleton getInstance() {if (INSTANCE == null) {synchronized (Singleton.class) {if (INSTANCE == null) {INSTANCE = new Singleton(); // 安全了!}}}return INSTANCE;}
}
在 INSTANCE = new Singleton();
编译后,volatile
会插入一个写屏障:
分配内存
调用构造函数(初始化)
↓
【写屏障】 ← volatile 插入
↓
INSTANCE = 地址(赋值)
屏障保证:
- 屏障前的初始化操作,一定在赋值之前完成
- 其他 CPU 核心会收到通知,缓存失效
- 下次读取时,必须从主内存拿最新值
这样,其他线程一旦看到 INSTANCE != null
,就一定能用到一个完全初始化好的对象。
生活比喻:盖房子交钥匙
没加 volatile
:开发商先把钥匙发了(INSTANCE
赋值),但房子还在装修(没初始化),业主拿着钥匙进不去,崩溃!加了 volatile
:必须等房子完全装修好,才允许发钥匙,确保业主拿到就能住!
为什么不锁 INSTANCE
?
你说:“能不能直接 synchronized(INSTANCE)
?”不行!因为:
- 初始时
INSTANCE = null
,null 没有对象头,不能作为锁对象; - 即使后来有值,每次都要获取对象锁,性能差;
- 我们要锁的是“创建过程”,所以锁
Singleton.class
更合理。
解决方法
指令重排只会保证串行语义的执行一致性(单线程),但并不会关系多线程间的语义一致性
引入 volatile,来保证出现指令重排的问题,从而保证单例模式的线程安全性:
private static volatile SingletonDemo INSTANCE = null;
ha-be
happens-before 先行发生
Java 内存模型具备一些先天的“有序性”,即不需要通过任何同步手段(volatile、synchronized 等)就能够得到保证的安全,这个通常也称为 happens-before 原则,它是可见性与有序性的一套规则总结
不符合 happens-before 规则,JMM 并不能保证一个线程的可见性和有序性
程序次序规则 (Program Order Rule):一个线程内,逻辑上书写在前面的操作先行发生于书写在后面的操作 ,因为多个操作之间有先后依赖关系,则不允许对这些操作进行重排序
锁定规则 (Monitor Lock Rule):一个 unlock 操作先行发生于后面(时间的先后)对同一个锁的 lock 操作,所以线程解锁 m 之前对变量的写(解锁前会刷新到主内存中),对于接下来对 m 加锁的其它线程对该变量的读可见
volatile 变量规则 (Volatile Variable Rule):对 volatile 变量的写操作先行发生于后面对这个变量的读
传递规则 (Transitivity):具有传递性,如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C
线程启动规则 (Thread Start Rule):Thread 对象的 start()方 法先行发生于此线程中的每一个操作
static int x = 10;//线程 start 前对变量的写,对该线程开始后对该变量的读可见 new Thread(()->{ System.out.println(x); },"t1").start();
线程中断规则 (Thread Interruption Rule):对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生
线程终止规则 (Thread Termination Rule):线程中所有的操作都先行发生于线程的终止检测,可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行
对象终结规则(Finaizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始
什么是 happens-before
想象你和朋友一起做饭:你说:“先洗菜 → 再切菜 → 最后炒菜。”这三步有顺序依赖,不能颠倒。Java 程序也一样,有些操作必须“先发生”,后面的才能看到结果。但在多线程环境下,由于 CPU 优化、编译器重排序、缓存等原因,代码写的顺序 ≠ 实际执行顺序。那怎么保证:线程 A 改了某个变量,线程 B 能“看到”这个修改?这就引出了 happens-before 原则。
“happens-before” 就是 JMM(Java 内存模型)规定的一套“谁必须先被看到”的规则。只要两个操作之间满足 happens-before 关系,那么前面的操作结果,对后面的操作一定可见,而且不能重排序。如果两个操作不满足任何 happens-before 规则,JMM 就不保证顺序和可见性,可能出现你意想不到的结果。
happens-before 的 8 条核心规则
1.程序次序规则:在一个线程内,代码写在前面的,happens-before 写在后面的。
就像你写菜谱:第一步洗菜,第二步切菜。不管 CPU 怎么优化,只要没破坏依赖,就得按这个顺序来。
int a = 1; // 操作1
int b = a + 1; // 操作2:依赖 a,所以操作1 happens-before 操作2
即使编译器或 CPU 为了性能重排序,也不能把 b = a + 1
放到 a = 1
前面,因为有数据依赖。
int a = 1;
int b = 2;
这两个没依赖,可能被重排序(但在单线程中结果不变,JMM 允许)。
2.锁定规则:对一个锁的 unlock
操作 happens-before 后面对这个锁的 lock
操作。
你上完厕所(unlock),把门打开,别人(另一个线程)才能进来(lock)。你上厕所时改的东西(比如冲了马桶),下一个人进来就能看到。
Object lock = new Object();
int x = 0;// 线程1
new Thread(() -> {synchronized (lock) {x = 10; // 修改共享变量} // 退出同步块:unlock
}).start();// 线程2
new Thread(() -> {synchronized (lock) {System.out.println(x); // 一定能读到 10!} // lock
}).start();
因为线程1的 unlock
happens-before 线程2的 lock
,所以 x=10
对线程2可见。
3.volatile 变量规则:对 volatile 变量的写操作 happens-before 后面对它的读操作。
你往微信群发了个“重要消息”(volatile 变量),所有人都能立刻看到,不会被缓存挡住。
volatile boolean flag = false;
int data = 0;// 线程1
new Thread(() -> {data = 42; // 普通写flag = true; // volatile 写
}).start();// 线程2
new Thread(() -> {while (!flag) { } // 等待 volatile 读为 trueSystem.out.println(data); // 一定能读到 42!
}).start();
因为 flag = true
happens-before while(!flag)
的读,而且 volatile 写会把之前的所有写都刷到主存。所以线程2不仅能读到 flag=true
,还能看到 data=42
。如果 flag
不是 volatile,线程2可能永远等不到 true
,或者看到 data=0。
4.传递规则:如果 A happens-before B,B happens-before C,那么 A happens-before C。
你爸是你爷爷的儿子(B happens-before C),你是你爸的儿子(A happens-before B),那你就是你爷爷的孙子(A happens-before C)。
volatile boolean ready = false;
int value = 0;// 线程1
value = 100; // A
ready = true; // B: volatile 写 → happens-before 读// 线程2
if (ready) { // B: volatile 读System.out.println(value); // C: 能看到 100!
}
- A (value=100) happens-before B (ready=true),因为是同一个线程,程序次序规则。
- B (ready=true) happens-before C (读 ready),因为是 volatile 规则。
- 所以 A happens-before C →
value=100
对线程2可见。
5.线程启动规则:thread.start()
happens-before 这个线程里的任何操作。
你喊一声“开始跑!”(start),运动员才开始跑。你在喊之前做的准备(比如系鞋带),运动员起步后都能看到。
static int x = 10;public static void main(String[] args) {x = 20; // 主线程修改 xThread t1 = new Thread(() -> {System.out.println(x); // 一定能读到 20!});t1.start(); // start() happens-before 线程内的操作
}
因为 t1.start()
happens-before 线程 t1 里的 println(x)
,所以能看到主线程修改的 x=20
。
6.线程中断规则:调用 thread.interrupt()
happens-before 被中断线程检测到中断。
你按了消防警报(interrupt),楼里的人马上能听到警报声(isInterrupted() 返回 true)。
Thread t = new Thread(() -> {while (!Thread.currentThread().isInterrupted()) {// 一直干活}System.out.println("收到中断信号!");
});t.start();// 主线程稍等一下再中断
try { Thread.sleep(100); } catch (InterruptedException e) {}t.interrupt(); // happens-before 线程内检测到中断
t.interrupt()
一定先于线程内部的 isInterrupted()
检测,不会出现“按了警报却听不到”的问题。
7.线程终止规则:线程内的所有操作 happens-before 其他线程检测到它终止。
你跑完马拉松,冲过终点线。裁判说“他完成了”,那你跑步过程中的所有动作(喝水、喘气)都已经被记录了。
int result = 0;Thread t = new Thread(() -> {result = 42; // 线程内计算
});t.start();
t.join(); // 等待 t 结束System.out.println(result); // 一定能读到 42!
因为线程 t 内的所有操作 happens-before t.join()
返回,所以主线程能安全看到 result=42
。
8.对象终结规则:一个对象构造函数结束 happens-before 它的 finalize()
方法开始。
你出生(构造完成)之后,才可能在多年后去世(finalize 被调用)。