☰
Java并发之volatile与JMM底层原理剖析
2026/10/10 0:15:21 网站建设 项目流程

并发编程里有个老生常谈却又极容易翻车的概念,就是volatile关键字和它背后的 Java 内存模型(JMM)。网上聊这个的帖子很多,但要么太理论,要么只讲了个皮毛,面试的时候一问“为什么 volatile 能保证可见性却保证不了原子性”,很多人就卡住了。这篇我打算换个角度,从底层层层拆开,把 volatile 和 JMM 的来龙去脉讲透,并且给出我自己在实际项目里踩过的坑和排查思路。

这篇东西适合谁看呢?如果你是还没入门的 Java 新手,可以把它当成一篇“并发避坑指南”;如果你是有几年经验但一直没细究底层的工程师,那这篇能帮你把零散的知识点串成网;如果你正在准备面试,那 volatility+JMM 这道高频题,看完这篇至少能给你提供一套完整的回答脉络。

1. 为什么并发编程绕不开 JMM

1.1 硬件与 Java 代码之间的“翻译官”

很多人觉得 Java 写的线程操作变量是直接对着内存操作的,实际上这是错觉。count++这行代码,在物理层面上要经过 CPU 计算、CPU 缓存、内存控制器多道坎,才会真正把数值写回内存。如果多线程同时跑,各自在 CPU 缓存里看到的count值就可能不一样,逻辑上就乱套了。

JMM(Java Memory Model)就是一套规则,它定义了线程在什么条件下能看到共享变量的最新值,什么时候必须把自己的修改刷回主内存,以及编译器、CPU 为了性能做了哪些重排序之后,程序员感知到的执行结果应该是什么样的。

举个生活化的例子,假设你和你同事在同一个群里改一份共享的排期表。你们每个人先把排期表下载到自己电脑上编辑,改完再把文件上传上去替换。问题就来了:如果你同事在你上传之前就用他本地那份旧的表格做了决定,那你的修改对他来说等于不存在。JMM 规定得就是“什么时候必须刷新、什么时候必须从群文件重新拉取新版本”这套同步规则。

1.2 可见性、有序性、原子性

JMM 的核心目标就三条,面试题也常从这三性切入:

  • 可见性(Visibility):一个线程对共享变量的修改,另一个线程能否立刻看到。
  • 有序性(Ordering):程序代码的执行顺序,在多线程环境下是否还是按代码顺序呈现,会不会被重排序。
  • 原子性(Atomicity):一个操作或一系列操作要么全部执行成功,要么全部不执行,中途不能被其他线程插一脚。

synchronized和volatile本质上是围着这三性做文章,只是侧重点完全不同。细节后面展开。

1.3 JMM 与 JVM 内存区域要区分开

初学的时候非常容易混淆:JMM 和 JVM 运行时数据区(堆、栈、元空间)不是一回事。JVM 内存区域说的是“Java 程序运行时数据放在哪”,JMM 说的是“多线程下数据怎么流动、怎么同步、怎么保证一致性”,相当于一个是地图,一个是交通规则。

堆内存属于线程共享的物理存储区域,但 JMM 抽象出来的概念是主内存和每个线程专属的工作内存(对应 CPU 缓存 + 寄存器那一层)。线程对变量的所有操作都必须在工作内存中进行,不能直接操作主内存;线程之间也不能直接访问对方的工作内存,只能通过主内存中转。这个抽象模型,是理解 volatile 可见性机制的第一块拼图。

2. 深度拆解 Volatile:它到底干了什么

2.1 volatile 的两个承诺

volatile修饰的变量,JMM 对它的读写操作有两条硬性规定:

  1. 写 volatile 变量时,JMM 会把该线程工作内存中的最新值强制刷新到主内存。
  2. 读 volatile 变量时,JMM 会把主内存中该变量的值强制拉回到工作内存,直接失效本地缓存。

这两条合起来,就是大家常说的“volatile 保证可见性”。听起来很简单,但它背后的语义还有很多细节。

为了把这两条做到,JMM 还给 volatile 的读写插入了内存屏障(Memory Barrier)。内存屏障是一组 CPU 指令,它禁止编译器或 CPU 把屏障两侧的指令做重排序,同时强制缓存失效或刷新。我在本地跑过一个半吊子 JMH 测试,纯 volatile 的读写性能大约是普通字段的 2 到 3 倍耗时,因为每次读写都带着屏障的额外开销,这也是它为什么不能替代一切同步机制的原因之一。

2.2 volatile 不保证原子性

这是 depth 里最容易踩的点。i++这种操作,表面一行代码实际是三步:读旧值、加一、写回新值。volatile 能保证读到的旧值是最新的、写回的值能立刻让其他线程看见,但没办法保证“读-改-写”这三步作为一个整体不被打断。

