Java volatile关键字详解:可见性、指令重排与正确使用场景
刷过Java面试题的人大概率都撞上过volatile这个词。它不太起眼总和其他并发关键字混在一起出现但真到用的时候边界感又特别模糊。我之前做代码评审时见过不少把volatile当万能锁用的有人拿它堵i的并发漏洞有人拿它保护一个可变HashMap最后线上问题该复现还是复现一条一条倒回去查才发现是volatile被放错了位置。这篇打算把volatile彻底翻出来聊透它到底解决什么问题底层靠什么撑住可见性和有序性哪些场景闭着眼睛用都不会错哪些场景碰都不要碰。这篇不只是给准备Java面试的开发工程师看的凡是写过并发代码、被诡异死循环或脏数据折磨过的人应该都能从这里找到答案。整个系列前几篇聊过锁和线程池这一篇补上并发基石里最容易踩坑的一块内容自成闭环单独读也没问题。1. 并发代码是怎么“翻车”的先理解volatile存在的理由1.1 一段可能永远跑不完的循环先看一段非常无辜的代码。worker线程在一个while循环里判断runningmain线程在500毫秒之后把running改成false按直觉worker应该顺势退出循环然后把结果打出来。我把完整代码列在这里你可以直接拿去跑。public class VolatileFlagDemo { 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(500); running false; System.out.println(main set runningfalse); worker.join(); } }这段代码在绝大多数开发者的认知里都是正常的但如果你跑在比较老的JVM或者编译优化开得比较激进的环境里会发现worker线程几乎退不出来甚至那句System.out永远不会被打印。问题就出在running身上worker线程在循环里高频读取它线程运行时早就把running复制到了自己的缓存或寄存器副本中每次循环都命中这个副本根本不会回主内存去看最新的值。main线程表面上把running改成了false改的只是主内存里的变量worker手里那把旧副本却纹丝不动。这就是并发里最常见的可见性事故也是volatile出场的最初理由。这种问题还有个阴险之处它不一定每次都能复现。在开发机上可能是好的一到线上或者换了一台CPU主频更高的机器两个小时后又冒出来一次。因为缓存失效、JIT编译优化、线程调度都有很大的不确定性你很难用“跑一次试试”来验证一个并发bug是不是已经修复只能从JMM的规则层面把问题钉死。1.2 JMM视角下的三大特性可见性、原子性、有序性要理解刚才的事故先得给自己补一个背景知识Java内存模型通常直接叫JMM。JMM并不是内存条或者CPU缓存里某个真实存在的区域它是一套规范用来描述线程和内存之间怎么交互约定了什么行为在多线程下是合法的。在JMM的设定里线程并不会直接操作主内存中的变量而是先把变量读到自己工作内存大致对应CPU缓存、寄存器这些更贴近CPU的区域中算完之后再写回主内存。麻烦的地方在于不同线程的工作内存是互相隔离的线程A改了线程B不一定看得到。于是并发编程里出现了三个经典问题。可见性指一个线程对共享变量的修改其他线程能不能立刻看见。原子性指一个操作或者一串操作在执行时会不会被其他线程插一脚。有序性指代码执行的顺序会不会被编译器和CPU重排。单线程下重排只要最终结果一致就无所谓因为对程序行为没有影响多线程下重排可能把一个变量的赋值顺序完全打乱程序就跑出了你最初设计的样子。拿生活类比一下可见性就是你在群里发了一条消息别人没刷新就看不到原子性就是转账时扣款和到账必须是一笔完整交易不能中间插入一个查询看到钱凭空消失有序性就是你以为先穿袜子再穿鞋结果CPU先帮你把鞋穿上了袜子还拿在手里。这三个问题不是孤立的它们经常同时出现。比如一个简单的计数器既要求每个线程看到最新值又要求自增操作不被穿插还要求代码执行顺序可靠。很多时候我们需要多种手段配合很少靠一个关键字解决所有并发问题。这也是volatile经常被误解的根源它确实顶了很大的名气但能力边界其实很清楚。1.3 volatile到底管了哪两件事volatile能解决的恰好是刚才三个问题中的两个可见性和有序性。它保证一个volatile变量的写操作对其他线程后续的读操作是立即可见的同时它通过在读写操作周围布下内存屏障禁止相关指令被随意重排。原子性它管不了这点后文会反复强调。很多人会把volatile的可见性简单理解成“访问volatile时每次都直接从主内存读写”这个说法不算错但容易误导。真正发生的是CPU通过缓存一致性协议和内存屏障让其他线程手里的旧副本失效或者强制线程从最新副本里读取而不是像普通人理解的那样每次慢悠悠地绕过所有缓存去触达主存。如果把内存比作一块共享黑板普通变量就是每个人手里的小抄拿起来就能看自己改了也不一定告诉别人volatile则是每次读写前必须重新看一眼黑板黑板被别人改了你手里的旧小抄就不再可靠。注意这里强调的是“单个变量”的同步语义。volatile作用的是变量级别不是一段代码块或临界区所以它天生不是锁的替代品。你可以在一个方法里用volatile控制开关但不能用volatile把整个方法的操作包成事务。这一点明白了后面的使用场景才不会被带偏。2. volatile的底层逻辑从内存屏障到CPU缓存2.1 volatile读写时CPU和内存之间发生了什么继续往下挖。现代CPU不是直接从主内存干活而是通过多级缓存来加速访问。每个核心在处理同一个变量时都可能在各自的缓存里放一份副本如果没有任何同步机制副本之间就会互相打架。硬件层解决这个问题的方法是MESI这样的缓存一致性协议。简单理解当某个核心修改了一个缓存行之后它会通过总线通知其他核心这个缓存行的数据已经被我改了你们的副本要么更新要么立即作废。线程下一次再读取这个变量时发现自己的缓存副本失效了就不得不重新从内存或者拥有最新副本的核心那里拿数据。那volatile跟MESI有什么关系JVM对volatile变量的读写会在字节码层生成内存屏障指令这些指令落到CPU执行时会触发缓存一致性协议去完成失效和重刷的动作从而让一个核心中被修改的值尽快对其他核心可见。更直白地说volatile并没有魔法它只是在一堆优化面前画了一条红线让CPU和编译器知道这个变量的读写必须走某种约束流程不能像普通变量那样放开手脚用缓存副本。值得提醒的是MESI是CPU硬件层面的机制JMM是语言规范层面的抽象两者并不是一个概念但最终是硬件机制托底让语言规范里的可见性有了物理基础。面试时能把这两个层次分清楚已经很能说明你对volatile的理解不是停留在表面。2.2 内存屏障如何阻挡指令重排指令重排听起来像编译器吃饱了没事干实际上是为了性能。CPU流水线执行时如果前一条指令在等数据后一条指令可以先跑起来编译器也希望把指令排得更紧凑让寄存器、缓存和计算单元尽量不闲着。这些优化在单线程里没有任何问题到了多线程就可能会破坏你期望的顺序。volatile的解法是在指令流里插屏障JMM规定volatile写操作要在之前插入StoreStore屏障在之后插入StoreLoad屏障volatile读操作要在之后插入LoadLoad和LoadStore屏障。这四个屏障的名字看着绕拆开就清楚。StoreStore禁止前面的普通写越过volatile写确保普通写先落定StoreLoad禁止前面的volatile写被后面的读操作插队这是四条屏障里开销最大的一个LoadLoad禁止volatile读后面的普通读越到它前面LoadStore禁止volatile读后面的普通写越到它前面。一句话普通指令之间的重排比较自由遇到volatile就得绕远路该排队排队该等就等。屏障不是让你程序变慢的主要元凶但它确实是volatile比普通变量读写更贵的原因之一这个性能代价在选型时也要心里有数。2.3 经典案例双重检查锁为什么必须加volatile讲到这就该请出Java面试里最经典的volatile应用双重检查锁定单例通常写作DCL。代码很简单但背后的坑很深值得花时间彻底吃透。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; } }重点在看new Singleton()做了什么。在字节码层面这一步至少拆成三件子事分配内存、调用构造函数初始化字段、把引用赋值给instance。问题在于初始化字段和引用赋值这两步是有可能被重排的。如果线程A先给instance赋了引用但还没完成构造线程B此时进来发现instance不是null直接返回使用拿到的就是一个半成品对象字段都还是默认值运行起来一切皆有可能。加了volatile之后StoreStore屏障卡在引用赋值前面非逼着构造函数先完成instance才算真正发布出去。很多资料喜欢把这个问题描述成“new对象不是一个原子操作”这个说法没错但真正要命的是重排。即便new看起来像原子操作只要初始化字段和引用赋值之间的顺序可以被颠倒DCL就存在竞态。volatile解决的核心不是“保证new的原子性”而是“禁止重排”让两个线程看到的instance语义一致。这也是我为什么把本文安排在锁和线程池之后讲没有这些并发基础DCL看起来就像玄学。3. volatile的正确使用姿势三个可靠场景加一个常见坑3.1 场景一线程停止标志位回到1.1的代码把它改成volatile问题立刻消失。public class VolatileFlagDemo { private static volatile 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(500); running false; System.out.println(main set runningfalse); worker.join(); } }为什么这个场景用volatile是对的因为语义完全符合volatile的能力边界一个线程写其他线程只读写操作不依赖running的当前值不需要互斥临界区。worker线程在循环里高频读running靠volatile保证每次读都能看到最新值main线程写一次falseworker就能感知并退出。这种停止标志位是我在实际项目中用得最多的volatile场景比用synchronized包一层while循环要清爽得多也比AtomicBoolean更轻量。要注意的是如果停止标志被多个线程同时翻转或者停止逻辑比较复杂那就不该靠volatile了因为那已经超出它的能力范围。3.2 场景二安全发布不可变对象第二个可靠场景是把volatile用在不可变对象的引用上。比如你在类里维护一个配置对象配置一旦构建就不再修改由一个后台线程定时刷新其他线程只读。如果配置引用被声明成volatile那么发布配置前对配置内容的一切普通写操作都会通过volatile的happens-before规则被后续读取该引用的线程看到。这样说很抽象给个直观例子线程A先构建Config对象把内部参数填好再把config引用赋给volatile变量线程B读到volatile变量后不光能确定config不是null还能确定config内部的参数已经完整构建完。反过来如果config引用不是volatile对象就可能被部分构造后顺手发布出去B线程拿到的配置缺胳膊少腿。这个场景的隐含前提是对象本身不可变一旦对象内容在发布后还会被修改volatile只保护引用保护不了对象的内部状态。换句话说字段设置一旦完成就再也不变才能放心地只用volatile做发布对象内部还在被写来写去就必须连内部字段一起考虑同步方案否则还是白搭。3.3 场景三long/double读写的原子性保障volatile第三个比较实用的作用是保证long和double单次读写的原子性。为什么单独提这两种类型因为JMM规定非volatile修饰的64位类型在部分实现下可以被拆成两个32位操作。线程A写了long值的高32位还没写低32位线程B横插一脚来读这个long读到的可能是一个高半段来自新值、低半段来自旧值的拼接怪。volatile long和volatile double就可以避免这种读写被撕成两半保证单次读或写要么完整发生要么完整不发生。不过这里要补一刀volatile只保证单次读或写的原子性不保证long/double的复合操作原子性long依然是灾难。现在大多数64位JVM里普通long读写其实也是原子的所以这个场景更多像规范层面的兜底面试问到了能答上就是加分项但别指望靠它去解决什么高级并发问题。如果业务逻辑真的绕不开64位数据的并发读写先用设计把共享降到最低再考虑volatile而不是逆向地把volatile当成万能粘合剂。3.4 第一个不能碰的坑volatile救不了count说完正确场景立刻来看最常见的误用。很多写并发代码的人潜意识里认为volatile既然保证可见性那count这种操作应该也能用它防住并发问题吧答案是不行而且差得远。count在代码里看着像一步在字节码里其实是getstatic、iconst_1、iadd、putstatic四步是典型的读-改-写序列。volatile保证的是第一步读的时候能看到别人的最新值第四步写的时候能让别人看到但第二步和第三步之间完全可能被第二个线程穿插。两个线程同时读到count10各自加1再各自写回最终可能是11而不是12一次更新就这样悄无声息地丢了。要修这个问题最直接的办法是AtomicInteger它用CAS在CPU指令层把比较和交换绑成原子操作或者用synchronized把整个自增过程包进临界区。这段结论请重点记住volatile不保证原子性任何依赖当前值进行修改的复合操作都别想靠它蒙混过关。我在评审里问过很多候选人一说可见性头头是道一写count就开始含糊本质还是没有把volatile的边界刻在脑子里。4. volatile与synchronized、Atomic的选型对比4.1 三者语义差异一张表把volatile、synchronized、Atomic类放在一起对比语义边界会清楚很多。对比维度volatilesynchronizedAtomic类可见性保证保证保证原子性单个volatile读写复合操作不保证临界区内复合操作整体原子单次CAS原子是否阻塞不阻塞可能阻塞忙等重试不阻塞使用粒度单个变量方法或代码块单个变量适用倾向单写多读、状态标志、安全发布多线程写共享状态、逻辑互斥计数器、累加器等简单复合操作表格一摆就能看出来volatile根本不是synchronized的平替它是另一个维度的工具。synchronized的本质是互斥进入临界区的线程独占锁其他线程只能等锁内的读、改、写被串成一条线自然就同时解决了可见性、原子性和有序性。volatile的本质更像通知它不阻止其他线程进入只保证某个变量的变化信息能被及时看见。Atomic类则是用CAS乐观锁的思路把比较和交换做成CPU原子指令兼顾可见性和原子性但一般只适合做单变量的简单操作。三者各有适用边界硬要互相替代最后多半是踩坑。4.2 选型逻辑什么时候锁、什么时候原子类我选型时基本按下面几个判断走先写下来给大家参考一个线程写、其他线程只读且写不依赖当前值优先volatile多个线程写同一个值或写操作依赖当前值比如计数、累加、累减优先Atomic类需要同时维护多个共享变量的逻辑一致性比如转账要同时改两个账户余额volatile和Atomic都搞不定必须用synchronized或Lock把整个事务包起来担心竞争激烈导致线程阻塞、吞吐下降可以先考虑Atomic的CAS忙等但CAS在极端竞争下也会出现频繁重试未必比锁划算。判断的核心不在于变量类型或性能而在于你到底保护的是“一个变量”还是“一段逻辑”。保护一个变量的最新值优先考虑volatile或Atomic保护一段由多个步骤组成的逻辑比如先检查再操作、先读再改就必须引入锁。把这两个问题想清楚选型就不会犹豫。很多线上案例看起来是并发bug实际上是没有把操作边界划清楚工具只是背锅侠。4.3 性能感知volatile真的慢很多吗说完选型再聊聊性能。volatile不是零成本它的读写要跨内存屏障屏障在CPU流水线上有额外开销还会影响缓存的正常利用所以比普通变量读写要贵一些。但相比synchronized它不会阻塞线程没有锁竞争、没有线程状态切换、没有内核调度开销在读多写少的场景里往往比锁更划算。我之前做过一次简单的对比测试同一个共享变量被多个线程高频读取volatile版本的吞吐比synchronized版本明显高出一截但如果是高频写volatile和synchronized的差距就没那么大而且多个写线程本身就要求原子性volatile往往连参选资格都没有。不要为了省一点性能去硬切volatile语义对的时候顺便获益才是正路。性能优化最忌讳拿一个感觉上的结论去改代码改完还不好验证。先用规则判断这变量该不该volatile再考虑快慢顺序不能反。5. 面试官视角volatile高频考点与雷区排查5.1 面试考察的五个核心点把volatile放到面试场景里面试官基本都在考五个点每个点其实都能延伸出一个小题库对volatile的理解要求说出可见性和有序性并主动澄清不保证原子性volatile和synchronized的区别要求从语义、粒度、阻塞机制、性能倾向几个维度拆DCL单例为什么必须加volatile考new对象三步与指令重排的关系volatile能不能保证原子性用count的读改写反例来答volatile的底层原理能提到内存屏障和happens-before规则基本就是高分回答。如果面试官只给一句“说下你对volatile的理解”聪明的做法是先把三大特性摆出来再说它管哪两个最后主动讲一个反例把边界划清楚。这样会显得你既懂它优点也懂它限制而不是背一段八股文。我面试别人的时候最怕听到“volatile保证可见性防止指令重排”这句话就停住了后面追问一句“所以它能保证原子性吗”很多人就开始支支吾吾。这个补充不是炫技是真的能暴露出一个人对并发模型的熟悉程度。5.2 happens-before规则volatile写-读的强约束volatile背后有一条JMM核心规则对一个volatile变量的写操作happens-before于后续任意线程对这个变量的读操作。这句话看着只是在讲读写顺序实际上能量比想象中大它不只保证读线程看到volatile变量本身的最新值还保证写线程在写这个volatile变量之前发生的所有普通写操作对读这个volatile变量之后的线程都是可见的。这个传导性非常重要看代码class ConfigPublish { private int configVersion; private volatile boolean ready false; void publishConfig(int version) { this.configVersion version; ready true; // volatile写 } void useConfig() { if (ready) { // volatile读 System.out.println(configVersion); } } }线程A调用publishConfig写入configVersion然后写ready线程B调用useConfig先读ready发现是true再读configVersion。由于happens-before的传递性B读到的configVersion一定是A写入的值不会是旧的默认值。这种“先普通写再volatile写先volatile读再普通读”的模式是volatile最精华的用法之一。理解到这里你对可见性的理解就不再停留于概念而是能解释为什么一个普通变量能被另一个volatile变量“捎带”着安全发布。5.3 误用案例与排查经验最后把我在实践中见到的volatile误用案例拉出来做成速查清单误用一volatile boolean加多个线程同时翻转状态。状态翻转属于读改写volatile防不住穿插结果会出现状态翻转被覆盖误用二volatile修饰引用但引用指向的对象内部字段还在变。volatile只保证引用本身的可见性内部字段仍需要其他同步手段误用三volatile修饰long/double但做复合运算比如volatile long balance然后balance照样丢更新误用四拿volatile保护集合类比如volatile List集合内部的增删改在多个线程里依然线程不安全。真遇到线上并发问题排查的第一原则不是急着优化代码而是先还原操作顺序和变量边界。我有一次排查很诡异的数据错乱问题时有时无后来才发现是编译器对非volatile字段的重排导致代码看着顺序对执行顺序完全不对。当时加volatile之后现象消失再从JMM角度倒推逻辑关系最后确认是内存可见性问题。排查并发问题最重要的不是碰运气而是把JMM的规则一条条对上去谁在写、谁在读、写入的值是否依赖旧值、有没有被重排的可能。按这个套路走大部分问题都能在一个小时内定位到根因。5.4 补充volatile与final、AtomicBoolean的关系说到安全发布还有一个经常被拎出来比较的关键字final。final的核心语义是被它修饰的字段在构造器中一旦初始化完成之后就不能再被修改。在JMM的规则里正确构造的对象在初始化阶段对final字段的写入对后续读取该对象的线程是可见的不需要额外同步。所以很多人会问安全发布到底用volatile还是final答案是如果字段在对象构造完成后永不修改final就够如果字段在运行时还需要被替换或更新才需要volatile。两者不是竞争关系而是不同生命周期的安全发布手段。AtomicBoolean则更像是volatile boolean的“原子增强版”。当你只需要一个标志位的可见性时volatile boolean最轻量当你需要对这个标志位做“比较再设置”这种操作时比如CAS翻转状态就需要AtomicBoolean。性能上两者都很快但语义上完全不同。把这一层关系理清楚选型的时候就不会再纠结先看操作是否是简单的读或写再看是否需要原子改写最后才决定用谁。写到这里volatile的几个关键点都过了一遍。我个人的习惯是团队里一旦有人想在并发代码里加volatile我都会请他回答三个问题这个变量的写线程有几个写操作是否依赖当前值能不能用不可变对象整体发布来替代一堆零散字段能答得清楚用volatile基本不会出错答不清楚多半是拿它填坑后续迟早要还给并发代码。这个习惯帮我省了不少改bug的时间也推荐你试试。下一篇准备把final在并发语义里的角色一起讲掉把安全发布这个话题彻底收尾避免大家看到volatile就急着到处加。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →