FEATURED · 精选文章

深入理解volatile:从JMM内存屏障到DCL单例实战

发布时间 / 2026/9/20 10:26:02
来源 / 创域科博编辑部
栏目 / 资讯中心
深入理解volatile:从JMM内存屏障到DCL单例实战 1. 从一段诡异的死循环代码说起如果你写过并发程序大概率遇到过这种场景主线程把flag置为true子线程里明明写的是while(!flag){}结果子线程像被钉死了一样永远出不来。加个打印语句它又正常了去掉打印又卡住。这种薛定谔的 bug十有八九是volatile在作祟——或者说是你没用volatile。volatile是 Java 并发编程里最容易被低估、也最容易被误解的关键字。很多人背面试题时能脱口而出保证可见性、不保证原子性、禁止指令重排但真到写代码时要么该用不用导致诡异的可见性问题要么滥用一通以为它能替代锁。这篇内容我打算把volatile从 JMMJava 内存模型底层一路讲到单例模式、状态标志、DCL 双重检查锁这些真实场景把原理、字节码、汇编屏障、踩坑经验全部摊开讲清楚。不管你是刚学 Java 基础的新手还是准备面试八股文的同学或者是写了几年业务代码但对并发始终心里没底的后端看完应该都能对volatile有一个从 CPU 缓存到业务代码的完整认知。我不会只给你结论而是把为什么需要它它到底做了什么什么时候不该用它这三件事讲透。因为并发编程这东西光背结论是没用的你得理解硬件和 JVM 到底在背后搞了什么小动作。2. volatile 到底解决了什么问题可见性与有序性的本质2.1 一个不加 volatile 的经典翻车现场先看一段几乎每个 Java 开发者都该跑一遍的代码public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws InterruptedException { new Thread(() - { System.out.println(子线程开始等待...); while (!flag) { // 空转 } System.out.println(子线程看到了 flag 变化退出); }).start(); Thread.sleep(1000); flag true; System.out.println(主线程已把 flag 置为 true); } }在大多数现代机器上跑这段代码子线程很可能永远不会打印退出。为什么因为主线程修改的flag只写进了自己的工作内存可以粗略理解为 CPU 缓存 寄存器没有及时刷回主内存而子线程一直在读自己工作内存里的flag副本那个副本永远是false。这里要澄清一个常见误解很多人以为工作内存是 JVM 里一块真实存在的内存区域。其实不是。JMM 是一套抽象规范所谓工作内存落到硬件上就是 CPU 的 L1/L2 缓存、写缓冲区Store Buffer、无效化队列Invalidate Queue这些东西。JVM 只是用主内存/工作内存这套说法把复杂的硬件缓存一致性问题包装成一个程序员能理解的模型。2.2 可见性问题的硬件根源缓存与写缓冲要真正理解volatile得先知道 CPU 为了性能做了哪些坏事。现代 CPU 的运算速度比内存快两个数量级所以每个核心都有自己的缓存。核心 A 改了变量核心 B 的缓存里还是旧值这就是缓存不一致。为了解决这个问题硬件层面搞出了 MESI 之类的缓存一致性协议当一个核心修改了某条缓存行其他核心对应的缓存行会被标记为 Invalid下次读的时候必须重新从内存加载。听起来很完美对吧但为了进一步榨性能CPU 又引入了Store Buffer写缓冲区。核心写数据时先写进 Store Buffer然后异步刷到缓存这样核心不用等缓存一致性握手完成就能继续执行。问题是别的核心可能在你刷出去之前就读了旧值。同时还有Invalidate Queue无效化队列收到无效化消息的核心不会立刻处理而是先塞进队列导致它可能继续用旧缓存。这两样东西合起来就是可见性问题的物理根源。volatile在 JVM 层面做的事情本质上就是通过插入内存屏障Memory Barrier强制把 Store Buffer 刷掉、把 Invalidate Queue 处理掉让变量的读写绕过缓存直达主内存。2.3 有序性问题编译器和 CPU 都在偷偷重排除了可见性还有更隐蔽的指令重排序。看这段代码int a 0; boolean ready false; // 线程 1 a 1; ready true; // 线程 2 if (ready) { System.out.println(a); // 可能打印 0 }直觉上线程 1 先写a再写ready线程 2 看到ready true时a应该已经是 1 了。但现实是编译器可能把ready true提到a 1前面CPU 也可能因为乱序执行做同样的事。结果线程 2 看到ready为真时a还是 0。重排序分三种编译器重排序JIT 优化、CPU 指令级重排序乱序执行、内存系统重排序Store Buffer 导致的写延迟。这三种在单线程下都不会改变语义但在多线程下就是灾难。volatile的第二个作用就是通过内存屏障禁止特定位置的重排序。2.4 volatile 的语义定义JMM 给出的三条承诺JMM 对volatile的定义可以浓缩成三条可见性对一个volatile变量的写happens-before 于后续对它的读。也就是说写操作的结果对后续读该变量的线程立即可见。禁止重排序volatile写之前的操作不能重排到写之后volatile读之后的操作不能重排到读之前。这就是所谓的内存屏障语义。不保证原子性volatile只保证单次读或单次写的原子性i这种复合操作依然不安全。第三条是面试重灾区后面会专门用一节讲清楚为什么volatile int i; i是错的。3. 从字节码到 CPU 屏障volatile 的底层实现链路3.1 字节码层面ACC_VOLATILE 标志位先看最表层。写一个最简单的类public class VolatileBytecode { private volatile int value; public void setValue(int v) { this.value v; } public int getValue() { return this.value; } }用javap -v VolatileBytecode反编译你会看到字段value的描述符里多了一个访问标志private volatile int value; descriptor: I flags: ACC_PRIVATE, ACC_VOLATILEACC_VOLATILE就是 JVM 识别volatile的标记。JIT 编译器在编译方法时看到这个标志就会在生成的机器码里插入屏障。注意解释执行阶段和JIT 编译阶段对volatile的处理略有不同但语义一致。这也是为什么有些并发 bug 在解释执行时看不出来一旦触发 JIT 编译就暴露了——因为 JIT 做了更激进的重排序优化。3.2 JMM 规定的四种内存屏障JMM 定义了四种屏障volatile的读写会按规则插入它们屏障类型作用LoadLoad禁止读-读重排前面的读先于后面的读完成StoreStore禁止写-写重排前面的写先刷出后面的写才能执行LoadStore禁止读-写重排StoreLoad禁止写-读重排最重的屏障通常要清空 Store Buffer具体插入规则是volatile 写之前插入 StoreStore 屏障保证前面的普通写都刷出去了再执行 volatile 写。volatile 写之后插入 StoreLoad 屏障保证 volatile 写完成后后续的读写才执行。这个屏障开销最大。volatile 读之后插入 LoadLoad 和 LoadStore 屏障保证 volatile 读完成后后续的读写才执行。用一张表总结就是操作前屏障后屏障volatile 写StoreStoreStoreLoadvolatile 读无LoadLoad LoadStore3.3 x86 上的落地lock 前缀指令理论说完了看实际。在 x86 架构上JIT 对volatile写的处理通常是在写指令前加一个lock前缀比如lock addl $0x0,(%rsp)。这个lock前缀干了三件事把当前处理器缓存行的数据写回内存。使其他 CPU 里对应的缓存行失效。相当于一个全屏障禁止该指令前后的读写重排。而volatile读在 x86 上几乎不需要额外指令因为 x86 本身是强内存模型读-读、读-写不会重排只有写-读会重排。所以volatile读的开销在 x86 上非常小volatile写的开销主要来自lock前缀。提示这也是为什么很多人说volatile 读性能接近普通读写性能明显下降。在 ARM 这类弱内存模型架构上volatile 读也需要显式屏障开销会更大。做性能敏感的场景时架构差异要考虑进去。3.4 happens-before 规则里的 volatileJMM 的 happens-before 规则里有一条专门讲volatile对一个 volatile 变量的写happens-before 于任意后续对这个 volatile 变量的读。这条规则是传递的。也就是说如果线程 A 写了volatile x线程 B 读了x并看到新值那么线程 A 在写x之前的所有操作对线程 B 读x之后的所有操作都可见。这就是volatile能当轻量级同步用的理论基础。举个具体例子// 线程 A data 42; // 普通写 ready true; // volatile 写 // 线程 B if (ready) { // volatile 读 System.out.println(data); // 保证看到 42 }因为data 42happens-beforeready true程序顺序ready truehappens-before 线程 B 的ready读volatile 规则再传递到println(data)所以data一定是 42。这个传递性是volatile最实用的地方也是 DCL 单例能成立的关键。4. 那些年我们一起踩过的 volatile 坑4.1 坑一以为 volatile 能保证 i 的原子性这是最经典的错误。看代码public class Counter { private volatile int count 0; public void increment() { count; } }开 10 个线程各跑 1000 次increment()最后count大概率小于 10000。为什么因为count不是一步操作它编译后大致是getfield count // 读 iconst_1 iadd // 加 putfield count // 写volatile只保证读和写这两步各自是原子的、可见的但读-加-写这三步之间可以被其他线程插进来。线程 A 读到 5线程 B 也读到 5两个都写 6一次自增就丢了。正确做法是用AtomicInteger的incrementAndGet()或者用synchronized。AtomicInteger底层靠 CASCompare-And-Swap保证原子性配合volatile保证可见性是自增场景的标准答案。注意volatile只对单次读或单次写保证原子性。像long和double这种 64 位类型在 32 位 JVM 上普通读写可能被拆成两次 32 位操作加volatile能保证其读写原子性。但i、i 1、i i 1这类复合操作volatile一律不管。4.2 坑二DCL 单例里漏写 volatile双重检查锁Double-Checked Locking单例是面试必考也是踩坑重灾区public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); } } } return instance; } }很多人问instance为什么必须加volatile因为new Singleton()不是原子的它分三步分配内存空间。初始化对象调用构造方法。把instance指向这块内存。如果没有volatile步骤 2 和 3 可能被重排成 3 先于 2。此时线程 A 执行到步骤 3 但还没初始化完线程 B 进来第一次检查发现instance ! null直接返回一个半成品对象用的时候各种诡异 NPE 或者字段是默认值。加上volatile后instance new Singleton()这个写操作前的 StoreStore 屏障会禁止步骤 2 和 3 重排保证对象完全初始化后才赋值。这就是volatile在 DCL 里的核心价值——禁止重排序而不是可见性。4.3 坑三把 volatile 当锁用有人觉得volatile比synchronized轻量就想用它替代锁。比如想实现一个检查再执行的逻辑private volatile boolean initialized false; public void init() { if (!initialized) { // 做初始化 initialized true; } }这段代码在多线程下完全不可靠。两个线程可能同时通过if (!initialized)检查都执行初始化。volatile保证的是可见性不是互斥性。要保证只有一个线程执行必须用锁或 CAS。判断标准很简单如果你的操作涉及读-改-写或者检查-执行这种复合逻辑volatile 就不够用。volatile只适合一个线程写、多个线程读的状态标志场景。4.4 坑四volatile 数组的迷惑行为private volatile int[] arr new int[10];这行代码里volatile修饰的是数组引用不是数组元素。也就是说arr new int[10]这个赋值有 volatile 语义但arr[0] 1这种元素写操作没有任何 volatile 保证。想让元素也有 volatile 语义得用AtomicIntegerArray这类工具类。这个坑很隐蔽因为语法上看起来volatile就在数组旁边容易让人误以为整个数组都受保护。4.5 坑五volatile 与 final 的微妙关系final字段在构造完成后对其他线程可见这是 JMM 对final的特殊保证。但如果你在构造方法里把this引用泄露出去比如注册监听器、启动线程其他线程可能看到final字段的默认值。这时候volatile也救不了你因为问题出在对象逸出而不是字段可见性。正确做法是避免在构造方法里泄露this或者用工厂方法在对象完全构造后再发布。5. volatile 的正确打开方式四个真实场景5.1 场景一状态标志位这是volatile最纯粹、最安全的用法public class TaskRunner { private volatile boolean stopped false; public void run() { while (!stopped) { // 执行任务 } } public void stop() { stopped true; } }一个线程写stopped另一个线程读没有复合操作volatile完美胜任。这种模式在框架里到处都是比如线程池的关闭标志、Netty 的EventLoop状态位。5.2 场景二一次性安全发布结合前面讲的 happens-before 传递性volatile可以用来安全发布对象public class SafePublisher { private volatile Config config; public void init() { Config c new Config(); c.setTimeout(3000); c.setRetries(5); config c; // volatile 写之前的设置都可见 } public Config getConfig() { return config; // volatile 读之后的读都能看到完整配置 } }只要config是volatileinit()里对c的所有设置都会在config c之前完成并对读线程可见。这比用锁轻量得多适合配置初始化这种写一次读多次的场景。5.3 场景三DCL 单例前面已详述再强调一遍关键点DCL 里volatile的作用是禁止对象构造的重排序防止其他线程拿到半初始化对象。这是volatile有序性语义的经典应用。5.4 场景四配合 CAS 实现无锁结构AtomicInteger、AtomicReference这些类的内部字段都是volatile的public class AtomicInteger extends Number { private volatile int value; public final int incrementAndGet() { return U.getAndAddInt(this, VALUE, 1) 1; } }volatile保证value的可见性CAS 保证更新的原子性两者配合才能实现无锁并发。单独用任何一个都不行。理解这一点你就理解了整个java.util.concurrent.atomic包的设计哲学。6. volatile 与 synchronized、CAS 的选型对照6.1 三者能力对比特性volatilesynchronizedCASAtomicXxx可见性保证保证保证依赖 volatile原子性仅单次读写保证保证有序性保证保证保证阻塞不阻塞阻塞不阻塞自旋适用场景状态标志、DCL复合操作、临界区计数器、无锁栈/队列性能读几乎无开销写有屏障开销有锁开销竞争激烈时升级重量级锁低竞争时快高竞争时自旋耗 CPU6.2 选型决策树我一般按这个顺序判断操作是不是单次读或单次写是的话优先volatile。是不是读-改-写或检查-执行是的话看竞争程度。低竞争用AtomicXxx高竞争或逻辑复杂用synchronized。临界区里有没有多个变量需要保持一致有的话必须用锁volatile和 CAS 都搞不定多变量的原子性。需不需要阻塞等待条件需要的话用LockCondition或synchronizedwait/notify。6.3 一个容易忽略的性能细节volatile写的lock前缀会清空 Store Buffer这个操作在多核机器上开销不小。如果你的代码里有一个高频写的volatile变量比如每秒写几百万次性能会明显下降。这时候可以考虑批量写 一次 volatile 发布的模式// 不推荐每次更新都 volatile 写 volatile int a, b, c; // 推荐普通写 一次 volatile 发布 int a, b, c; volatile boolean published; void update() { a 1; b 2; c 3; published true; // 一次 volatile 写前面三个都可见 }这个技巧在 Disruptor 这类高性能框架里用得很多核心思想就是把多次 volatile 写合并成一次。7. 面试高频追问与我的应答思路7.1 volatile 能保证原子性吗标准答案是不能只保证单次读写原子性。但面试官往往想听更深的东西。我会补一句volatile的原子性保证仅限于变量本身的读写对于long/double在 32 位 JVM 上的非原子读写问题volatile能解决但对于i这种复合操作必须靠 CAS 或锁。这样答既准确又展示了知识边界。7.2 volatile 和 synchronized 的区别从三个维度答原子性synchronized 保证volatile 不保证复合操作、阻塞性synchronized 会阻塞volatile 不会、适用场景volatile 适合状态标志synchronized 适合临界区。再补一句底层实现synchronized 靠 monitor 对象和 monitorenter/monitorexit 字节码volatile 靠内存屏障。7.3 为什么 DCL 单例要加 volatile这是考察有序性的经典题。答的时候一定要点出new的三步分解以及步骤 2、3 可能重排导致半初始化对象逸出。能画出这个重排过程基本就过关了。7.4 volatile 的内存屏障是怎么插入的进阶题。答 JMM 的四种屏障LoadLoad、StoreStore、LoadStore、StoreLoad以及 volatile 写前 StoreStore、写后 StoreLoad、读后 LoadLoadLoadStore 的规则。再补一句 x86 上落地为lock前缀指令ARM 上需要显式dmb指令就能体现你对硬件的理解。7.5 volatile 能替代锁吗不能。volatile只解决可见性和有序性不解决互斥性。任何涉及多个线程竞争修改同一状态的场景都需要锁或 CAS。这个问题的陷阱在于诱导你说能一定要明确否定并给出理由。8. 我个人的一些实战体会写了这么多年并发代码我对volatile最大的体会是它是一把手术刀不是万能钥匙。用对了地方它能以极低的成本解决可见性和有序性问题用错了地方它带来的 bug 比不用还难查因为可见性问题往往依赖特定的硬件、JIT 时机和线程调度本地跑一万次没问题上线跑一次就炸。我的习惯是只要一个字段被多个线程访问先问自己三个问题——有没有写写是不是只有一处读是不是只关心最新值三个都是是才考虑volatile。只要有一个否就老老实实上锁或者用java.util.concurrent里的工具类。JDK 已经把绝大多数并发场景封装好了ConcurrentHashMap、AtomicInteger、CopyOnWriteArrayList这些类内部都处理好了volatile和 CAS 的配合能用现成的就别自己造轮子。最后分享一个调试可见性问题的小技巧如果你怀疑某个字段有可见性问题可以临时把它改成volatile跑一遍。如果问题消失基本就坐实了如果问题还在那多半是原子性问题或者逻辑问题得往锁的方向查。这个二分法能帮你快速缩小排查范围比盯着代码干想要高效得多。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