你的代码不是按你写的顺序跑的
为了性能,编译器会重排指令,CPU 会乱序执行——前提只有一条:单线程内结果不能变(术语叫 as-if-serial,你写出来的逻辑等价于按顺序执行的效果)。麻烦在于:单线程的「结果不变」管不到别的线程的视角。重排在单线程里天衣无缝,落到另一个线程眼里,就是偶现的灵异事件。
灵异现场:没初始化完的对象
new 一个对象不是原子动作,它在 JVM 层面分三步:分配内存 → 初始化字段 → 把引用指过去。第 2、3 步之间允许重排——先把引用指过去,再慢慢初始化。单线程无感;但另一个线程此刻检查「引用非空」就直接用,拿到的是一个字段全为默认值的半成品对象。这不是玄学,是指令重排的教科书案例。
happens-before:JMM 的秩序公约
靠什么判断「A 线程的写,B 线程能不能看见」?JMM 给出 happens-before 公约——只要满足以下任意一条,前一个操作的结果对后一个操作可见且有序:程序顺序规则(单线程内,按代码顺序——重排不破坏结果即可)、锁规则(同一把锁,解锁先于后续加锁)、volatile 规则(写先于后续读)、线程规则(start 先于子线程任何动作、子线程动作先于 join 返回)、传递性(A 先于 B、B 先于 C,则 A 先于 C)。一条都不满足?JMM 不承诺任何事——可能是旧值,可能是重排后的鬼故事。所谓写并发代码,本质上就是给线程之间的读写凑出 happens-before 链条。
实战考场:DCL 单例为什么要 volatile
class Singleton {
private static volatile Singleton instance; // volatile 不能省
static Singleton getInstance() {
if (instance == null) { // 第一次检查:无锁
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:持锁
instance = new Singleton(); // 三步,可被重排
}
}
}
return instance;
}
}第一次检查不加锁,是为了让 99% 的调用直接返回、不排队——但这恰恰给重排留了空子:线程 A 在 synchronized 里 new 到一半(引用已赋值、字段没初始化完),线程 B 在第一次检查看到 instance 非空,直接 return 一个半成品。volatile 禁止「初始化字段」和「引用赋值」的重排,堵死这条通道。DCL 的价值不只在单例本身——它是理解重排与 happens-before 最好的教具。顺带一个更省心的写法:静态内部类单例,靠 JVM 类初始化自带的锁语义天然线程安全,还自带懒加载,不写 volatile 也不出错。
volatile 的小抄
完整语义到齐:可见性(写立即可见)、有序性(禁止特定重排)、没有原子性。三宗罪里它治两宗,剩下一宗——原子性——交给下一篇的主角:CAS。
评论 (0)