首页 / Java 学习笔记 / 13

JAVA · Vol.II · DAY 13 · 并发编程

volatile 深入:可见性、有序性与 happens-before

高级必问#并发#JMM#核心
第 1 站

先讲一个「开关不生效」的线上事故

凌晨 1 点,你被电话叫醒:配置中心推了一个紧急开关 degradeSearch = true,要把搜索降级掉。服务端日志显示「已收到新配置」,但业务线程还在跑旧逻辑,重启才生效——半小时后重启,又坏了一次。

排查到最后:那个 boolean 字段没加 volatile。管理线程写它,业务线程在 while 循环里读它——JIT 把读取的值缓存在了寄存器里,业务线程永远看到旧值。加上 volatile,问题消失。这个事故花了一个团队两小时,本质只值一个关键字。

然后面试官会顺着这个场景问你:

"volatile 能保证原子性吗?"

"不能。"——如果你只回答这两个字,面试就到此为止了。

面试官真正想听的是:volatile 保证可见性和有序性,但不保证原子性。紧接着,他会追问:

  • 可见性是怎么实现的?缓存失效?内存屏障?
  • 有序性是什么意思?指令重排为什么危险?
  • 为什么 i++ 用 volatile 不行?
  • DCL 单例为什么一定要加 volatile?
  • volatile 写和读之间到底建立了什么关系?

这篇文章把 volatile 拆成 16 站,从可见性到内存屏障,从 happens-before 到 DCL 单例,一站一站把 volatile 讲透。

发车之前,先问自己:

  • volatile 写操作后,CPU 缓存做了什么?
  • StoreStore 屏障和 StoreLoad 屏障分别插在哪里?
  • 对象创建的 3 条指令为什么可能被重排?
  • happens-before 是「先发生」的意思吗?

带着这些问题,我们开始。

第 2 站

并发三性全景:谁负责什么,先立个地图

在钻进 volatile 的细节之前,先把多线程问题的「三性」和它们的责任人列清楚——volatile 只负责其中两性的半壁江山:

性质一句话定义谁负责典型工具
原子性一组操作要么全做、要么全不做,中间不可打断锁 / CASsynchronized、ReentrantLock、AtomicXxx
可见性A 线程的修改,B 线程能看到volatile / 锁volatile、synchronized(出锁刷回)
有序性代码的执行顺序不被编译器/CPU 随意调换(多线程可见的语义)内存屏障 / 锁 / volatilevolatile、synchronized、final(部分)

注意 volatile 的「责任边界」:可见性 ✓、有序性 ✓、原子性 ✗。后文 16 站基本都在回答三个问题——可见性怎么实现的(第 3、4 站)、有序性怎么实现的(第 5、6、8 站)、为什么原子性它管不了(第 7 站),外加它的「法律条文」happens-before(第 9 站)和生产落地(第 11、12 站)。

记忆锚点volatile 是一个「广播 + 交通管制」设备:广播让修改人人可见,交通管制(屏障)让指令不乱序——但它不是「保险箱」,管不住读-改-写这类复合操作。
第 3 站

可见性:一个线程改了值,其他线程立刻能看到

现代 CPU 都有多级缓存(L1、L2、L3),每个核心有自己独立的 L1/L2 缓存。Java 线程运行在不同的 CPU 核心上时,对同一个变量的读写可能发生在各自的缓存里——线程 A 修改了变量,值还在 L1 缓存中,线程 B 根本看不到

主内存 (Main Memory) volatile int flag = 0 L3 共享缓存(所有核心共享) CPU 核心 A(线程 1) L1 缓存:flag = 0 volatile write: flag = 1 ① 写操作 → 强制刷回主内存 ② 通知其他核心缓存失效 CPU 核心 B(线程 2) L1 缓存:flag = 0(旧值) volatile read → 缓存失效,重新读取 ③ 收到失效通知 → 清除 L1 缓存行 ④ 从主内存读到 flag = 1(新值)
图 1volatile 写操作会立即刷回主内存并通知其他核心缓存失效;volatile 读操作会先使缓存失效,再从主内存重新加载

