说起 volatile,很多人第一反应就是“能保证多线程下的可见性”,再问深一点就支支吾吾了。刚好最近在翻 JMM 的底层逻辑,索性一口气把 volatile 的裤子扒干净,连字节码和汇编都给你掏出来看看。
先看一段让人头疼的代码:
public class NoVisibility {private static boolean ready;private static int number;private static class ReaderThread extends Thread {public void run() {while (!ready) {Thread.yield();}System.out.println(number);}}public static void main(String[] args) throws InterruptedException {new ReaderThread().start();number = 42;ready = true;}
}
这段代码有意思的地方在于:ReaderThread 可能会一直死循环,永远看不到 ready 变成 true;就算看到了,打印出来的 number 也可能是 0,而不是 42。这就是典型的可见性和重排序问题。主线程那边 number = 42 和 ready = true 这两行代码,在 CPU 眼里是没有依赖关系的,它完全可以先改 ready 再改 number,而 ReaderThread 看到的顺序又可能跟写入顺序不一致。
给 ready 加上 volatile 之后:
private static volatile boolean ready;
神奇的事情发生了——循环能退出了,number 也稳如老狗地输出 42。volatile 在这里干了三件事:一是强制把修改立即刷回主内存,二是让其他线程读这个变量时直接从主内存拿,三是禁止指令重排序。但这三个描述只是表象,底层逻辑要硬核得多。
回过头看另一个经典场景,双重检查锁定的单例:
public class Singleton {private static volatile Singleton instance; // 必须有 volatilepublic static Singleton getInstance() {if (instance == null) { // 第一次检查synchronized (Singleton.class) {if (instance == null) { // 第二次检查instance = new Singleton(); // 问题就在这一行}}}return instance;}
}
如果不加 volatile,instance = new Singleton() 这一行在字节码层面可以拆成三步:
- 分配内存空间
- 初始化对象(调用构造方法)
- 将引用赋值给 instance
但 CPU 可能会把步骤 2 和 3 重排,变成先给 instance 赋值(此时 instance 非空),再初始化对象。这时另一个线程在第一次检查时发现 instance 不为 null,直接拿去用,拿到的却是个半成品对象,业务可能就崩了。
这就是 volatile 禁止指令重排序的意义——它给变量的写操作前后加上了“内存屏障”。具体到 JVM 的实现,volatile 写操作前面会插入 StoreStore 屏障,后面插入 StoreLoad 屏障;读操作后面会插入 LoadLoad 和 LoadStore 屏障。这些屏障就像一堵墙,强行把指令的乱序执行给框死。
光讲抽象的屏障还是不够过瘾,我们把生成的汇编代码扒出来看看。在 JIT 编译后的代码里,对 volatile 变量的写操作(比如 ready = true),在 x86 架构上会翻译成带 lock 前缀的指令:
movb $0x1,0x70(%rsi) ; 普通写
lock addl $0x0,(%rsp) ; 这就是内存屏障的体现
这个 lock 前缀太关键了,它的作用比单纯加锁要底层得多:它会锁住总线或者使用缓存一致性协议(比如 MESI),让当前 CPU 缓存行的修改立即写回主内存,同时让其他 CPU 里对应的缓存行失效。这样一来,其他线程再读这个变量的时候,CPU 发现缓存行失效,就只能乖乖去主内存重新加载,可见性就是这么硬扛出来的。
而且 lock 指令本身也相当于一个“万能屏障”——在它之前的读写操作一定不会被重排到它之后,反之亦然。所以 JIT 编译器在遇到 volatile 时,会根据不同平台生成对应的屏障指令,x86 平台上 StoreLoad 屏障就是靠 lock 前缀完成的,而 ARM 架构则需要显式地插入 DMB 指令。
说到这儿你可能会觉得 volatile 挺牛逼的,顺手就想用它来解决累加问题,比如:
private static volatile int count = 0;
// 十个线程各加 1000 次
结果跑出来永远小于 10000,然后你满头问号。因为 volatile 不保证原子性,count++ 这个操作读和写是两个步骤,中间被其他线程插一杠子就丢失更新了。这个坑我当年踩过,还以为是 Java 的 bug。
所以 volatile 适用场景其实很明确:一个线程写,多个线程读,或者作为状态标志位;还有就是前面说的双重检查锁定,用它来防止对象逸出。一旦涉及“先检查后执行”的复合操作,老老实实上锁或者用 Atomic 类吧。
这样一路刨下来,你会发现 volatile 的底层就是靠着 JMM 的 happens-before 规则、内存屏障、CPU 的 lock 指令和缓存一致性协议这一整套组合拳,才把并发编程里最容易出鬼的可见性和重排序问题给按住。面试的时候如果能把这一串串起来讲,比背八股文有说服力多了。