我之前写并发计数器,用的就是 volatile int,结果线上单量一大就开始漏记。后来看过一个线程安全的计数器,它的自增操作被拆成了多条字节码指令,volatile 根本没插上手,必须依赖AtomicInteger或者synchronized。

一句话总结:volatile 解决的是“看到”的问题,不解决“抢”的问题。

2.3 有序性与重排序

编译器为了性能会对指令重排,CPU 也会乱序执行。单线程下这没问题,因为有 happens-before 规则兜底,最终结果跟代码顺序一致;但多线程下就可能会出错。

volatile 通过内存屏障,限制了它前后的重排序:

两个关键规则:

  • 对 volatile 变量的写操作,前面的代码中不能有任何重排序越过这次写。这意味着写 volatile 之前的普通写操作也必须停留在写之前,不会被乱序到写之后。
  • 对 volatile 变量的读操作,后面的代码中不能有任何重排序越过这次读。这意味着读 volatile 之后的普通读不会跑到读之前去。

这套规则的直接应用就是经典的双重检查锁(DCL)单例。如果不加 volatile,在极端情况下可能拿到一个“半初始化”的对象:此时对象引用已经非空,但对象的构造方法还没完全执行完,另一个线程读到这个引用后直接拿去用,问题就完全不可控了。加了 volatile,JMM 会保证引用的发布不会被重排序到构造函数完成之前,从而规避这个隐患。

3. Volatile 的底层实现:从字节码到 CPU 屏障

3.1 加了 volatile 之后,代码发生了什么

先看字节码层面。给一个静态变量加上 volatile 之后,编译出来的字节码其实和普通字段几乎一模一样,变的不是字节码,而是 JIT 编译器后续生成本地指令时的策略。

HotSpot 虚拟机实际做的事情有两类:

  • 写操作会生成带有Lock 前缀的指令,这个 lock 会促使处理器将该核心的写缓冲刷新到内存,同时让其他核心的缓存行失效。
  • 读操作会根据具体 CPU 架构插入load 屏障,让缓存行失效,从而强制从内存获取最新值。

3.2 MESI 缓存一致性协议

现代多核 CPU 是各自有 L1、L2 缓存的,一个核心改了数据,另一个核心怎么知道自己缓存旧了?这依赖缓存一致性协议。Intel 家族常用的叫 MESI,每一个缓存行有四种状态:Modified(修改)、Exclusive(独占)、Shared(共享)、Invalid(失效)。

当某个核心要写一个 Shared 状态的变量,它会向总线发信号,让其他核心把对应缓存行标记为 Invalid;之后再有核心去读这个变量,发现缓存行是 Invalid,就只能老老实实到内存拉一份新的。volatile 的“每次写强制刷主存、每次读强制拉新值”,本质上就是在利用 MESI 这类协议实现的一致性语义。

所以网上有人把 volatile 比喻成“每次操作都带着大喇叭喊话,让所有人知道有更新”,从缓存协议的角度来说还挺贴切的。

3.3 内存屏障的细化

在 x86 架构上,乱序执行的能力相对有限,JMM 为了兼顾不同架构,定义了好几类屏障:

屏障类型含义典型场景
LoadLoad禁止上面的读被下面的读重排序volatile 读后的普通读
StoreStore禁止上面的写被下面的写重排序volatile 写前的普通写
LoadStore禁止上面的读被下面的写重排序volatile 读后的普通写
StoreLoad禁止上面的写被下面的读重排序volatile 写后的普通读

x86 上实际只需要 StoreLoad 这一类的完整屏障,其他几个在极端场景才需要补。这也是为什么很多人说“x86 下 volatile 开销不算特别大”的原因。

4. happens-before:JMM 的完整契约

4.1 八条规则串起并发安全

JMM 里最核心的规则叫 happens-before,它定义了哪些操作之间的顺序是对线程可见的。主要内容如下:

  • 程序次序规则:一个线程内,写的代码在上面的操作先行发生于下面的操作。
  • 监视器锁规则:一个 unlock 操作先行发生于后面对同一把锁的 lock 操作。
  • volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对该 volatile 变量的读操作。
  • 线程启动规则:Thread.start() 先行发生于该线程内的所有操作。
  • 线程终止规则:线程中所有操作先行发生于其他线程检测到这个线程已终止。
  • 线程中断规则:调用 interrupt() 先行发生于被中断线程的 interrupted() 检测。
  • 对象终结规则:一个对象的构造函数执行完,先行发生于 finalize() 的启动。
  • 传递性:如果 A happens-before B,B happens-before C,那么 A happens-before C。

这些规则最大的意义是,它给了程序员一套推理工具,不用每次深入到 CPU 级别,也能判断自己写的并发代码有没有问题。

4.2 volatile 与 happens-before 的关系