volatile 保证可见性的机制可以概括为两条规则:

volatile 可见性规则
volatile write(写操作):
  // 1. 将新值立即刷新到主内存
  // 2. 通过 MESI 协议通知其他 CPU 核心:这个缓存行无效

volatile read(读操作):
  // 1. 使当前 CPU 缓存中该变量的缓存行失效
  // 2. 从主内存重新读取最新值

底层实现依赖 CPU 的 缓存一致性协议(如 MESI)和 Lock 前缀指令。JVM 会在 volatile 操作时插入特定的 CPU 指令(如 lock 前缀),强制把缓存行刷回主内存。

补一个 JMM(Java 内存模型)视角的对应:Java 规范里把「主内存」抽象为所有线程共享的变量之家,每个线程还有一份工作内存(Working Memory)——线程读变量 = 把工作内存的副本拉本地,写变量 = 改本地副本。volatile 的效果就是让写操作「立即提交回主内存」,读操作「强制从主内存重新拉取」。CPU 缓存层次是硬件实现,JMM 是语言层面的抽象——同一件事的两个刻度。

记住volatile 让每一次写都"广而告之",每一次读都"眼见为实"——但这只对单个变量的单次读写有效。
第 4 站

MESI 与 lock 前缀:「广而告之」的硬件真相

第 3 站说 volatile 写会「通知其他核心缓存失效」,这个通知靠的是 CPU 的缓存一致性协议。x86 用的是 MESI 协议,每个缓存行(Cache Line,通常 64 字节)有四种状态:

状态含义对应场景
Modified本核改过,主内存还是旧值,其他核都没有有效副本线程 A 刚写了 volatile 变量,还没刷回去
Exclusive本核有、其他核没有,但和主内存一致只有线程 A 读过这个变量
Shared多个核都有有效副本两个线程都读过、都还没写
Invaid无效别人写了,你的副本被作废

核心规则就一条:同一时刻,一个缓存行只能有一个核处于 M 或 E 状态。线程 A 写 flag 时,它所在核必须先把该缓存行置为 M,并通过总线发一个「无效化广播」——其他核收到后把各自缓存行的 flag 标记为 I。这就是「缓存失效」的硬件过程,不神秘,就是一次总线上的广播。

lock 前缀指令做了什么?在 x86 上,lock 前缀(如 lock add)有两个效果:

  • 锁定缓存行:在操作完成前,其他核不能修改这一行——保证操作的独占性;
  • 强制一致性:操作完成后,缓存行的新值刷回内存并广播失效——把 M 状态的「私有修改」立刻公开。

所以「volatile 写 = 带 lock 前缀的写指令」在 x86 上几乎是逐字成立的。而 x86 是强内存模型(Store-Store / Load-Load / Load-Store 都不重排),这让 JVM 在 x86 上可以少插屏障——第 8 站展开。面试被问「volatile 底层是什么」,标准答题链:volatile 写 → lock 前缀指令 → 缓存行锁定 + 刷回 + MESI 广播失效 → 其他核读到新值

追问:那 ARM 呢?ARM 是弱内存模型,Store 和 Store 之间都可能被重排。JVM 在 ARM 上要给 volatile 插满四种屏障——这正是「同一份 Java 代码在不同架构上性能差异大」的底层原因之一(Android 和服务器选型的隐性成本)。
第 5 站

有序性:编译器、JVM、CPU 都在偷偷调顺序

为了提高性能,编译器和 CPU 会对指令进行重排序。只要最终结果在单线程中不变,重排就是合法的。但在多线程环境下,重排可能导致灾难。

来看一个经典例子:

ReorderExample.java
int a = 0, b = 0;
int x = 0, y = 0;

// 线程 1
a = 1;      // ①
x = b;      // ②

// 线程 2
b = 1;      // ③
y = a;      // ④

