§2.2.2 volatile深入
考察意图:volatile是面试高频基础题。初级答"保证可见性+禁止重排序",中极答内存屏障语义,高级能讲出实际项目中用它解决过什么并发bug。
一、volatile的两个核心作用(基础层,必须熟练)
- 保证可见性:一个线程修改volatile变量后,其他线程立即可见。底层通过lock前缀指令触发缓存一致性协议(MESI),写变量时强制将缓存行写回主存并使其他CPU核的缓存行失效,其他核读时必须从主存重新加载。
- 禁止指令重排序:通过内存屏障限制编译器和CPU的指令重排。
不保证原子性:volatile int i; i++不是原子操作(读→改→写三步),多线程并发自增仍然会丢失更新。
二、内存屏障详解(深度层,拉开分差)
内存屏障是一条CPU指令,强制在其前后的内存操作满足特定的执行顺序。Java的volatile通过JMM定义的四类屏障保证了Happens-Before语义:
| 屏障类型 | 指令 | 作用 |
|---|---|---|
| LoadLoad | Load1; LoadLoad; Load2 | 保证Load1在Load2之前完成(防止读操作重排到前面的读之前) |
| StoreStore | Store1; StoreStore; Store2 | 保证Store1在Store2之前完成(防止写操作重排到前面的写之前) |
| LoadStore | Load1; LoadStore; Store2 | 保证Load1在Store2之前完成(防止写操作重排到前面的读之前) |
| StoreLoad | Store1; StoreLoad; Load2 | 保证Store1在Load2之前完成(防止读操作重排到前面的写之前)最重的一类屏障,同时具备其他三种效果,通常由 mfence或 lock addl指令实现 |
volatile的内存屏障插入策略(JMM规范):
| |
为什么StoreLoad屏障最重? 它要求在执行StoreLoad之后的任何Load指令前,Store缓冲区中的所有数据必须全部刷新到主存(或者至少对其他处理器可见)。在x86平台上,StoreLoad通常由 mfence(全屏障)或带lock前缀的指令实现,开销是普通指令的几十到上百倍。而LoadLoad、StoreStore、LoadStore在x86上通常无需额外指令——x86本身就是强内存模型(TSO),只允许Store-Load重排,其他重排在硬件层面已被禁止。所以在x86上,volatile的实际开销主要来自StoreLoad屏障。
三、volatile实用场景(实战层,证明你真正用过)
场景1:DCL(双重检查锁定)单例中的volatile(经典面试题):
| |
为什么 instance必须用volatile? 关键在第③行 new DeviceConfigManager(),这行代码在JVM层面分三步:
| |
如果不用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:线程安全的状态标志位(实战项目):
安全生产平台,定位计算引擎需要定期从配置文件刷新定位参数(基站坐标、校准因子)。这个开关被多个工作线程读取、被定时任务线程修改:
| |
这个场景volatile的妙处:配置变量 config本身不需要volatile修饰。volatile只用于 configChanged这个"开关变量",利用volatile写前StoreStore屏障保证"先赋值config、再改标记"的写入顺序,利用volatile读后LoadLoad屏障保证"先读标记确认、再读config内容"的读取顺序。这种"volatile变量守卫普通变量"的模式比把所有变量都标volatile高效得多。
场景3:安全发布(Safe Publication):
在物联平台设备接入时,每个设备连接对应的Handler需要持有设备基础信息。这个信息在主线程初始化,被NIO的Worker线程读取:
| |
如果没有volatile,Worker线程可能看到 deviceInfo非空(引用已赋值),但 info对象的内部属性还是初始值(构造函数未完整执行被重排),导致 getProtocol()返回null而NPE。
陷阱提示:说"volatile能保证原子性"——致命错误(i++反例);说"所有共享变量都需要volatile"——过于暴力,性能代价大;知道四种屏障名字但说不出各自阻止什么重排;不知道DCL单例为什么必须volatile(JDK 5之前volatile语义不够强,DCL有bug);不知道x86只需要StoreLoad屏障(LoadLoad/StoreStore/StoreLoad在x86上硬件已保证,伪共享除外)。