volatile 变量规则是 happens-before 里的重要一条。它的精髓在于写和读之间的一对配对关系:线程 A 对 volatile 变量写了一个值,线程 B 在稍后时间对同一个变量读,B 不仅能读到 A 写进去的值,还能看到 A 在写之前对别的普通变量做的所有修改。

这个特性的应用很广泛。比如我们在项目里用一个 volatile boolean flag 去控制线程优雅关闭:主线程先把一些状态更新完,再置 flag 为 true;工作线程检测到 flag 为 true 时,它不仅能退出循环,也能顺带看到之前在普通变量里更新的状态。这种场景,volatile 共用了多大成本就省下多大成本。

5. Volatile 与 Synchronized 的对比

5.1 从三个维度看透差异

很多人把 volatile 和 synchronized 放在一起选型,但两者其实不在一个维度上。

  • 原子性:synchronized 可以保证临界区内的全部操作原子性,volatile 只能保证单个读或单个写是原子的。
  • 可见性:两者都能保证可见性,synchronized 在锁释放时会强制刷新工作内存到主内存;volatile 每次读写都在做这件事。
  • 阻塞:synchronized 会让未抢到锁的线程阻塞;volatile 完全不阻塞。

5.2 什么时候选 volatile

选 volatile 其实就一个核心判断标准:变量是否只会被单线程写,但会被多线程读。典型的场景有三类:

  1. 状态开关:线程安全的 boolean 开关,控制循环退出。
  2. 双重检查锁:单例里的实例引用。
  3. 安全发布:一个对象的所有内容在构造函数里已经完全初始化,并且引用字段是 volatile,那么其他线程看到引用时,对象内部字段的可见性也能被保证。

反过来,只要有多个线程同时去写那共享变量,或者写操作依赖变量当前值(比如自增、累加、CAS 成功才更新之类),那 volatile 就不够用了。

5.3 synchronized 的粗粒度 vs volatile 的细粒度

synchronized 虽然能同时保证原子性、可见性、有序性,但它太重了。现代的 synchronized 经过锁消除、锁粗化、偏向锁等优化,开销小了很多,但在高并发高频访问的场景下,把它加在一个只读状态变量上还是浪费。

我个人的实践倾向是:能用 volatile 解决的别上锁,上了锁的也别幻想用 volatile 去替代。两者不是替代关系,而是不同粒度的互补工具。

6. Volatile 在经典并发场景中的实战

6.1 案例一:DCL 单例为何必须 volatile

来写个实际代码感受一下。常见的双重检查锁单例:

public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }

这里 volatile 的必要性就体现在new Singleton()这一步。这一步在字节码层面不是一个原子操作,它会先分配内存空间、再调用构造器初始化对象、然后把 instance 引用指向这块内存。如果这三个步骤被重排序,就可能出现“引用已经指向一块未完成初始化的内存”。另一个线程进来后,第一次判断instance == null时看到非 null,直接返回对象,这时候对象内部的字段可能还是默认值。volatile 的写语义和内存屏障就压着它,确保发布操作不会被乱序到初始化之前。

6.2 案例二:优雅停机标志位

线上服务经常要用 flag 控制线程的停止,来段最基础的:

public class Worker extends Thread { private volatile boolean running = true; public void shutdown() { running = false; } @Override public void run() { while (running) { // 干活 } // 收尾逻辑 } }

这里如果 running 不加 volatile,主线程调 shutdown() 时,工作线程可能一直看不到 running 变成了 false,导致停不下来。在以前的内存模型中,确实存在这种“活锁”风险;现在的 JMM 里,这种标志位写法是合法的,而且它比加锁优雅得多。

6.3 案例三:volatile 失效的自增场景复盘

一个很典型的错误代码:

public class Counter { private volatile int count = 0; public void increment() { count++; } }

我之前团队里就有人拿这段代码做统计用的计数器,最后统计值和实际值差了几万。原因是count++这个操作根本不是原子的,多个线程同时读到了旧值,各自加一,最后一次写回覆盖了其他人的结果。类似问题在线上出现过多次之后,我特别提醒一点:看到“volatile 保证可见性”这几个字之后,别顺手就脑补成“线程安全”。

换成AtomicInteger就对了:

private final AtomicInteger count = new AtomicInteger(0); public void increment() { count.incrementAndGet(); }

因为它内部用的是 CAS,能保证“读-比较-写”在硬件层面是原子的。

7. Volatile 在 Java 9+ 中的变化与延伸

7.1 VarHandle 带来的新可能

Java 9 引入了 VarHandle,把原来只能靠Unsafe干的“原子操作”“栅栏操作”以一种更安全的方式放了出来。通过 VarHandle 也可以直接操作普通字段,并且可以定义更细粒度的内存屏障类型。

如果你有高性能需求,想要更加精准地控制内存屏障,可以用 VarHandle 的方法,比如fullFence()、acquireFence()、releaseFence()。但这些 API 接近底层,一般用不太到,了解即可。

7.2 从 volatile 到锁的完整生态

很多新手从 volatile 学到 synchronized,又从 synchronized 学到 AQS,最后才发现整个并发工具包都是在 JMM 之上构建的。市面上常见的并发工具:

  • ConcurrentHashMap:用分段锁 + volatile + CAS 的组合。
  • ThreadPoolExecutor:内部大量使用 volatile 标记工作线程状态。
  • AbstractQueuedSynchronizer (AQS):用 volatile int state 来记录锁状态,同时结合 CAS 修改 state。

很典型的一点是,ThreadPoolExecutor里那个ctl字段用了 AtomicInteger 来同时包装“线程数”和“线程池状态”,而AtomicInteger的实现里又有 volatile 字段 value。所以问题回到根本,还是得先把 volatile 和 JMM 理解扎实,上层的东西才能看得懂。

8. 面试高频追问 Top 5 与避坑经验

8.1 “volatile 能保证原子性吗?”

这道题的标准回答路线是:先说 volatile 保证了可见性和有序性,随后澄清它不能保证复合操作的原子性。最好再补一个i++的字节码证据,说明它是有多条指令。面试官如果有兴趣,你还能引出 MESI 协议和内存屏障,展示你底层深度。

8.2 “volatile 与 synchronized 区别?”

回答的关键是不要背定义,而是说明场景。给一个例子:多读少写用 volatile,复合操作或者多个步骤需要一致状态时用 synchronized,或者ReentrantLock。最好还能说一句:“锁住的是一种互斥关系,volatile 透露的是一种发布订阅关系。”

8.3 “为什么 DCL 需要 volatile?”

要把“半初始化对象”和“指令重排序”串起来讲。正常的new分三步,CPU 可能把“引用指向内存”提前到“初始化完成”之前。volatile 的 StoreStore 屏障保证两步顺序不被打乱。

8.4 “happens-before 里的 volatile 规则是什么?”

这个要答出“写 volatile 先行发生于读同一个 volatile”。可以再举一两个之前说的代码例子,说明写之前的普通变量更新也能被读到。

8.5 “volatile 写一定比普通写慢多少?”

这是个开放题,重点是想考察你对成本的理解。回答方向可以说:volatile 的写会导致缓存行失效和内存刷新的开销,在 MESI 协议下会触发总线事务;但在 x86 上不至于数量级慢,因为它靠缓存一致性协议实现。如果面试官问,还能接着聊 cache line padding 解决伪共享的问题。

8.6 避坑经验汇总

在做后端服务高并发调优时,容易遇到一些看起来和 volatile 相关的问题,但根因不完全在它头上,我把常见坑总结一下:

  • 勿把可见性当原子性:凡是复合操作(读改写、检查再修改)都要上锁或 CAS。
  • 别在写操作上做复杂逻辑:volatile 变量本身不是锁,如果多个线程都要赋予不同值,仍要小心竞态。
  • 注意伪共享(False Sharing):多个线程同时修改不同变量,但它们在同一个缓存行里,如果其中一个写成 volatile,会造成其他变量的缓存行频繁失效。这种场景下,要学会用@Contended注解(Java 8+)或者主动 padding。
  • 结合读场景:读远多于写时,volatile 特别划算;写操作又多又频繁时,性能下降明显,考虑换成原子类或锁。

9. 写在最后的实操体会

我自己最开始学并发编程时,把 volatile 当成一个“万能并发锦囊”,结果在真实的分布式调度项目里挨了一次毒打——用一个 volatile 的 List 引用去存待执行任务,主线程替换 List 时,其他线程读到的引用一直变,但内部元素却偶尔不一致。原因很简单,我替换的是引用(volatile 能保证可见性),但后续操作要是修改 List 内部内容,volatile 管不了。这才真正理解,volatile 只约束变量本身,不约束它指向的对象内部的字段。

后来排查线上问题,我积累了一套习惯,供你参考:

  • 遇到并发数据不一致,先看共享变量的可见性,再看操作序列的原子性,最后梳理重排序的可能。
  • 能复现的 bug 先加打印,打印本身也会影响时序,所以别急着下结论。
  • jstack和jmap常常能帮你确认哪些线程真正阻塞了,哪些线程只是“看得见但拿不到最新值”。

并发编程这条路没有终点,JMM 是地基,volatile 是地基上最细的一根钢筋。先把这根钢筋扎稳,后面再学锁、学并发容器、学 AQS,都会轻松很多。

如果后续有空,我还会再写一篇关于 JMM 里锁升级和轻量级锁的实操拆解,正好可以和这篇文章呼应起来,把那套“从无锁到偏向锁再到重量级锁”的演进讲透。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询