按程序顺序的「直觉」,可能的结果有 (x=1, y=0)(x=0, y=1)(x=1, y=1) 三种——唯独 x=0 且 y=0 不该出现:它意味着线程 1 在 a=1 之前读了 b(②在①前),线程 2 也在 b=1 之前读了 a(④在③前),两边同时把「先写后读」颠倒过来。

x=0, y=0 真的会发生——因为 ① 和 ② 之间没有数据依赖(a=1 的结果不参与 x=b 的计算),编译器/CPU 认为交换顺序不影响单线程结果,就放心重排了。单线程里 ①② 和 ②① 结果一样;多线程里,两边同时重排就撞出了第三种「不可能」。

volatile 通过建立 happens-before 关系来阻止特定的重排:

volatile write 之前的所有操作 → happens-before → volatile write → happens-before → volatile read → happens-before → volatile read 之后的所有操作

具体来说:

  • volatile write 之前的普通写不会被重排到 volatile write 之后——保证其他线程看到 volatile flag 时,也能看到 flag 之前的所有写入。
  • volatile read 之后的普通读不会被重排到 volatile read 之前——保证读 volatile flag 之后,能读到 flag 之前的所有写入。
happens-before 关系的传递性:如果 A happens-before B,B happens-before C,那么 A happens-before C。volatile 就是那个"B"——一座桥梁。注意 hb 的准确含义下一站细讲,先记住「它是可见性保证,不是时间戳」。
第 6 站

重排的三层来源,和单线程为什么永远安全

第 5 站说「编译器和 CPU 会重排」,其实重排发生在三个层面,各自的「裁判」不同:

层级谁干的为什么例子
编译器重排Java 编译器 / JIT指令调度优化(寄存器分配、循环展开)把无依赖的 a=1 挪到 x=b 后面
CPU 指令重排处理器(乱序执行引擎)填满流水线、隐藏延迟内存读还没返回,先执行后面的加法
内存系统重排缓存 / 写缓冲 / 总线Store Buffer 等硬件结构写进了写缓冲,还没落到缓存行

那为什么单线程程序从来没出过重排事故?因为 as-if-serial(串行语义)原则:JVM 允许任意重排,但重排后的执行结果必须和按程序顺序执行的结果一致——对单线程代码而言,观察者只能看到最终结果,而最终结果没变,重排就「不可见」。a=1; x=b; 单线程跑,不管怎么调顺序,你打印的结果都一样。

问题只出在多线程:另一个线程是「外部观察者」,它能窥见你的中间状态。重排把「中间状态」改变了,事故就来了。所以有序性的准确定义不是「指令不能动」,而是——不能把「别的线程能看到」的变化顺序搞乱。volatile 的屏障正是卡在「对别的线程可见」的边界上:volatile 写之前的写入,必须赶在 volatile 写「可见」之前完成。

面试金句"单线程安全靠 as-if-serial:怎么重排结果都不变。多线程不安全是因为重排改变了别的线程能观察到的顺序。volatile 就是告诉编译器和 CPU:这个变量的读写是观察边界,屏障两侧不许动。"
第 7 站

不保证原子性:i++ 为什么用 volatile 还是不安全?

面试官最爱问的问题之一。先看代码:

VolatileAtomicTest.java
public class VolatileAtomicTest {
    static volatile int count = 0;

    public static void main(String[] args) throws Exception {
        CountDownLatch latch = new CountDownLatch(100);
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    count++; // 看似简单的自增
                }
                latch.countDown();
            }).start();
        }
        latch.await();
        System.out.println("count = " + count);
        // 期望:100000,实际:往往 < 100000
    }
}

问题出在 count++ 这个操作。它不是原子的——实际上它由 3 条指令组成:

count++ 的 3 步
// 第 1 步:read —— 从内存读取 count 的值
int temp = count;       // 比如读到 42

// 第 2 步:modify —— 在寄存器中加 1
temp = temp + 1;          // 变成 43

// 第 3 步:write —— 把新值写回内存
count = temp;            // 写入 43

volatile 只保证第 1 步读到的是最新值,但第 1 步和第 3 步之间,其他线程完全可以插入进来修改 count:

