
1. 为什么学了这么久Java你还没搞懂volatile先说个场景。你写了一个多线程程序一个线程负责接收外部信号另一个线程根据信号做业务处理。上线跑了两个月一直好好的某个深夜流量突增程序突然出现了诡异的行为——状态标志位明明是true了业务线程却像瞎了一样视而不见继续走老逻辑。你第一反应是“并发问题”加锁问题消失但性能掉了30%。你百思不得其解状态变量只是被读和写又没有复合操作为什么会出问题答案就藏在volatile里。volatile是Java并发编程里的一个关键字也是面试Java岗位时几乎必问的一道题。很多人对它只有模糊的印象——知道它能保证可见性知道它和synchronized不一样但具体底层干了什么、什么场景该用、什么场景用了反而踩坑说不清楚。这篇文章我打算把volatile一次讲透从内存模型到底层指令从经典使用场景到那些“看着能用、用了就炸”的边界情况全部过一遍。适合谁来读正在准备Java面试的开发者已经写过一段时间并发代码但没系统整理过volatile知识点的初中级工程师以及那些读源码时看到volatile修饰的字段却始终不太理解为什么这么写的朋友。看完这篇文章你至少能回答三个问题volatile保证什么、不保证什么、什么时候该用它。2. 没有volatile的日子多线程到底在争什么2.1 一个简单的flag为什么会出问题在讲volatile之前先把问题的根源挖出来。Java程序跑在多核CPU上每个CPU核心都有自己的高速缓存L1、L2、L3这一层级结构。线程是CPU调度的最小单位不同线程可能被调度到不同的核心上执行。线程执行时并不是直接读写主内存RAM里的数据而是先把数据从主内存加载到CPU缓存中在缓存里做运算之后再同步回主内存。这个设计本身是为了性能但带来了一个副作用——某个线程在缓存里修改了值另一个线程在主内存里读到的还是旧值或者它自己也有一份过期的缓存副本。经典的flag代码如下public class FlagTest { private boolean flag false; public void updateFlag() { flag true; // 线程A执行 } public void doSomething() { while (!flag) { // 线程B一直死循环等着flag变true } System.out.println(flag is true); } }线程A执行updateFlag方法把flag改为true。线程B在另一个核心上执行doSomething方法它可能永远看不到这个修改一直陷在死循环里。我在本机实测过加了JIT热点编译优化之后这种现象出现的概率并不低——因为编译器还可能把!flag这个判断优化掉直接从寄存器里取值连缓存都懒得读了。2.2 Java内存模型JMM到底规定了什么Java为了解决这种跨硬件平台的内存可见性问题在语言层面定义了一套规范叫Java内存模型Java Memory ModelJMM。JMM规定了一系列规则告诉编译器和CPU哪些重排序可以做、哪些不能做变量什么时候必须从内存读、什么时候必须写回内存。JMM里有一条核心规则如果多个线程共享某个变量而且至少有一个线程对它执行了写操作那么这些读写操作之间必须通过同步机制来保证一致性。同步机制包括synchronized、volatile、Lock以及Atomic系列类。volatile关键字在JMM里的定位是轻量级的同步机制。它比synchronized轻量得多因为它不涉及线程阻塞和上下文切换但它的能力范围也比synchronized小得多。synchronized保证的是“互斥可见性原子性”volatile只保证了“可见性”和某种程度上的“有序性”原子性它管不了。2.3 JIT编译器与CPU的“小动作”指令重排序除了缓存导致的问题还有另一个隐蔽的破坏者——指令重排序。现代CPU和JIT编译器为了提升执行效率会在不改变单线程执行结果的前提下对指令的执行顺序进行调整。比如代码里写的是“先执行A再执行B”实际底层可能是“先执行B再执行A”。单线程下这个问题不大因为重排序的前提就是“不改变单线程语义”。但多线程下问题就大了线程1按照重排序后的顺序执行了某两步操作线程2可能在中间状态观察到“B先发生、A还没发生”的撕扯现象。volatile关键字可以禁止这种重排序具体来说它通过插入内存屏障指令来实现。内存屏障是CPU提供的一组特殊指令作用相当于在指令流中设置“栅栏”告诉CPU和编译器栅栏两侧的指令不能跨越栅栏乱序执行。3. volatile的底层原理从字节码到CPU指令3.1 加了volatile之后字节码发生了什么变化先用工具看看到底有什么不一样。写一段最简单的代码public class VolatileDemo { private volatile int count 0; public void increment() { count 1; } }用javap -c反编译这个类你会发现字节码层面和普通变量几乎看不出区别。putfield指令也好getfield指令也好指令本身完全一样。volatile的语义约束不是体现在字节码指令层面的而是体现在JVM解释执行和JIT编译后的机器码层面。用JITWatch或者hsdis工具看JIT编译后的汇编代码就能看到关键差异volatile修饰的字段在写入之后会紧跟一条lock前缀的指令或者一个内存屏障指令。在x86平台上写volatile变量时JVM会生成类似下面这种模式mov %eax, 0x10(%rsp) lock addl $0x0, (%rsp) ; 内存屏障这条lock指令的作用是锁住总线或者锁住缓存行强制把当前CPU的写缓冲器store buffer中的内容刷到主内存。同时它也会让其他CPU核心的对应缓存行失效。3.2 缓存一致性协议MESI的默契配合Intel CPU使用MESI协议来维护多个核心之间缓存的一致性。MESI是四个状态的缩写Modified已修改、Exclusive独占、Shared共享、Invalid失效。当一个核心修改了一个缓存行里的数据后这个缓存行的状态变成Modified。当其他核心要读取同一缓存行时它们会通过总线嗅探机制发现这个缓存行已经被修改此时有两种处理方式写更新write-update把新值广播给所有持有该缓存行的核心。写失效write-invalidate把其他核心的缓存行标记为Invalid其他核心必须重新从主内存加载。x86选择了写失效策略。volatile的写入操作通过lock指令触发缓存行失效其他核心下次访问这个变量时发现自己的缓存行是Invalid只能重新从主内存拉取最新值。这就是volatile可见性的底层链条lock指令 → 缓存行失效 → 重新从主内存加载。整个过程不需要加锁不需要线程阻塞所以性能代价比synchronized小得多。3.3 内存屏障的四种类型与volatile的插入规则内存屏障按功能划分有四种类型屏障类型指令示例作用LoadLoadLoad1; LoadLoad; Load2确保Load1先于Load2及后续所有读操作完成StoreStoreStore1; StoreStore; Store2确保Store1先于Store2及后续所有写操作完成LoadStoreLoad1; LoadStore; Store2确保Load1先于Store2及后续所有写操作完成StoreLoadStore1; StoreLoad; Load2确保Store1先于Load2完成且Store1的值对其他核心可见JMM针对volatile定义了一套保守的内存屏障插入策略每个volatile写操作前面插入一个StoreStore屏障确保之前普通写操作的结果对后续的volatile写可见。每个volatile写操作后面插入一个StoreLoad屏障防止volatile写和之后可能出现的volatile读/写操作重排序。每个volatile读操作后面插入一个LoadLoad屏障和LoadStore屏障防止后续的普通读写操作被重排到volatile读之前。从这组规则能看出一个关键点volatile写不能重排到volatile读之后volatile读也不能重排到volatile写之前。这两个约束构成了volatile“禁止重排序”的核心语义。4. volatile的两个核心语义可见性和有序性4.1 可见性写线程的修改读线程必须立即可见volatile的可见性语义用一句官方的话来表达一个线程对volatile变量的写操作会happen-before后续任意线程对该变量的读操作。这个happen-before关系不是一句空话它背后有完整的硬件链路在支撑。写线程执行volatile写入时JIT编译器会插入StoreLoad屏障把当前核心store buffer里的数据强制刷到主内存。读线程执行volatile读取时会从主内存重新加载数据而不是使用过期的寄存器缓存或CPU缓存。这么说可能还是有点抽象。换个生活化的类比你把一条消息写在公司公告栏上主内存volatile写操作相当于你不仅把消息贴了上去还在公告栏旁边按了一个大铃铛把所有人都震醒确保每个人都知道要去看一眼。普通的写操作就像在公告栏贴了条消息但没按铃铛大多数员工其他线程可能压根不看公告栏还用着自己备忘录里的旧消息。4.2 有序性volatile如何阻止指令乱序volatile的有序性语义规定了一个简单的原则如果程序中对volatile变量的读/写操作在代码顺序上是先发生的那么在实际执行时也必须先发生。更严格地说JMM规定volatile读和volatile写不能与前后任何内存操作进行重排序除非重排序后的结果对volatile读写的语义没有影响。还是用DCL单例模式来演示。经典的饿汉式/懒汉式之外的第三种写法——双重检查锁public class Singleton { private static volatile Singleton instance null; private Singleton() {} public static Singleton getInstance() { if (instance null) { synchronized (Singleton.class) { if (instance null) { instance new Singleton(); } } } return instance; } }之前见过一个版本的代码没加volatile跑在高并发环境下偶尔会拿到一个“半成品”的单例对象。这个例子非常经典原因在于new Singleton()不是一条原子指令它底层实际上分三步分配内存空间在内存上初始化对象执行构造函数把引用地址赋值给instance变量如果CPU或编译器把步骤2和步骤3重排序了就可能出现“引用已经指向一块内存但对象还没构造完成”的中间状态。另一个线程在if (instance null)判断时发现instance不为null直接返回了这个半成品程序使用到它的某个字段时就会抛空指针或者返回默认值。volatile禁止了这种重排序。instance变量被volatile修饰后JMM要求volatile写第3步和volatile读判断和返回之间不能乱序从而保证一个线程发布对象时另一个线程看到的一定是“完整构造后的对象”而不是一个残次品。4.3 volatile不保证原子性最容易踩的坑防杠声明放前面volatile不保证原子性这一点已经被无数篇博客强调过了但很多人还是会在实践中踩坑而且往往踩得莫名其妙。举个最典型的例子多个线程对同一个volatile变量执行count。代码如下public class Counter { private volatile int count 0; public void increment() { count; // 这不是原子操作 } }count在字节码层面拆开看是四条指令getstatic // 读取count当前值 iconst_1 // 加载常数1 iadd // 执行加法 putstatic // 把新值写回countvolatile保证了每次getstatic都能读到最新值也保证了每次putstatic都立即对其他线程可见。但“读取值-加法-写回值”这三个步骤之间线程可能被切换。线程A读到count10还没执行加法线程B也读到count10执行加法写回11。线程A恢复执行后把11写回去两次自增的结果变成了只加了1。这就是典型的“丢失更新”问题。volatile在这里无能为力因为问题不在可见性而在复合操作的原子性被破坏。要想解决得用synchronized、AtomicInteger这类CAS无锁类或者加显式锁。5. volatile的经典使用场景与实战代码5.1 场景一状态标志开关最经典的使用场景是布尔类型的运行状态标志。一个线程负责启动/停止整个流程其他线程根据这个标志决定是继续工作还是退出。由于状态标志是“一写多读”模式写入没有依赖之前的值所以volatile的语义完美契合。public class Worker { private volatile boolean running true; public void shutdown() { running false; // 这个写入操作没有依赖任何旧值 } public void run() { while (running) { // 执行业务逻辑 doWork(); } } private void doWork() { // ... } }这里有个容易被忽略的细节为什么不用synchronized因为synchronized会引起线程阻塞、唤醒和竞争而这个场景里只需要读线程能“看到”写线程的最新修改即可完全不需要互斥——多个线程同时读是安全的多个线程同时写才会出问题。volatile在这个场景下提供了synchronized的可见性能力却没有它的锁竞争开销。5.2 场景二双重检查锁DCL前面已经演示过DCL的代码。这里补充说明一个判断标准什么样的单例需要volatile懒汉式单例即实例只有在第一次被访问时才创建单例字段可能被多个线程同时访问构造函数内部有其他字段的初始化操作存在“发布”不完整的风险如果用了饿汉式或者在构造函数里没有任何需要发布的对象字段volatile可加可不加。但为了保险我建议DCL单例一律加上volatile因为至少不会出错而漏掉volatile在某些JVM版本、某些CPU架构下真的会翻车。5.3 场景三安全发布不可变对象还有一种比较有意思的使用场景——当一个对象的所有字段都是final或者对象本身是不可变的volatile引用可以保证其他线程能安全地看到这个完整发布的对象。public class Cache { private volatile MapString, String cache new HashMap(); public void updateCache(MapString, String newCache) { cache newCache; // 整体替换引用而不是修改原map } public String get(String key) { return cache.get(key); // 读取的一定是某个完整的快照 } }这种写法叫“Copy-on-Write”思路的简化版。每次更新都是构造一个新Map并整体赋值给volatile引用读线程要么看到旧Map要么看到新Map永远不会看到一个“改了一半”的Map。它避免了显式锁读操作几乎零开销。当然这个场景要求Map本身不被就地修改只通过替换引用来更新。5.4 实战一个完整的并发统计器把前面的知识点串起来写一个稍微完整点的demo。需求是统计电商平台某活动页面的独立访问量要求多线程并发累加但不能用synchronized锁因为并发量太大。public class PageVisitCounter { private volatile long visitCount 0; // 这个方法只能单线程调用或者由调用方保证外部同步 public void incrementByOne() { visitCount; } public long getVisitCount() { return visitCount; } }等等这个代码是有问题的。前面刚说了count不是原子的多线程自增会丢数据。所以这个场景真正的正确写法应该是用AtomicLongpublic class PageVisitCounter { private final AtomicLong visitCount new AtomicLong(0); public void increment() { visitCount.incrementAndGet(); } public long getVisitCount() { return visitCount.get(); } }这个例子恰恰说明了volatile和Atomic系列类的分工需要读可见性、要高性能、但读多写少——用volatile需要原子性的复合更新比如自增、自减、CAS——用Atomic类。Atomic内部用的就是volatile变量Unsafe的CAS操作它把volatile的可见性能力和原子性能力合在了一起。5.5 实际项目中哪些经典源码用了volatile阅读开源框架源码时多留意volatile的身影能帮你更好地理解它的适用场景。举几个我印象深刻的例子JDK的AbstractQueuedSynchronizerAQS中的state字段就是用volatile修饰的锁的状态变更需要被所有等待线程及时看到。ConcurrentHashMap的sizeCtl字段是volatile的用于控制扩容操作的状态。Java的Thread类里的interrupt状态通过volatile相关机制实现线程中断信号的传递。Spring框架中不少地方用volatile修饰缓存引用、上下文对象等。这些场景有一个共同特征单线程写、多线程读或者通过CAS更新但读操作需要立即可见。写多读多的场景或者写操作依赖当前值的场景它们通常会用更重的同步手段。6. volatile、synchronized、Atomic核弹对决什么时候选谁6.1 三者的核心差异对比放一张我自用的对照表面试时也经常直接画出来讲维度volatilesynchronizedAtomic系列可见性保证保证保证原子性不保证保证保证CAS有序性部分保证禁止重排序保证部分保证性能开销低高锁竞争/上下文切换中低CAS自旋使用复杂度低中低典型场景状态标志、DCL、安全发布复合操作、临界区代码计数器、累加器、CAS操作这里补充一个经常被误解的点synchronized性能没那么差。如果锁竞争不激烈JVM的锁升级机制偏向锁→轻量级锁→重量级锁可以让synchronized的开销很低。但如果锁竞争激烈线程会进入阻塞和唤醒这个代价远高于volatile的StoreLoad屏障。反过来volatile虽然快但能力边界非常清晰用错场景就丢数据而且丢得很安静不像synchronized那样至少能保证正确性。6.2 选型时我自己惯用的三个判断条件遇到一个并发场景我会按这个顺序快速判断该用哪个先问这个变量是“一写多读”还是“多写多读”一写多读优先考虑volatile多写多读基本直接放弃volatile转Atomic或加锁。再问这个操作是“单纯读/写”还是“读-改-写”的复合流程复合流程直接排除volatile。最后问能不能接受阻塞不能接受阻塞且只是简单状态更新用volatile或Atomic能接受阻塞且操作复杂、涉及多个变量的协同用synchronized或ReentrantLock。这套规则我在实际项目里用了很多年没翻过车。唯一需要补充的是如果只是做计数器累加AtomicLong通常是首选性能比synchronized好一个量级。6.3 为什么说volatile是“轻量级”的synchronized网上有一种说法volatile是轻量级的synchronized。这个说法有道理但不完整。轻量体现在它不同步代码块、不涉及锁的获取和释放、不导致线程阻塞、不会出现死锁风险。它只是对变量读写操作施加了一些保守的限制这些限制在硬件层通过内存屏障完成不需要操作系统介入。但它缺少synchronized最核心的一个能力原子性。你不能把一段代码声明为volatile也不能让多个变量的操作组合成一个原子动作。所以准确的说法是volatile是轻量级的、只负责内存可见性的同步手段而synchronized是重量级的、同时负责互斥与同步的完整方案。6.4 一个容易忽略的候选final关键字讨论变量安全发布时还有一个容易被忽略的关键字final。如果一个对象的字段是final修饰的并且对象在构造函数中正确初始化那么任意线程都能安全地看到这些final字段的值不需要volatile也不需要锁。final的“安全发布”机制和volatile不同final保证了在对象构造完成之后final字段的值不会被修改而且JMM保证构造函数中对final字段的写入不会被重排序到构造函数结束之后。所以当你发布一个不可变对象时final 正确构造比volatile更简洁、更安全。7. volatile实战中高频踩坑点与排查思路7.1 用volatile修饰引用类型的风险volatile修饰引用类型变量时保证的是“引用的可见性”而不是“引用指向的对象内部状态的可见性”。这个区别非常关键。public class Holder { public volatile ListString list new ArrayList(); public void addItem(String item) { list.add(item); // volatile不保护list对象内部的修改 } }上面这段代码volatile只保证了list这个引用本身的变化对所有线程可见但当多个线程都调用addItem去修改同一个ArrayList对象时ArrayList的内部状态可能会被破坏而且这种破坏和volatile一点关系都没有。正确做法是使用CopyOnWriteArrayList、加锁或者整体替换list引用而不是就地修改。7.2 volatile与复合操作的“静默丢数据”很多新手最困惑的点是我已经加了volatile为什么多线程累加还是少数据而且程序看起来什么问题都没有不报错、不死锁就是结果不对。这是最坑的——并发问题往往是概率性的测试机器核心数少、线程少时可能根本跑不出来。等上了生产环境、8核16线程、高并发压测问题才突然爆发。排查这类问题的手段用jstack看线程状态在关键变量上打印线程名和当前值观察是否有“读到旧值”的情况用JITWatch看JIT编译结果确认volatile写有没有生成lock指令但说实话等出了问题再去查不如一开始就想清楚凡是“读-改-写”复合流程一律别用volatile。7.3 什么时候加volatile反而会引入新问题volatile不是银弹有些场景加了它反而引入新问题对volatile变量的高频写操作会引起缓存一致性流量激增。因为每次写都要通过lock指令触发其他核心的缓存行失效如果多个核心同时高频写同一个缓存行会引发严重的“缓存行抖动”cache line ping-pong性能下降甚至超过加锁。将volatile用在错误的数据结构上比如上面提到的ArrayList就地修改volatile给了你“好像同步了”的错觉但底层的线程安全问题一个不少这种错觉比完全不加更危险。在无锁算法中强行使用volatile以为它就能搞定一切。无锁编程涉及的问题比volatile的能力范围广阔得多没有深入理解CAS、内存序、ABA问题之前不建议自己设计无锁数据结构。7.4 一个值得收藏的排查checklist最后整理一份快速判断清单遇到并发问题时对着打钩[ ] 变量是基本类型还是引用类型引用类型的话内部状态是否会被并发修改[ ] 对变量的操作是单纯读写还是依赖旧值的复合操作[ ] 实际生产环境是几核CPU在低配机器上测不出问题不代表问题不存在[ ] 有没有更合适的替代方案Atomic类、synchronized、Lock、CopyOnWrite容器[ ] 如果你说不清volatile在你这段代码里到底保证了什么那就说明不该用8. 最后分享一点我的个人体会volatile是我自己学习Java并发时第一个“听懂但没真正理解”的知识点。听懂和真正会用的差距在于你能否在看清一段并发代码之后准确说出每个同步手段到底在保护什么。volatile保护的是“看到一个变量的最新值”它是内存可见性问题的最直接解药。但并发问题往往不是单一原因造成的可见性、原子性、有序性这三座大山经常混在一起出现只用一把钥匙很难打开三把锁。有一次线上排查经历让我印象特别深。一个缓存组件在高并发下偶尔读到“过期”的数据最初的怀疑对象是缓存更新逻辑加锁不够折腾了半天最后的根因竟然是缓存引用本身没有被volatile修饰导致一个线程更新了引用另一个线程还在读旧引用。这让我真正意识到并发编程里很多问题不是你加了多少锁而是你有没有在正确的位置使用正确的工具。一定要记住volatile不会让你的程序变慢太多但它也解决不了所有并发问题。独立地理解它、克制地使用它把它放在该放的位置上它就是你工具箱里最高性价比的那个螺丝刀。