资讯动态

Java volatile 深度解析:从可见性到内存屏障再到原子性边界

发布时间:2026/10/14 19:49:52 来源:尧图企业网站定制
1. 你以为的volatile和你必须知道的volatilevolatile大概是Java并发里最被“半懂”的一个关键字面试会考项目里也常见但如果真问一句“它到底保证什么、不保证什么底层又是怎么做到的”能完整答清楚的人其实不多。我在这个系列里零零散散提过它几次这篇决定单独拎出来把容易出现偏差的点一次性捋清楚。先纠正一个细节标题里写的Volitate是很多人拼错时的写法正确的关键字长这样volatile。读音怎么读不重要拼错了在代码里就是编译错误这点反而比它的语义更先被人记住。这篇“拾遗”适合两类人一类是刚学完synchronized正准备把并发工具箱补齐的同学另一类是工作中已经用过volatile但说不清“为什么加上这个关键字就能读到最新值”的工程师。我会先从JMM语义讲到CPU缓存和内存屏障再用真实运行结果验证原子性的短板最后给出我认为最稳妥的几种用法以及我在实际代码里踩过的坑。整个过程中会反复出现“可见性、有序性、原子性”这三个词我希望你看完之后能明白volatile只是其中一个维度的专家不是并发包万能药。1.1 一个在最简单场景下就可能翻车的例子先看一段非常典型的“优雅关停线程”代码。很多初学者觉得这事已经够简单了主线程把running改成false工作线程的while循环自然会退出。但实际跑起来很可能卡死在那里程序既不退出也不报错。public class VolatileDemo1 { private static boolean running true; public static void main(String[] args) throws InterruptedException { Thread worker new Thread(() - { while (running) { // 空循环什么都不做 } System.out.println(worker stopped); }); worker.start(); Thread.sleep(1000); System.out.println(main: try to stop worker); running false; worker.join(); System.out.println(main: done); } }我当年第一次跑这个Demo直接在服务器上等了十分钟循环都没有停下来。原因在于while循环被JIT编译成机器码之后running这个值可能被优化到寄存器里反复读取主线程即使把内存里的值改了工作线程也感知不到缓存和主内存之间已经出现了分裂。这不是玄学而是JMM允许的“可见性延迟”。解决办法大多数人也知道给running加上volatile循环立刻就能退出来。但这个例子真正值得思考的是为什么没有volatile时代码不一定会卡死而是“有时卡死、有时正常”因为JIT的优化策略、CPU的缓存行为、线程调度都不同。这套“如果不加同步就不知道什么时候出问题”的不确定性恰恰是并发编程最危险的地方——你没法用“我跑过一次没问题”来证明代码是对的。1.2 JMM给volatile的三层语义Java内存模型JMM并不是描述JVM“内部怎么做”的规范而是定义“程序在多线程下对外表现出什么行为”的一套规则。对volatile来说JMM在三个层次上做了约定。第一层是可见性。线程A对一个volatile变量的写对于接下来读取这个volatile变量的线程B来说是可见的。这个“接下来”不是时间上的先后关系而是Happens-Before规则里的先后关系写操作Happens-Before后续对这个变量的读操作。有了这一条你才能放心地用一个标志位去控制另一个线程的执行流程。第二层是有序性。JMM对volatile变量的读写操作加上了重排序限制编译器、JIT、CPU都不能把volatile写之前的内存操作重排序到volatile写之后也不能把volatile读之后的内存操作重排序到volatile读之前。换句话说volatile像是一道栅栏挡住了那些不合理的指令重排。第三层是不保证原子性。volatile保证的是“单个变量单次读或写”的原子性但不保证“读出来、改一下、写回去”这种复合操作的原子性。你可以用volatile实现一个安全的开关但不能用它实现一个安全的计数器。这一点后面会专门展开很多人翻车就翻在这里——加了volatile后数据还是错的于是开始怀疑人生。1.3 黑板类比可见性不等于原子性想把volatile讲明白我试过很多类比最顺手的还是教室黑板。假设黑板上写着一个数字老师在讲台上把它改了喊了一句“大家看黑板”所有学生都抬头看见了新数字——这就是可见性。但是现在要求全班同学轮流到黑板前每人把数字加1后再写回去。如果没有排队机制两个同学同时走到黑板前都看到数字是5也都写上6最后黑板还是6而不是应该的7。volatile解决的只是“老师喊话大家都能看见”这个环节管不了“谁先谁后在黑板上动笔”的顺序问题。从这个类比能推出两个结论。第一如果某个共享变量只是在单线程里被修改其他线程只负责读取volatile往往够用第二如果多个线程都需要基于当前值做计算然后把新值写回去这个“读-改-写”的复合步骤必须有锁或CAS介入volatile帮不上忙。2. volatile的可见性底层到底靠什么撑起来上一章说的是JMM层面的定义这一章我打算把问题往下沉一层可见性为什么能实现它的物理基础是什么很多人听了“缓存一致性协议”和“内存屏障”这两个词但不知道怎么和Java代码对应起来。我这里把链路拆开讲一遍。2.1 CPU缓存、缓存行和写传播现代CPU不会每次都直接从主存取数据因为主存太慢了。每个核心都有自己的私有缓存L1和L2所有核心共享L3缓存数据以“缓存行”为单位在这些层级之间搬移常见缓存行大小是64字节。当Java程序里创建了一个对象对象的字段在内存里其实是以某种二进制布局存在的一个线程读一个共享变量拿着的是主存地址但真正操作的是这个地址在某级缓存里的副本。问题来了多个核心都持有一个内存地址的副本时该怎么知道哪个副本是最新的在x86这类强内存模型上CPU内部有很多细节规则但板级层面依然需要一个统一机制来保证“写传播”。也就是说一个核心改了值其他核心必须有机会感知到这个改变否则程序就崩溃了。volatile的作用就是让这个“有机会”变成“必须”。在字节码层面给一个字段加上volatile修饰后JIT编译生成的机器码会在读写这个字段的位置插入对应的内存屏障要求CPU放弃一些可以偷懒的优化老老实实把最新值暴露出去。这不是Java特立独行本质上就是一门语言对底层硬件约束能力的直接利用。2.2 MESI不是唯一答案但它解释了失效机制聊缓存一致性避不开MESI协议。它把缓存行状态分成四种Modified已修改、Exclusive独占、Shared共享、Invalid失效。当一个核心要写某个数据时它会先把对应缓存行改成Modified状态并通过核间互连通知其他核心你们手里那份已经过期了。其他核心收到消息后把本地的缓存行标记为Invalid。等这些核心真正要读这个地址时发现状态是Invalid就会从更底层的缓存或者主存重新加载最新数据。不同的硬件实现未必完全使用MESI的名字比如有的方案是MOESI或MESIF但核心思想一致写传播加失效。volatile的可见性落到硬件层面就是在合适位置插入屏障保证一次写操作能触发这套失效传播机制并且读操作必须等待失效处理完成后再执行。有了这个机制我们才敢说“读volatile变量一定能看到最近一次写”。2.3 内存屏障编译器与CPU之间的防火墙内存屏障分几类LoadLoad屏障禁止“先读后读”的重排LoadStore屏障禁止“先读后写”的重排StoreStore屏障禁止“先写后写”的重排StoreLoad屏障禁止“先写后读”的重排。其中StoreLoad是最昂贵的因为它既要阻塞前一个写操作落到全局可见又要阻塞后一个读操作提前执行。在JMM层面volatile写和volatile读的约束是这样的一个volatile写之前的普通写操作不能重排序到volatile写之后一个volatile读之后的普通读和普通写不能重排序到volatile读之前。JIT在生成机器码时会在合适的位置插入具体架构的屏障指令比如x86上常见的是mfence、lfence或带lock前缀的指令而ARM和PowerPC上实现的屏障选择又不太一样。我自己看JIT汇编码时最常见到的组合是volatile写之前会有StoreStore屏障保证前面所有普通写都已经完成volatile写之后会有StoreLoad屏障防止后续的读操作越过这个写。这套屏障组合保证了“写完之后其他线程如果在读一定拿得到这个新值”。2.4 x86与其他架构的差异同一个程序两个世界同样一段不加volatile的多线程代码为什么有人怎么跑都不出错有人一到线上就出事故除了JIT版本不同CPU架构差异也是个大变量。x86使用TSOTotal Store Order全存储顺序模型它的内存模型相对较强普通读写之间的重排限制也比较多。所以很多程序员在x86开发机上学并发写完代码跑一下发现没问题这事很容易造成误判。到了ARM这类弱内存模型的设备上硬件允许的重排序更多屏障也更密集之前藏在“开发机跑不出问题”里的隐患就会爆出来。举个例子我在一个嵌入式项目里见过类似的现象同样的标志位逻辑在本地x86服务器上稳定运行了两个月一搬到ARM架构的网关设备上几天内就出现了一次“线程不退出”。排查到最后就是缺了volatile。代码没换只是硬件变了潜在问题就暴露出来。这件事给我留下的经验是并发代码的验证结果要和目标运行环境绑在一起看不能只靠开发机的行为下结论。3. volatile不保证原子性这个“短板”的完整拆解如果说可见性是volatile的“长板”那原子性就是它的“短板”。这里我想把“为什么自增操作会丢数据”从字节码层面到实测数据全部摆出来帮你看清问题到底出在哪一步。3.1 count在字节码层面到底发生了什么很多人以为count是一条“原子加一”操作实际上它在字节码层面是四条指令GETSTATIC把当前值读到操作数栈ICONST_1压入常量1IADD做加法PUTSTATIC把新值写回静态字段。这四条指令之间没有任何组合保证。如果count是volatile字段第一条GETSTATIC和最后一条PUTSTATIC各自都是原子的但“读出来算一次写回去算另一次”这个整体不是原子的。两个线程可以同时读到旧值10都算出11然后都写回11最终结果从期望的12变成11一次更新被覆盖了。这个道理大家都懂但我想强调一点volatile并没有让“读到旧值”这件事本身出错它保证你读到的一定是某个瞬间的全局最新值。问题在于“读”和“写”之间存在时间窗口这个窗口里另一线程可能已经完成了同样的读改写过程。窗口越长丢更新的概率越高但哪怕窗口只有一个指令的时间只要并发足够错误就必然发生。3.2 实测1000个线程各自增10000次结果差多少空谈误国不如跑一轮真实验证。我写了个简单程序1000个线程每个线程对同一个volatile int执行10000次自增理论上最终值应该是10000000。public class VolatileDemo2 { private static volatile int count 0; private static final int THREADS 1000; private static final int LOOP 10000; public static void main(String[] args) throws InterruptedException { Thread[] threads new Thread[THREADS]; for (int i 0; i THREADS; i) { threads[i] new Thread(() - { for (int j 0; j LOOP; j) { count; } }); threads[i].start(); } for (Thread t : threads) { t.join(); } System.out.println(count count); } }我在同一台机器上连续跑了5次结果大概是这样运行轮次最终count与理论期望的差值178123462187654282301871769813380953421904658485512761448724579093312090669结果每次都不一样但几乎都稳定地低于期望值。注意这里count是volatile的可见性没问题它仍然帮不了原子性。如果你把count换成AtomicInteger.incrementAndGet()结果就会稳定在10000000原因是CAS操作把“读-改-写”变成了一条不可分割的硬件级原子操作。3.3 补救方案怎么选锁、CAS还是换个思路如果你的确需要线程安全的计数器有两种主流选择。第一种是AtomicInteger底层用CAS循环实现适合并发量高、竞争不激烈、操作本身是“读当前值、算新值、写回去”这种模式。第二种是用synchronized把整个读改写过程包起来适合操作步骤更多、依赖多个变量、或者代码可读性优先的场景。选择的关键是看你的更新逻辑有多复杂。count这种单变量更新AtomicInteger足够。如果更新规则要同时读取两个volatile变量并基于它们计算一个新状态再写回CAS就没法一个操作完成这时候更适合用锁。千万别在“需要用synchronized保护复合逻辑”的场合硬套volatile那是把希望寄托在一个根本不保证原子性的工具上。还有一个常见思路是把共享可变状态改成不可变对象。每次更新时创建一个新对象发布新引用读方拿到新引用后用。这种方式在高并发读多写少的场景下很舒服读方不会有脏数据写方也不需要对单个字段自增因为根本没有原地修改。很多缓存系统的实现就是用这个思路来做并发控制的。3.4 64位变量容易被忽略的long/double原子性这是很多资料里一笔带过、但实际会踩坑的地方。在Java里对long和double这类64位类型JMM在规范层面允许JVM把它们“分两次32位写”来处理。如果变量不是volatile理论上可能出现一个线程写高32位、另一个线程写低32位最终读到两个半段拼出来的“脏值”——既不是旧值也不是新值而是混合体。把long或double声明成volatile之后JMM要求单次读写必须是原子的也就是说读方保证读到“完整旧值”或“完整新值”不会出现半新半旧拼接。现代64位JVM在64位平台上通常已经能够保证64位读写原子性但这属于实现细节规范层面的保证依赖volatile。我给这个点单独列出来的原因很简单如果你在维护一个老系统里面有一个共享的long状态字段有人告诉你可以不担心脏读那他多半是在拿64位平台的默认行为当规范。跨平台、换JVM实现之后这套假设不一定成立。要降低心智负担就直接加volatile成本不高换来的却是边界情况下的合规。4. volatile、synchronized、Atomic三选一还是组合拳很多初学者纠结的问题是它们之间到底怎么选我的回答是不要把它们看作同一个篮子里互相替代的苹果它们的语义边界完全不同组合使用也完全常见。4.1 一张表把三者的语义边界划清楚对比维度volatilesynchronizedAtomicInteger等原子类是否保证可见性是是是是否保证原子性仅单次读或写整个代码块单变量读改写是否防重排序限制变量相关重排限制临界区内外互斥依赖CAS的语义作用对象单个字段代码块/方法包装变量典型开销低但写屏障不免费较高涉及锁竞争和上下文切换中竞争激烈时CAS自旋明显适合场景状态标志、安全发布复合操作、多变量不变式单计数器、序列生成这张表里最容易看歪的地方是“可见性”。三者在可见性上都能做到但方式不同volatile专门为可见性而存在锁是通过互斥加Happens-Before规则来保证可见性Atomic类则依靠自身的CAS内存语义保证对包装变量操作的可见性。4.2 三个对应场景场景选错就是翻车现场场景A单写多读的状态标志比如服务开关、线程停止信号。一个线程改多个线程读修饰目标就是一个布尔字段。此时volatile是首选代码最简洁性能最好没有任何一个锁参与。场景B多个线程都在修改同一个计数器比如统计请求量。不管字段加不加volatile自增都会丢更新所以要用AtomicInteger或synchronized包住整个自增操作。实测下来在竞争不是特别激烈时AtomicInteger通常比锁更快因为CAS失败后只是重试不会让线程休眠。场景C多个共享变量需要作为一个整体保持一致典型例子是转账时“余额和流水”不能分开更新。这时候一个volatile字段根本描述不了不变式老老实实用synchronized或Lock把所有相关操作包进同一个临界区。先写余额还是先写流水没有意义关键是并发线程不能看到“余额变了但流水没变”的中间状态。我做技术评审时常说如果一个变量需要靠锁来保护它的“复合更新”那这个变量本身加不加volatile往往不是关键锁已经同步了可见性。反过来如果一个变量只靠volatile保证可见性那它绝对不能被多个线程同时执行“读旧值-算新值-写回去”。4.3 “都加volatile”为什么不是性能灵药有人听说volatile开销低就把所有共享字段都加上volatile以为这样既能提高响应性又能避免锁。这种“防御性编程”在并发场景里代价可能很高。第一volatile写大部分时候需要在后面插入StoreLoad屏障而这类屏障在x86上不是免费的会让CPU管线出现较大停顿。第二一旦共享变量是volatile编译器就不能对它做某些激进优化比如循环不变量的提取因为编译器必须时刻准备着“这个值可能被其他线程改掉”。第三volatile字段如果和别的热字段挨在一起、落在同一个缓存行中一个线程不停写其中一个字段会导致另一个核心上相邻字段的缓存行被频繁失效产生伪共享问题。极端情况下加volatile后的性能比加锁还差这是我实际压测过的。所以我的建议是volatile只用在真正需要跨线程可见性的字段上其他普通字段保持普通。它的价值在于“少而准”滥用反而会把原本正常的性能拖垮。5. volatile的正确打开方式从状态标志到安全发布聊完了原理和对比这一章给出一批我验证过、也愿意在项目里重复使用的具体模式。每一类都配套解释“为什么这样设计是可靠的”。5.1 单写多读的状态标志最简单的正确用法最经典的使用场景就是1.1节里的关停线程。把running换成volatile后主线程一次写入工作线程在下一次循环判断时必然能看到新值然后平稳退出。这里的关键约束是只有一个线程会写这个标志其他线程只读标志的新值不依赖标志的旧值。一旦满足这条约束volatile就足够可靠。你可以把同一条规则延伸到很多地方服务限流开关、缓存刷新标记、配置热更新的“脏位”。核心都是“一个写者多个读者简单赋值”。如果哪天需求变成了“多个线程都能修改同一个开关”那就要重新审视因为此时两个写方可能基于不同的前置状态做出相反决定volatile保证不了这种逻辑一致性。5.2 双重检查锁定单例与指令重排第二个高频场景是DCLDouble-Checked Locking双重检查锁定单例。代码长这样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; } }为什么这里的instance必须是volatile关键在于new Singleton()在JVM里不是一步完成的。它大致要经历三件事分配内存、调用构造方法初始化对象、把引用值赋给静态字段。如果第三个赋值动作被重排序到第二步之前另一个线程在第一次判空时读到非null的instance就会拿着一个“内存有了但内部字段还没初始化完成”的半成品对象去用直接崩溃。volatile在这里做的事就是禁止new Singleton()中的赋值操作和构造过程发生重排序保证线程看到引用时对象已经被完整构建。注意这是Java 5之后的语义Java 5之前的旧JMM对volatile的约束力不够DCL在早期是不安全写法。现在主流JVM早就过了这个阶段但聊到这个坑时我会建议新写的代码按上面的写法来不要因为“以前没出事”而挑战这条规范。5.3 安全发布对象构造完成前不要让引用逃逸DCL背后更通用的思想是“安全发布”。一个对象在被构造线程创建时所有字段先被写入然后构造函数返回。接下来如果这个对象通过一个volatile引用来暴露给其他线程那么读线程在读到这个引用的同时能保证看到构造过程中写入的所有字段的最新值。这相当于在“写线程完成初始化”和“读线程读取引用”之间建立了一条Happens-Before关系。代码上可以很简单public class Publisher { private static volatile Config config; static class Config { final String dbUrl; final int timeout; Config(String dbUrl, int timeout) { this.dbUrl dbUrl; this.timeout timeout; } } public static void publish(Config newConfig) { config newConfig; } public static Config get() { return config; } }这里的config是volatiledbUrl和timeout都是final双重保险保证读方拿到的要么是旧对象要么是构造完成的新对象不会看到“引用已更新但内部字段还缺一半”的中间态。实际上如果字段是final且在构造方法里正确赋值JMM本身就提供了初始化安全保证volatile主要保证引用本身在所有线程中的可见性。两者配合就是一套完整的安全发布方案。5.4 “轻量级读写可见性”的边界在哪里有一些框架会把volatile用在“读多写少但写线程唯一”的配置更新场景。比如配置中心推送新配置后后台一个线程负责同步进本地缓存前端大量请求线程读取配置。这种情况下把配置对象的引用声明成volatile既能让读方第一时间看到新配置又避免了每次读都加锁的成本。边界在于这个引用指向的对象内部字段必须不可变。如果读方拿到新引用后还要继续修改对象里的字段那volatile只保护了引用本身保护不了对象内部字段的状态一致性。一旦出现“内部字段会被多个线程修改”的情况就该退回到用锁管理整个对象的生命周期或者把对象重新设计成不可变结构。6. 实测过程中的坑以及我常用的验证手段讲完正确姿势最后一章说点实际操作中容易遇到的糟心事。有些问题不是原理层面的而是环境、工具、习惯层面的。6.1 不加volatile也可能正确运行但它确实违规前面说x86内存模型相对较强很多不加volatile的代码在x86开发机上“碰巧”能跑对。还有一个因素是JIT版本JIT编译器在不同的优化级别下做寄存器分配和指令调度可能把某些场景优化得很保守导致你没触发问题换个JVM参数、换一个热点阈值问题立刻暴露。我在这次压测里就有过体会把1.1里的demo放到不同机器上跑有的机器几百毫秒内worker就自己停了有的机器跑半小时都没停。这说明“实际运行看起来正确”和“符合JMM规范”是两回事。规范层面的回答很简单没有任何同步关系的共享变量读写程序的行为就是未定义的结果好坏都算运气。所以做并发改造时不要以“我本机跑过没问题”为验收标准正确性必须建立在规则之上。越是看似无害的共享标志位越要警惕它在弱内存模型、高优化级别、大量线程并发时的表现。6.2 把volatile当锁使的翻车现场这是我给别人代码评审时反复见到的错误。比如有人写了一个volatile布尔变量做“互斥标记”线程A把flag设为true线程B看见true后以为“A已经在处理了”于是跳过任务。这个设计根本挡不住两个线程同时进入临界区线程A判断flag为false还没把它改true之前线程B也在判断flag为false然后两个线程同时钻过去。另一个更隐蔽的翻车是用volatile int保存某个状态值先读取状态判断再写新状态。比如一个轻量级限流器先读currentCount小于上限则写回currentCount1。两个线程同时读到相同旧值都认为还没到上限都写回相同新值限流器就失灵了。这些场景的共性问题是虽然每个单独读写都是原子的但“判断条件”和“更新状态”之间缺少互斥。锁能保证这个整体不被打断volatile不能。要修也只能改成AtomicInteger的CAS或者直接用synchronized。我的原则是当代码里出现“先读一个共享变量再根据结果修改另一个或同一个共享变量”的写法时先问一句——这段逻辑在并发下是不是需要原子性如果需要就不要让volatile单独上阵。6.3 用JIT汇编验证volatile的内存屏障想亲眼看到内存屏障可以开启JVM的PrintAssembly功能来输出JIT生成的机器码。在HotSpot系JVM上启动参数大致是-XX:UnlockDiagnosticVMOptions -XX:PrintAssembly运行后会输出大量汇编片段找到写volatile字段的指令位置通常能看到mfence、lfence或带lock前缀的指令。这套方法需要配hsdis插件一些发行版自带没有的话需要自己准备在容器环境里经常会因为权限或依赖问题跑不起来。如果不想折腾这块可以退一步用更简单的验证方式把变量去掉volatile和加上volatile各打印一次分别在多核心机器上反复跑观察行为差异。这种黑盒验证虽然没有汇编那么直接但胜在门槛低、不依赖环境适合日常排查。我在实际项目里通常先用黑盒法锁定问题再决定要不要上PrintAssembly确认底层效果。6.4 看完这篇文章也别急着“优化”掉所有volatile最后想提醒的是两件反方向的事。第一不要觉得volatile是性能负担就把需要它的地方去掉换一个“看起来更酷”的自旋等待或休眠方案。第二不要在不确定正确性时用“加个volatile”去掩盖更大的设计缺陷比如对象内部状态混乱、生命周期管理缺失。有人用Thread.sleep(10)给其他线程时间“慢慢看到更新”这是一种反面做法。休眠只是创造了一个随机时间窗口既不能保证可见性也不能保证顺序纯属碰运气。正确做法就是查明这个共享变量的读写关系按前面章节的约束设计该锁就锁该原子类就原子类该volatile就volatile。我自己在代码评审里最后会问三个问题这个字段是不是只有一个线程写读取它之后会不会继续修改其他共享状态这个字段的更新是否依赖当前值三个问题都搞清楚volatile该不该用、用在哪、要不要换成锁答案就自己浮出来了。并发编程没有银弹但把每个关键字的适用边界钉死绝大多数问题都能在设计阶段就避免掉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