时间线线程 A线程 Bcount 值
T1read count → 4242
T2read count → 4242
T3modify: 42+1=4342
T4modify: 42+1=4342
T5write count = 4343
T6write count = 4343

两个线程各做了一次 count++,count 应该变成 44,结果却只变成了 43——丢失了一次更新。100 个线程各做 1000 次,丢失的更新越多,最终结果离 100000 就越远。

破解volatile 保证的是"读到的值是对的"(可见性),但不保证"从读到写之间没人改过"(原子性)。复合操作必须用 synchronized 或 AtomicXxx(后者用 CAS 把三步焊成一个 CPU 原子指令序列,详见「CAS 与原子操作」篇)。
补一个细节:volatile 对单个变量的单次读/单次写是原子的(比如 flag = 1 一次写不会被撕成两半)。唯一的例外是 32 位 JVM 上的 long/double(8 字节要拆两次写);JMM 规定 64 位平台上 long/double 的读写按原子处理,生产上不用操心。所以「volatile 不保证原子性」的准确表述是:不保证复合操作(读-改-写)的原子性
第 8 站

内存屏障:阻止指令重排的硬件机制

volatile 的可见性和有序性最终都要落到 CPU 指令层面。JVM 通过在 volatile 操作前后插入内存屏障(Memory Barrier)来实现这两大保证。

volatile 操作前后的内存屏障 volatile write 序列 普通 Store / Load 操作 StoreStore Barrier ← 确保上面的写先于 volatile 写完成 volatile Store(写操作) StoreLoad Barrier ← 确保 volatile 写先于下面的读完成 普通 Store / Load 操作 volatile read 序列 普通 Store / Load 操作 volatile Load(读操作) LoadLoad Barrier ← 确保下面的读在 volatile 读之后 LoadStore Barrier ← 确保下面的写在 volatile 读之后 普通 Store / Load 操作 四种屏障的含义 StoreStore :屏障之前的 Store 不会被重排到屏障之后 StoreLoad :屏障之前的 Store 不会被重排到屏障之后的 Load 之前(万能屏障) LoadLoad :屏障之后的 Load 不会被重排到屏障之前 LoadStore :屏障之后的 Store 不会被重排到屏障之前 x86 只对 Store-Load 重排敏感,所以 JVM 在 x86 上只在 volatile write 后插入 lock 前缀指令(等效于 StoreLoad)
图 2volatile write 前后插入 StoreStore + StoreLoad 屏障;volatile read 之后插入 LoadLoad + LoadStore 屏障

在 x86 架构上,CPU 本身不会对 Store-Store、Load-Load、Load-Store 进行重排,唯一会重排的是 Store-Load。所以 JVM 在 x86 上做了优化:volatile write 之后只需要插入一个 lock 前缀指令(等效于 StoreLoad 屏障),其他屏障实际上是空操作(no-op)。

为什么 StoreLoad 屏障最重要?因为"先写后读"是唯一会被 CPU 自由重排的组合——写操作还没刷到主内存,读操作就去读旧值,这是最常见的可见性问题。
第 9 站

volatile 的 happens-before 规则:一句话的法律条文

第 5 站给了直觉,这一站给规范原文。JSR-133 关于 volatile 的规则只有一句话:

对 volatile 变量 V,线程 A 对 V 的写,happens-before 线程 B 对 V 的后续读
(后续 = B 的这次读发生在 A 的这次写之后)

这句话单独看平平无奇,但它是「安全发布」的法律基础。看一个真实模式——数据 + 标志位

volatile 发布数据
private byte[] buffer;                    // 普通字段
private volatile boolean ready = false;   // volatile 标志

// 初始化线程
buffer = new byte[1024];
... 填充 buffer 的所有数据 ...
ready = true;   // volatile write

// 业务线程
if (ready) {              // volatile read
    use(buffer);          // 一定看得到初始化线程写好的 buffer!
}

推导链(用 hb 的传递性):

  • 程序顺序规则:「填充 buffer」hb 「ready = true」(同线程内,前面的操作 hb 后面的)
  • volatile 规则:「ready = true」(写)hb 「if (ready) 读到 true」(读)
  • 传递性:「填充 buffer」hb 「use(buffer) 前」→ 业务线程读 buffer 时,所有数据必然已就位

反过来,标志位不加 volatile 就断了第一座桥:业务线程可能永远读不到 true(可见性),或者读到 true 时 buffer 还是半初始化的(有序性)——第 1 站的事故就是前者。

最大的误区:hb ≠ 先发生

happens-before 不是时间先后,而是可见性保证。A hb B 的意思是「A 的执行结果对 B 可见」,不要求 A 在墙上时钟上先于 B 执行完——B 甚至可能「更早」开始执行(它只是要等 A 的结果可见才继续)。面试被问「happens-before 是什么意思」,答「A 在 B 之前执行」就是错的;答「A 的结果对 B 可见,A 之前发生的所有写对 B 之后可见的所有读都可见」才是对的。完整的 8 条 hb 规则见「JMM 与 happens-before」篇。

第 10 站

DCL 单例为什么一定要加 volatile?

这是 Java 面试中最经典的问题之一。先看 Double-Checked Locking(DCL)的代码:

Singleton.java
public class Singleton {
    private static volatile Singleton instance; // 必须 volatile!

    public static Singleton getInstance() {
        if (instance == null) {                 // 第一次检查(无锁,快速路径)
            synchronized (Singleton.class) {
                if (instance == null) {           // 第二次检查(有锁,防止重复创建)
                    instance = new Singleton();    // ⚠️ 问题出在这里
                }
            }
        }
        return instance;
    }
}

问题出在 instance = new Singleton() 这一行。它不是原子操作——字节码层面它包含 3 步:

new Singleton() 的 3 步
new        // ① 分配内存空间
dup
invokespecial // ② 调用构造方法初始化对象
putstatic   // ③ 把引用赋值给 instance 变量

在正常顺序下,执行顺序是 ① → ② → ③。但编译器和 CPU 可能将 ② 和 ③ 重排为 ① → ③ → ②(② ③ 之间没有数据依赖):

步骤正常顺序重排后的顺序
分配内存分配内存
初始化对象把引用赋给 instance(对象还没初始化!)
把引用赋给 instance初始化对象

重排之后的灾难场景:

DCL 失败场景 · 时间线
// 线程 A 执行 getInstance()
T1: 分配内存
T2: instance = 引用          // 引用不为 null 了!但对象还没初始化

// 线程 B 执行 getInstance()
T3: if (instance == null)   // false!跳过 synchronized 块
T4: return instance          // 返回了一个半初始化的对象 → NPE 或逻辑错误

// 线程 A 继续
T5: 初始化对象               // 但线程 B 已经拿到了未初始化的引用

volatile 如何解决这个问题? 加入 volatile 后,putstatic(volatile write)之前会插入 StoreStore 屏障,阻止 ② 和 ③ 重排。保证其他线程看到 instance != null 时,对象一定已经初始化完毕——正好就是第 9 站「数据 + 标志位」模式的一个特例:instance 本身就是那个「标志 + 数据」合一的引用。

面试金句DCL 不加 volatile 在 x86 上"可能"不会出问题(因为 x86 不做 Store-Store 重排),但在 ARM 等弱内存模型架构上必然出问题。面试时回答"必须加",并解释指令重排的原因。
第 11 站

volatile vs synchronized:一张表分清楚

这两个词在面试里总被放在一起问。别急着答「一个轻量一个重量」,先摆事实:

维度volatilesynchronized
可见性✓ 写后立即可见✓ 出锁时刷回主内存,进锁时重新读取
有序性✓ 插入内存屏障✓ 临界区内不被重排(JIT 把锁块当作屏障边界)
原子性✗ 只保证单次读写✓ 整个临界区原子执行
阻塞永不阻塞(无锁)竞争时阻塞(JDK 15+ 无偏向锁,升级链路:轻量级→重量级)
能做什么状态标志、安全发布复合操作、互斥临界区、wait/notify
性能单变量读写几乎零开销JDK 6 后优化很大,无竞争时接近 volatile 量级,但仍有开销

