
学多线程的人很少有绕开 volatile 的。面试题里它是常客实际开发里它也是高频关键词——你写一个多线程程序发现某个线程改了状态另一个线程却“看不见”排查到最后十有八九就落在 volatile 上。这篇是之前那篇多线程与异步编程的续篇专门把 volatile 掰开揉碎讲清楚它到底是什么、底层靠什么生效、哪些场景该用、哪些场景千万别用。不太理解内存模型的朋友也能跟上我会尽量用具体的代码和场景说话。先说结论volatile 解决的是多线程下的“可见性”和“有序性”问题它不解决“原子性”。这三件事的区别是理解 volatile 的前提。1. volatile 到底解决了什么问题1.1 并发三大特性先搞懂可见性、原子性、有序性要理解 volatile必须先理解并发编程里三个绕不开的概念可见性、原子性、有序性。很多人一上来就背 volatile 的定义结果换个场景就懵根源就是这三个概念混在一起。可见性一个线程修改了共享变量另一个线程能不能立刻看到。注意不是“最终能看到”而是“立刻”。实际运行中线程读到的可能是 CPU 缓存里的旧值。原子性一个操作要么全部执行完成要么完全不执行中途不能被线程调度打断。比如count这行代码看起来是一条语句底层实际上是“读取-加一-写回”三步不具备原子性。有序性程序代码的执行顺序不一定和源码顺序一致。编译器和 CPU 为了优化会做指令重排在单线程里重排不影响结果但在多线程环境下就可能出问题。volatile 的关键字义正好落在可见性和有序性上。它并不是万能的很多人拿它当“免锁金牌”最后栽了跟头这才是需要重点警惕的地方。1.2 volatile 的边界它只管可见性和有序性我用一个很生活化的例子说明可见性问题。你和同事共享一块白板你在上面写了flag true但同事手里拿的是一张之前复印的旧版上面还是flag false他凭旧复印件判断自然看不到你的修改。在计算机里白板相当于主内存复印件相当于 CPU 缓存线程运行时优先读缓存这就是可见性问题。volatile 做的事情很明确写 volatile 变量时强制把值刷新到主内存让其他线程能读到最新值。读 volatile 变量时强制从主内存读取而不是用 CPU 缓存里的旧值。通过内存屏障禁止指令重排保证有序性。但有个前提必须强调volatile 不保证复合操作的原子性。典型的坑就是volatile int count然后多个线程执行count最终结果一定是错的。因为“读取-加一-写回”这三步之间其他线程完全可以插进来volatile 管不了这一步。很多人实际项目中在这里踩坑后文我会专门展开。2. 从硬件到内存模型volatile 为什么能生效2.1 可见性问题的根源CPU 缓存与缓存一致性很多人不理解为什么一个线程改了变量另一个线程会看不到这里得从硬件层面说起。现代 CPU 的运算速度远超内存访问速度所以 CPU 和内存之间加了多层高速缓存L1、L2、L3。每个 CPU 核心都有自己的 L1/L2 缓存线程跑在某个核心上改一个变量时优先改的是自己核心的缓存而不是直接写主内存。当另一个线程在另一个核心上运行时它读的是那个核心的缓存两边缓存一致性问题就出现了。为了解决这个问题CPU 层面有缓存一致性协议最典型的是 MESI 协议Modified、Exclusive、Shared、Invalid。核心思想是当一个核心修改了缓存里的数据其他核心缓存里对应的数据会被标记为失效下一次读取时必须从主内存重新加载。这就是 volatile 可见性的底层硬件基础。但这还不够。编译器和 CPU 还会做指令重排。比如你写的是flag true; value 42;CPU 可能先把value 42执行完再执行flag true因为这样做在单线程下结果不变还更快。可一旦 flag 是另一个线程的判断条件重排后就会出现我们常说的“乱序执行”问题。volatile 的内存屏障就是为了把这个口子堵住。2.2 内存屏障与内存语义volatile 的底层实现volatile 之所以能保证可见性和有序性靠的是编译器或 JIT 在生成的指令里插入内存屏障Memory Barrier。内存屏障是一条特殊的指令作用是限制屏障两侧的指令重排并强制刷新缓存或失效缓存让 CPU 和内存之间的数据保持一致。以 Java 为例JMMJava 内存模型对 volatile 做了这几条硬性规定写 volatile 变量前编译器会插入一个 StoreStore 屏障保证屏障前面的普通写操作已经完成不会被重排到 volatile 写之后。写后面插入 StoreLoad 屏障防止 volatile 写和后续 volatile 读重排。读 volatile 变量后面会插入 LoadLoad 屏障和 LoadStore 屏障保证后续的普通读操作和写操作不会重排到 volatile 读之前。换句话说volatile 读操作之后的普通读写都能看到 volatile 读之前的所有写操作的结果。这也就是 JMM 里的 happens-before 规则对一个 volatile 变量的写操作happens-before 于后面对同一个 volatile 变量的读操作。在 x86 平台下具体指令层面volatile 写通常会被实现为带 lock 前缀的写操作这个 lock 会锁住缓存行强制刷回主内存。所以 JDK 5 之后的 Javavolatile 语义是非常明确的这也是它能支撑双重检查锁单例这类经典写法的原因。C/C 的情况就完全不同了后面跨语言对比我会细说。3. 经典应用场景与代码示例3.1 最常用的状态标志位volatile 最经典的场景就是布尔类型的开关控制。我写一个实际案例public class VolatileDemo { private static boolean running true; public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { long count 0; while (running) { count; } System.out.println(worker stopped, count count); }); worker.start(); Thread.sleep(1000); running false; System.out.println(main set running false); worker.join(2000); if (worker.isAlive()) { System.out.println(worker did not stop!); } else { System.out.println(worker stopped in time); } } }这个例子里running 是共享变量worker 线程一直循环检查它主线程在 1 秒后把它改成 false。如果 running 不加 volatile实际运行中很可能出现主线程已经把 running 改成 false 了但 worker 线程仍然在死循环不退出。加了 volatile 之后worker 线程能立刻感知到修改循环正常退出。我实测过很多次这个现象在普通的本地环境不一定次次复现因为 JIT 优化、CPU 核心调度甚至运气因素都会影响结果。但如果你把循环体写得稍微重一点或者让 worker 线程长期空转不加 volatile 出现死循环的概率会显著上升。它非常适合用来理解 volatile 的可见性。3.2 双重检查锁DCL单例为什么必须加 volatilevolatile 另一个极其经典的场景是双重检查锁Double-Checked LockingDCL单例。很多框架源码里都能看到这种写法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()这行代码在 JVM 层面大致拆成三步分配内存空间。调用构造函数初始化对象。把 instance 引用指向这块内存。问题在于步骤 2 和步骤 3 之间编译器可能重排。如果先执行了步骤 3另一个线程此时看到 instance 不为 null直接返回但对象还没构造完成调用它的方法就出大事了。volatile 正是通过内存屏障禁止这种重排保证 instance 引用被发布时对象已经完整初始化。这里顺带提一个关键点JDK 5 之前volatile 的语义不够强DCL 并不安全需要靠其他方式实现单例。JDK 5 修订了 volatile 的内存语义之后DCL 配上 volatile 才是标准写法。所以你在老古董资料里看到的“DCL 不安全”的结论放在今天的 Java 环境里已经过时了。这也是我建议读源码时优先看较新的并发相关的书籍或文档的原因。3.3 轻量级“开关”与进度通知除了状态标志和单例volatile 还适合做轻量级的进度通知、事件开关。比如后台任务处理完一批数据需要通知 UI 线程刷新进度条一个 volatile 的 int 字段就够了。由于 volatile 的读写性能远高于加锁这类高频读、低频写的场景非常适合用它来替代锁。但这里有两个前提第一写操作必须简单不能依赖旧值比如你不能在 volatile 字段上做data data 1这种依赖自身旧值的操作第二单次写入是原子的这没问题但如果你需要“多个变量的一致快照”volatile 也搞不定。比如一个坐标点x和y分别加 volatile线程 A 改了 x线程 B 改了 y另一个线程读到的可能是“x 是新的、y 是旧的”这种混合状态。这种情况必须用锁或者不可变对象快照。顺带说一句最近有人问 Kafka 消费端多线程怎么保证消息顺序性这个场景就很有代表性。Kafka 分区内的消息天然有序但如果你在多线程消费端共享一个 volatile 计数器去标记处理进度就会出顺序错乱问题。正确做法是按分区分配单线程消费或者用有序队列加锁保证投递顺序而不是依赖 volatile。这让我想到很多人习惯到处加 volatile但它在“有序性保证”上是有明确边界的并不是“一个关键字解决所有并发问题”。4. 别踩坑volatile 在各语言中的差异4.1 Java 的 volatile 是“强语义”的前面已经提到JDK 5 之后Java 的 volatile 语义被重新定义得非常清晰一个 volatile 变量的写先行发生于后面对它的读并且禁止重排。它是 JMM 的一部分不需要依赖任何平台特定的指令JVM 会负责在不同硬件架构上生成合适的屏障指令。这意味着在 Java 生态里你只需要信任 volatile 的语义即可不需要像 C/C 那样还要考虑编译器之间差异。Java 并发相关的面试题比如“volatile 和 synchronized 的区别”“volatile 能保证原子性吗”给出的标准答案都是建立在这套语义之上的。4.2 C/C 的 volatile 和线程安全无关这里我必须重点提醒如果你从 Java 转到 C/C千万别把 Java 的 volatile 习惯带过去。C/C 的 volatile 关键字核心语义是告诉编译器“这个变量可能被外部意外修改不要把它优化到寄存器里”。它确实能防止编译器层面优化掉某些读写但完全不保证 CPU 缓存一致性也不提供内存屏障更不能作为线程同步原语。举个实际例子在 C 里用 volatile 修饰一个 bool 开关试图让两个线程通信结果很可能是无效的。你甚至无法保证 volatile 写之后另一个核心能看到这个修改。正确的做法是使用std::atomicbool它提供原子性和内存序控制编译器会针对不同平台插入正确的屏障指令。我见过不少从 Java 转到 C 的同事在这一点上踩坑写出的代码在高并发下偶发数据错乱排查起来非常痛苦。C 的std::atomic还允许你显式指定内存序比如 relaxed、acquire、release、seq_cst这比 Java 的 volatile 更底层也更灵活。但代价是你得自己承担正确性论证的责任稍有疏忽就容易出错。4.3 C#、Delphi、Python 等语言的现实情况C# 也有 volatile 关键字语义和 Java 比较接近保证可见性禁止重排但不保证原子性。C# 的 volatile 官方文档明确说它只适用于字段且字段类型有严格限制。需要复合操作的场景C# 里通常配合Interlocked类使用比如Interlocked.Increment。这里有一个明显区别Java 的 volatile 在语言规范层面有强内存语义C# 的 volatile 则更依赖 CLR 在 x86 架构上的实现实际用起来相比 Java 要谨慎一些。Delphi 也是类似情况它支持 volatile 关键字但在多线程同步方面Delphi 社区更常用的是 TThread.Synchronize、临界区或者原子操作函数。用 volatile 做线程开关Delphi 中能保证编译器不会优化掉访问但如果要严格处理缓存一致性和内存序还是得靠操作系统提供的同步原语。Python 则根本没有 volatile 关键字。CPython 的 GIL全局解释器锁在某种程度上保护了简单操作的原子性像list.append、dict.setdefault这类操作是线程安全的但这并不是因为 volatile而是解释器层面的锁在起作用。Python 多线程项目中正确的方式是使用threading.Lock、threading.Event等同步原语。拿 Java 思维去 Python 里找 volatile是不存在的。汇总一下各语言差异语言volatile 是否线程相关是否保证可见性是否禁止重排替代/补充方案JavaJDK 5是是是synchronized、Lock、AtomicIntegerC/C否不保证不保证std::atomic、mutexC#是是是Interlocked、lockDelphi部分部分平台相关TCriticalSection、原子函数Python无此关键字依赖 GIL依赖解释器threading.Lock、Event这张表建议收藏语言之间切换时最容易在这里栽跟头。5. volatile 的典型误用与排查实录5.1 复合操作volatile 救不了 i这是 volatile 误用里最常见、也是危害最大的一类。很多人知道 volatile 能保证可见性就以为它能同步计数于是写出这种代码private volatile int count 0; public void add() { count; }多个线程调用add()之后count 的最终值几乎必然小于期望值。原因很简单count不是原子操作。它至少包含三步读取 count、计算 count 1、写回 count。线程 A 和线程 B 可能同时读到 count 0同时计算得到 1同时写回最终 count 是 1但实际应该加两次变成 2。这种情况哪怕是 volatile 也无济于事正确做法有三个改用AtomicInteger它的incrementAndGet()是原子的。改用synchronized或Lock保证复合操作的原子性。如果只是计数且允许误差可以接受近似值但一般不建议这么做。我在实际项目中见过一次严重事故一个统计系统用 volatile 记录请求数量高峰时线程并发高统计结果少了一截。排查时大家都没往 volatile 上想后来压测复现才发现问题就出在counter上。排查这种问题非常隐蔽因为它不是每次必现而是并发越高越明显。5.2 引用类型的可见性陷阱再提醒另一个容易忽视的坑volatile 只保证引用的可见性和引用的赋值不被重排并不保证引用指向的对象内部字段的可见性。什么意思比如private volatile Config config; public void update() { config new Config(a, b, c); }volatile 能保证 config 这个引用更新后其他线程能看到新引用。但 Config 对象内部的字段比如 config.getName() 返回什么volatile 管不了。如果一个线程正在读取 config 内部的某个字段而另一个线程正在修改这个字段且没有其他同步手段你还是会遇到可见性问题。一个常见的解决方案是让 Config 变成不可变对象所有字段在构造函数里一次性赋值并且字段声明为 final。这样通过 volatile 发布一个不可变对象整个对象的内容就能被安全地共享。这也是我在写“配置热更新”这类功能时比较推荐的做法配置对象做成不可变每次更新直接替换引用读取方只用关心 volatile 引用的可见性内部状态天然安全。5.3 到底什么时候该用锁什么时候用 volatile很多人纠结这个问题。我给出一个比较实用的判断标准按这个思路选基本不会错如果只是“读多写少”的简单状态单个线程写多个线程读比如开关标志、缓存引用用 volatile 就够了。如果有多个线程同时写同一个变量或者一个操作依赖变量的旧值那就必须用锁或原子类。如果逻辑需要“读-判断-写”的完整流程即便每个单独步骤都是原子的合并起来也仍然不是原子的必须用锁。举个例子实现一个“只执行一次”的初始化逻辑单纯用 volatile 是不够的。第一步检查 flag第二步设置 flag这两步之间可能有另一个线程已经设置了 flag导致你的初始化逻辑被跳过或重复执行。这种场景要用 synchronized 或 CAS 循环来完成。5.4 常见问题速查表结合我自己的排查经验整理了一张速查表方便遇到问题时直接对着查症状可能原因解决方案一个线程改值另一个线程看不到变量未加 volatile或被 JIT 优化到寄存器加 volatile 或使用锁多线程同时 count结果比预期小复合操作非原子AtomicInteger 或锁单例实例拿到未初始化对象DCL 单例缺少 volatile重排导致instance 加 volatile配置热更新后读取方仍用旧值引用未加 volatile 或对象内部状态不一致volatile 不可变对象死循环无法退出但主线程已修改标志标志位未被可见性保护标志位加 volatile用 volatile 实现队列/缓冲区数据错乱volatile 不具备原子性复合操作失控改用队列数据结构或锁这张表基本覆盖了实际开发中我遇到过的 volatile 相关问题。每次排查到并发问题时我都建议先问自己三个问题这个变量是共享的吗读写关系是单写多读还是多写多读操作是原子的吗问题答案一明确用不用 volatile 基本就清楚了。6. 实操验证用一段代码复现可见性故障6.1 不加 volatile程序为什么“卡死”纸上谈兵再多不如自己跑一遍。我建议你动手做这个实验直观感受一下 volatile 的作用。我提供一个 Java 示例跑不跑得出现象取决于你的 JVM 版本、CPU 架构和运行参数但多加尝试一般能复现。准备一个项目写一个主类public class VisibilityTest { private static boolean running true; public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { long count 0; while (running) { count; } System.out.println(worker stopped at count); }); worker.start(); Thread.sleep(1000); running false; System.out.println(main: running set to false); worker.join(2000); if (worker.isAlive()) { System.out.println(worker still alive! visibility problem.); } else { System.out.println(worker exited normally.); } } }我第一次跑这个实验时程序没有退出worker 线程一直空转输出了worker still alive!。原因就是 worker 线程的循环体太简单JIT 编译器可能把running字段的值直接优化到寄存器里每次循环读寄存器根本不重新读内存。主线程改了主内存里的runningworker 看不到死循环。6.2 加了 volatile 之后的变化把 running 字段改成private static volatile boolean running true;重新运行程序在 1 秒后正常结束worker 线程输出退出信息。为什么加了 volatile 就生效了因为这个字段被定义成 volatile 之后JVM 会禁止把它的读取优化到寄存器同时读写之间插入内存屏障。worker 线程每次循环都会从主内存重新读取 running 的值主线程写入时也会强制刷新到主内存。双方通过 volatile 语义建立了可见性关联。如果你想让“不加 volatile 卡死”的现象更容易出现可以调整 JVM 参数比如使用 Server 模式的 JIT或者让循环体足够简单。但说实话本地实验不完全可控有时候加不加 volatile 都正常退出这没关系理解背后的原理比单纯复现现象更重要。6.3 验证过程中的几个关键细节做这个实验时有几个细节值得留意。第一你可能会在本地一次都复现不了“卡死”但生产环境就是偶发。这很正常因为可见性问题受 JIT 优化策略、CPU 核心数、线程调度时机共同影响复现概率不稳定。所以不要因为实验“正常”就放松警惕并发问题的故障本来就是“裸奔时不出事出事就是大事”。第二如果修改循环体加一些计算让循环无法被过度优化可见性问题反而更难复现因为它掩盖了 JIT 优化导致的寄存器缓存问题。这也是为什么实验尽量保持循环体简单。第三可以顺便用-Xint参数强制 JVM 以解释模式运行不加 volatile 时基本观察不到问题因为解释执行时每次都会访问内存。这从反面证明了可见性问题跟 JIT 优化强相关。理解了这一点你就明白为什么有些老系统换成新 JDK 之后突然出现并发故障特性变化导致优化策略变了。第四这个实验也可以验证一下有序性的重要性把 DCL 单例里的 volatile 去掉虽然大多数情况下程序还是能跑但理论上可能出现半初始化对象。这类问题更难复现却危害更大所以必须从正确性上直接保证不能靠“跑几次没问题”来判断。我自己做并发相关开发时一直保留一个习惯所有共享变量先问一句“需不需要被多个线程可见”如果答案是肯定的再问“单个读写是否是原子操作”。这两个问题想清楚代码的并发正确性就会高很多。拿 volatile 去解决所有并发问题是不现实的但把它用在合适的场景里它确实是性价比最高的同步手段之一。希望这篇能让你对 volatile 有一个更完整的认识下次再遇到并发可见性问题能直接定位到根源。