volatile深入

§2.2.2 volatile深入

考察意图:volatile是面试高频基础题。初级答"保证可见性+禁止重排序",中极答内存屏障语义,高级能讲出实际项目中用它解决过什么并发bug。

一、volatile的两个核心作用(基础层,必须熟练)

  1. 保证可见性:一个线程修改volatile变量后,其他线程立即可见。底层通过lock前缀指令触发缓存一致性协议(MESI),写变量时强制将缓存行写回主存并使其他CPU核的缓存行失效,其他核读时必须从主存重新加载。
  2. 禁止指令重排序:通过内存屏障限制编译器和CPU的指令重排。

不保证原子性volatile int i; i++不是原子操作(读→改→写三步),多线程并发自增仍然会丢失更新。

二、内存屏障详解(深度层,拉开分差)

内存屏障是一条CPU指令,强制在其前后的内存操作满足特定的执行顺序。Java的volatile通过JMM定义的四类屏障保证了Happens-Before语义:

屏障类型指令作用
LoadLoadLoad1; LoadLoad; Load2保证Load1在Load2之前完成(防止读操作重排到前面的读之前)
StoreStoreStore1; StoreStore; Store2保证Store1在Store2之前完成(防止写操作重排到前面的写之前)
LoadStoreLoad1; LoadStore; Store2保证Load1在Store2之前完成(防止写操作重排到前面的读之前)
StoreLoadStore1; StoreLoad; Load2保证Store1在Load2之前完成(防止读操作重排到前面的写之前)最重的一类屏障,同时具备其他三种效果,通常由 mfencelock addl指令实现

volatile的内存屏障插入策略(JMM规范):

1
2
3
4
5
6
7
8
9
// volatile 写之前
StoreStore屏障   ← 保证volatile写之前的普通写操作对volatile写可见
// volatile 写
StoreLoad屏障    ← 保证volatile写之后的读操作不会被重排到volatile写之前

// volatile 读
LoadLoad屏障    ← 保证volatile读之后的普通读不会被重排到volatile读之前
// volatile 读
LoadStore屏障   ← 保证volatile读之后的普通写不会被重排到volatile读之前

为什么StoreLoad屏障最重? 它要求在执行StoreLoad之后的任何Load指令前,Store缓冲区中的所有数据必须全部刷新到主存(或者至少对其他处理器可见)。在x86平台上,StoreLoad通常由 mfence(全屏障)或带lock前缀的指令实现,开销是普通指令的几十到上百倍。而LoadLoad、StoreStore、LoadStore在x86上通常无需额外指令——x86本身就是强内存模型(TSO),只允许Store-Load重排,其他重排在硬件层面已被禁止。所以在x86上,volatile的实际开销主要来自StoreLoad屏障。

三、volatile实用场景(实战层,证明你真正用过)

场景1:DCL(双重检查锁定)单例中的volatile(经典面试题):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
public class DeviceConfigManager {
    private static volatile DeviceConfigManager instance;

    public static DeviceConfigManager getInstance() {
        if (instance == null) {                          // ①第一次判空
            synchronized (DeviceConfigManager.class) {
                if (instance == null) {                  // ②第二次判空
                    instance = new DeviceConfigManager(); // ③危险操作
                }
            }
        }
        return instance;
    }
}

为什么 instance必须用volatile? 关键在第③行 new DeviceConfigManager(),这行代码在JVM层面分三步:

1
2
3
1. memory = allocate()      // 分配内存空间
2. ctorInstance(memory)     // 初始化对象(执行构造方法)
3. instance = memory        // 将引用指向分配的内存地址

如果不用volatile,步骤2和3可能被重排成1→3→2。线程A执行 new指令时刚好走到"分配内存→赋值引用"但构造方法还没执行完(对象是半成品),线程B走到第①行 if (instance == null)发现instance非空,直接返回了一个没有初始化完成的对象——后续使用时NullPointerException或数据错乱。volatile的StoreStore屏障保证了步骤2一定在步骤3之前完成(写前插入StoreStore:ctorInstance; StoreStore; instance=memory),线程B读instance时LoadLoad屏障保证读到的对象一定是初始化完毕的。

场景2:线程安全的状态标志位(实战项目):

安全生产平台,定位计算引擎需要定期从配置文件刷新定位参数(基站坐标、校准因子)。这个开关被多个工作线程读取、被定时任务线程修改:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class PositionEngine {
    // 不用volatile——reload线程改了flag,worker线程可能永远看不到
    // private boolean configChanged = true;

    // 用volatile——reload线程一改,所有worker线程下次循环立即感知
    private volatile boolean configChanged = true;
    private PositionConfig config;  // 注意:config本身不是volatile

    // 定时任务线程每5分钟刷新一次
    public void reloadConfig() {
        PositionConfig newConfig = loadFromRemote();   // ①先加载完整新配置
        this.config = newConfig;                        // ②再赋值给config
        this.configChanged = false;                     // ③最后改标记
        // volatile写前的StoreStore屏障保证①②③不会重排——
        // 所以configChanged变为false时,config一定是新值
    }

    // 几十个工作线程每秒调用数千次
    public Position calcPosition(Device device) {
        if (configChanged) {
            // volatile读后的LoadLoad屏障保证——
            // 读到configChanged=true时,config一定已经是最新赋值
            updateLocalConfig(this.config);
            configChanged = false;
        }
        // ... 定位计算,用本地缓存的config,不走volatile,零开销
    }
}

这个场景volatile的妙处:配置变量 config本身不需要volatile修饰。volatile只用于 configChanged这个"开关变量",利用volatile写前StoreStore屏障保证"先赋值config、再改标记"的写入顺序,利用volatile读后LoadLoad屏障保证"先读标记确认、再读config内容"的读取顺序。这种"volatile变量守卫普通变量"的模式比把所有变量都标volatile高效得多。

场景3:安全发布(Safe Publication)

在物联平台设备接入时,每个设备连接对应的Handler需要持有设备基础信息。这个信息在主线程初始化,被NIO的Worker线程读取:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class DeviceHandler {
    private volatile DeviceInfo deviceInfo;  // 必须volatile

    // 主线程启动时调用一次
    public void init(String deviceId) {
        DeviceInfo info = new DeviceInfo(deviceId);  // ①构造完整对象
        info.setProtocol("modbus");                    // ②设置所有属性
        info.setLocation("井下一区");
        this.deviceInfo = info;                        // ③发布——volatile写
        // StoreStore屏障:保证①②所有初始化在③赋值之前被所有线程可见
    }

    // Netty Worker线程读取
    public void onData(byte[] data) {
        DeviceInfo info = this.deviceInfo;
        if (info != null) {
            // LoadLoad屏障:读到的info对象一定是完整初始化的
            String protocol = info.getProtocol();  // 必然返回"modbus",不会是null
        }
    }
}

如果没有volatile,Worker线程可能看到 deviceInfo非空(引用已赋值),但 info对象的内部属性还是初始值(构造函数未完整执行被重排),导致 getProtocol()返回null而NPE。

陷阱提示:说"volatile能保证原子性"——致命错误(i++反例);说"所有共享变量都需要volatile"——过于暴力,性能代价大;知道四种屏障名字但说不出各自阻止什么重排;不知道DCL单例为什么必须volatile(JDK 5之前volatile语义不够强,DCL有bug);不知道x86只需要StoreLoad屏障(LoadLoad/StoreStore/StoreLoad在x86上硬件已保证,伪共享除外)。

本作品采用 CC BY-NC-SA 4.0 协议进行许可
使用 Hugo 构建
主题 StackJimmy 设计