注意一个反直觉的点:synchronized 其实也保证可见性和有序性——很多人只把它和「互斥」挂钩。它靠的是:进锁的读和出锁的写都有屏障语义,加上同一时刻只有一个线程持锁,「串行化」天然带来可见性。所以「用 synchronized 就一定能看到最新值」是成立的;「看到最新值就需要 synchronized」才是错的——volatile 就够,还更便宜。

选型口诀单个状态标志/引用发布 → volatile;读-改-写或跨变量一致性 → synchronized(或锁/CAS)。两者不是「轻量版 vs 重量版」的替代关系,是不同职责:一个管广播和秩序,一个管互斥。
第 12 站

生产落地:volatile 的正确打开方式

回到第 1 站的事故,把它泛化成一个排查模式:「A 线程写了,B 线程看不到,重启就好」= 可见性问题,99% 是共享字段少了 volatile(或没用线程安全容器)

生产里 volatile 的三块主力阵地:

阵地一:服务开关 / 降级标志
private volatile boolean running = true;
private volatile boolean degradeSearch = false;  // 第 1 站事故里的开关

// 工作线程:while 循环里反复读
while (running) {
    if (degradeSearch) { // 配置推送后立即生效,不用重启
        return degradeResult();
    }
    doWork();
}

// 管理线程(配置中心回调 / 停机钩子)
degradeSearch = true;   // volatile write,工作线程下一轮循环必看到
running = false;        // 优雅停机开关同理
阵地二:缓存 / 配置的安全发布
private volatile Config config;   // 整个对象一次发布,不逐字段改

// 更新线程:构造好新对象,一次性替换引用
Config next = Config.loadFromRemote();
config = next;   // volatile write:读到新引用的线程,必能看到 next 构造完成的所有字段

// 读线程:永远读到「完整」的对象,不会看到半更新状态
Config c = this.config;   // 局部缓存一次,避免两次读之间被换掉
if (c != null) c.getPort();

阵地三就是 DCL 单例(第 10 站)。三条最佳实践,直接当 Code Review 清单用:

  • volatile 只用于「状态」,不用于「数据累积」count++list.add() 这类复合操作看到 volatile 就该打回——换成 AtomicXxx / 并发容器 / 锁。
  • 「数据 + 标志」成对出现时,标志必须 volatile:先填数据后翻标志,缺一不可(第 9 站的推导链)。
  • 读对象引用先存局部变量Config c = this.config——两次读 this.config 之间引用可能已被换掉,用局部变量固定住「这一轮看到的版本」。
反模式:用 volatile int 当状态机的值(STATE_INIT → STATE_RUNNING → STATE_DONE)然后 if-else 转移状态。单个 volatile 只保证「每次读到最新」,不保证「转移本身不被并发打断」——状态机迁移要加锁或用 CAS(AtomicReference)。
第 13 站

适用场景速查:什么时候用,什么时候别用

第 12 站讲了「怎么用」,这一站给一张「用不用」的速查表。知道什么时候 volatile 是基本功,知道什么时候不用 volatile 才是功力。

适合用 volatile 的场景:

场景 1:状态标志位
volatile boolean running = true;

// 工作线程
while (running) {
    // 干活...
}

// 管理线程
running = false; // 工作线程下次循环时一定能看到 false
场景 2:一次性安全发布
volatile Config config;

// 初始化线程
Config c = new Config(loadFromFile()); // 构造完成
config = c;  // volatile write:保证其他线程看到 config != null 时,Config 内部字段也已初始化

// 其他线程
if (config != null) {
    config.getPort(); // 一定能读到正确的值
}
场景 3:DCL 单例模式
// 见第 10 站,volatile 防止指令重排导致半初始化对象

不适合用 volatile 的场景:

场景问题正确方案
计数器 count++读-改-写不是原子的AtomicIntegersynchronized(高并发用 LongAdder
条件检查后修改
if(!map.containsKey(k)) map.put(k,v)
检查和修改之间可能被其他线程插入ConcurrentHashMap.putIfAbsent()
多个变量的联合更新
x=1; y=2;
volatile 只保证单个变量的可见性synchronized

volatile vs AtomicXxx:

对比
// volatile:只保证可见性和有序性,适合简单的读写
volatile int flag = 0;
flag = 1;              // ✅ 单次写,安全
int x = flag;          // ✅ 单次读,安全
flag++;                // ❌ 读-改-写,不安全

// AtomicInteger:基于 CAS,保证原子性 + 可见性
AtomicInteger count = new AtomicInteger(0);
count.incrementAndGet(); // ✅ CAS 循环,原子操作
count.compareAndSet(0, 1); // ✅ CAS,原子操作
选择原则单次读或单次写 → volatile。需要读-改-写(CAS)→ AtomicXxx。多个操作需要整体原子性 → synchronized。
第 14 站

进阶:volatile 引用为什么能「安全发布」整个对象

第 9 站的「数据 + 标志位」有一个更隐蔽的推论:把引用声明成 volatile,发布时连对象内部字段一起「打包可见」。为什么?

回忆第 10 站:对象创建的 ② 初始化 和 ③ 引用赋值 可以被重排。JMM 为了防止这种重排「泄漏」给别的线程,规定:volatile 写一个引用时,该引用指向的对象的完整构造(包括它所有的 final 字段和普通字段的初始化)都 happens-before 这个 volatile 写。于是:

  • 「对象构造完成」hb 「config = c」(volatile 写)
  • config = c」hb 「另一个线程的 config 读」
  • 传递:「对象构造完成」hb 「另一个线程使用 config」→ 看到的对象保证构造完毕

这就是「安全发布(Safe Publication)」:发布一个对象的所有合法途径只有四种——① 用 final 字段(构造完成即天然安全)② 用 volatile 引用发布 ③ 用锁保护 ④ 用线程封闭(不共享)。其他任何方式(普通字段直接赋值后给出去)都可能出现「引用可见、内部字段还是半初始化」的幽灵。

顺带把 final 的语义也串上:final 字段在构造方法执行完时就「定格」——任何线程读到持有该 final 字段的对象(哪怕引用本身是普通字段泄露出去的),final 字段指向的对象也是构造完毕的。final 管「构造期」,volatile 管「发布期」,两者合起来覆盖了对象可见性的全部场景。完整的 final 域规则(包括 final 域逃逸的坑)见「JMM 与 happens-before」篇。

为什么这个知识点值钱

它把「volatile 是个关键字」升级成「volatile 是发布协议的一部分」。面试答到 DCL 时顺一句「本质上 DCL 就是 volatile 安全发布 + 锁保证构造互斥」,或者排查「对象字段是默认值」类灵异 bug 时先问「这个对象是怎么发布出去的」——这种层次的答案在候选人里凤毛麟角。

第 15 站

高频追问快答:四道送命题

volatile 讲到这里,面试里剩下的基本都是固定追问。四道高频题,标准答案各一段:

Q1:volatile 慢吗?

"单次 volatile 读几乎和普通过读一样快(读不用加锁,x86 上甚至不插额外指令);volatile 写比写贵,因为要执行带 lock 前缀的指令——锁缓存行 + 刷回 + MESI 广播,代价在几十到几百个 CPU 周期。真正的性能杀手不是 volatile 本身,而是高频率写同一个 volatile 变量引发的缓存行乒乓(cache line ping-pong):N 个核轮流失效彼此的缓存。所以生产里高频计数用 LongAdder 分片,而不是 volatile int 硬加。"

Q2:volatile 能替代 synchronized 吗?

"不能。volatile 没有原子性,count++ 这种读-改-写它会丢更新;也不能做互斥临界区。它和 synchronized 是不同职责:volatile 保证广播 + 秩序,synchronized 保证互斥 + 广播 + 秩序。能替代的只有『单变量状态标志』这一小块场景。"

Q3:x86 上 volatile 到底插了几条屏障指令?

"x86 是强内存模型,只有 Store-Load 会重排,所以 JVM 只保证 volatile write 之后有一条等效 StoreLoad 的屏障(lock 前缀指令);StoreStore 由 x86 硬件天然保证,volatile read 后的 LoadLoad/LoadStore 在 x86 上不需要额外指令。ARM 等弱内存模型上则四种屏障都要插满——同一份代码跨架构性能差异的根源就在这。"

Q4:两个线程同时 volatile 写同一个变量,谁覆盖谁?

"没有'谁覆盖谁'的确定性——每次写都是原子的,但两次写之间没有任何协调,最终值是最后一次写,中间值丢失。这就是为什么 volatile 不能当计数器、不能做状态机:它保证『每次写完整』,不保证『写和写之间不被插入』。要协调就用 CAS(AtomicXxx)或锁。"

总结

这一篇你掌握了什么

用一张表把 volatile 的所有特性收拢起来:

特性是否保证实现机制
可见性volatile write 刷回主内存 + 通知其他核心缓存失效;volatile read 使缓存失效 + 重新读取
有序性插入内存屏障(StoreStore、StoreLoad、LoadLoad、LoadStore),阻止屏障两侧的指令重排
原子性只对单次读/单次写保证原子性(32 位 JVM 上的 long/double 除外),复合操作不安全

volatile 相关的 happens-before 规则:

规则 1:对同一个 volatile 变量,线程 A 的 write happens-before 线程 B 的 read
规则 2:volatile write 之前的所有操作 happens-before volatile write
规则 3:volatile read happens-before volatile read 之后的所有操作
规则 4(传递性):由规则 1+2+3,A 写之前的操作 → 对 B 读之后可见

核心知识点回顾

  • 三大性质:可见性 ✓ / 有序性 ✓ / 原子性 ✗(复合操作)——并发三性里它只认领两半。
  • 可见性机制:volatile 写 = lock 前缀指令 = 缓存行锁定 + 刷回 + MESI 广播失效;读 = 强制重取。
  • 有序性机制:volatile 写前 StoreStore、写后 StoreLoad;读后 LoadLoad + LoadStore。x86 只需一条 lock(StoreLoad)。
  • 重排三层:编译器 / CPU / 内存系统;单线程安全靠 as-if-serial,多线程危险在「被观察到的顺序」变了。
  • i++ 不安全:读-改-写三步,volatile 只护住第一步。
  • happens-before 规则:volatile 写 hb 后续读;hb 是可见性保证,不是时间先后。
  • DCL:new 的三步(分配/初始化/赋值引用)后两步可重排 → volatile 用 StoreStore 屏障锁死顺序。
  • 安全发布:final 管构造期、volatile 管发布期;引用先存局部变量再读。
  • 生产三板斧:开关标志、安全发布、DCL;volatile 只用于状态,不用于累积。

一句话总结

volatile 是「广播 + 交通管制」:让修改立刻人人可见(MESI 广播),让指令在可见边界上不乱序(内存屏障)——但它不是保险箱,读-改-写请找 CAS 或锁。

🎤 面试 30 秒总结

"volatile 保证可见性和有序性,不保证原子性。

 可见性:volatile write 会立即刷回主内存,并让其他核心的缓存行失效(lock 前缀 + MESI 广播);
         volatile read 会使本地缓存失效,从主内存重新加载。

 有序性:通过在操作前后插入内存屏障,阻止编译器和 CPU 对屏障两侧的指令重排。
         例如 DCL 单例中,volatile 阻止了对象创建时 '赋值引用' 和 '初始化' 的重排。

 不保证原子性:count++ 是读-改-写三步操作,volatile 只保证读到的值是最新的,
              但三步之间可能被其他线程干扰,导致更新丢失。应该用 AtomicInteger。

 生产用法:状态标志、一次性安全发布、DCL 单例;volatile 引用能『安全发布』整个对象——
 它和 final 一起构成对象可见性的两条腿。"

Comments · 评论