资讯动态

深入理解volatile:可见性、禁止重排与实战避坑指南

发布时间:2026/10/13 21:41:01 来源:尧图企业网站定制
1. 从一次诡异的死循环说起volatile到底在解决什么问题很多刚接触多线程的开发者第一次遇到volatile这个词大概率是在面试八股文里。背几句“保证可见性、不保证原子性、禁止指令重排”面试也能糊弄过去。但真正到了项目里一个线程改了标志位另一个线程死活读不到新值程序卡在while(!flag)里出不来这时候你才会意识到这个关键字不是用来背的是用来救命的。我最早踩这个坑是在一个后台任务调度的小模块里。主线程负责下发停止指令工作线程循环检查一个布尔变量逻辑上看起来天衣无缝。结果测试环境跑得好好的一到生产环境工作线程就像聋了一样主线程把标志位改成true它还在那儿傻转。当时排查了大半天最后定位到问题工作线程读的一直是自己工作内存里的旧副本压根没去主内存同步。加上volatile之后问题瞬间消失。这个经历让我明白一件事volatile的核心价值不是让你写出多高深的并发代码而是让你在“一个线程写、多个线程读”这种最朴素的场景下不至于掉进内存可见性的陷阱。它解决的是可见性问题顺带禁止指令重排但它不保证原子性——这三句话是理解volatile的全部地基。这篇文章我打算按实际使用的思路来写不搞教科书式的定义堆砌。先讲清楚 Java 内存模型到底怎么让变量“看不见”再拆解volatile的两层语义然后重点说清楚它和synchronized、Atomic系列的区别最后给几个真实场景下的用法和避坑经验。如果你正在写多线程代码或者被volatile的各种“保证”和“不保证”绕晕了这篇应该能帮你把思路理顺。2. Java内存模型变量为什么会在眼皮底下“消失”2.1 主内存与工作内存的抽象关系要理解volatile绕不开 Java 内存模型JMM。很多人一看到“模型”两个字就觉得抽象其实用一个生活场景就能说明白。想象一个公司里有一个公共白板主内存所有员工线程都能看到上面的内容。但每个员工手里还有一个自己的小本子工作内存/本地内存。员工要读白板上的数据不会每次都跑到白板前看而是先抄到自己本子上之后就用本子上的记录。要写数据也是先写在自己本子上至于什么时候同步回白板看心情或者说看 JMM 的规则。问题就出在这里员工 A 把白板上的flag改成了true但员工 B 本子上记的还是falseB 就一直按false来干活。这就是可见性问题——一个线程的修改另一个线程不一定能立刻看到。JMM 规定所有变量都存在主内存每个线程有自己的工作内存线程对变量的所有操作都必须在工作内存中进行不能直接读写主内存。线程间变量值的传递必须通过主内存来完成。这套抽象不是 Java 独有的而是为了屏蔽不同硬件和操作系统的内存差异让并发程序有一套统一的语义。2.2 可见性问题的三种典型触发场景可见性问题不是玄学它有明确的触发条件。我总结下来最常见的是这三种第一种循环检查标志位。就是开头说的那种一个线程while(!flag)空转另一个线程改flag。如果flag不是volatileJIT 编译器甚至可能把flag的读取优化到寄存器里循环体里根本不重新读内存这时候别说可见性了连内存都不访问了。第二种状态缓存。比如一个配置对象初始化线程写完配置后设置一个initialized true其他线程看到true就去读配置。如果initialized没有volatile修饰其他线程可能看到true但读到的配置字段还是旧值因为指令重排把配置写入排到了标志位设置之后。第三种双重检查锁定的单例。这是经典中的经典。instance new Singleton()这行代码实际分三步分配内存、初始化对象、把引用指向内存。如果没有volatile步骤 2 和 3 可能重排另一个线程拿到一个非 null 但还没初始化完的对象用的时候直接空指针或者数据错乱。这三种场景的共同点是一个线程写另一个线程读且读线程依赖写线程的结果。只要满足这个模式就必须考虑可见性。2.3 从字节码到CPU可见性问题的底层链路有经验的开发者会往下再挖一层为什么会有工作内存这个东西直接读写主内存不行吗答案是性能。CPU 的运算速度比内存快几个数量级如果每次读写都走内存CPU 大部分时间都在等。所以硬件层面引入了多级缓存L1、L2、L3每个 CPU 核心有自己的缓存。Java 的工作内存某种程度上就是这些缓存的抽象。写操作先写到缓存什么时候刷回主内存由缓存一致性协议比如 MESI来协调。读操作先看缓存缓存没有才去主内存拿。这套机制在单核时代没问题多核时代就出事了核心 A 改了缓存里的值核心 B 的缓存里还是旧值两边不一致。volatile在字节码层面会给变量加上ACC_VOLATILE标志JVM 在生成机器码时会在写操作后插入内存屏障强制把缓存刷回主内存并在读操作前插入屏障强制从主内存重新加载。这就是它保证可见性的底层手段。理解到这一层你就不会再把volatile当成一个“魔法关键字”而是一个有明确硬件语义的工具。3. volatile的两层语义可见性与禁止重排缺一不可3.1 可见性保证写立刻刷回读必须重新加载volatile的第一层语义是可见性。具体来说当一个线程写一个volatile变量时JMM 会把该线程工作内存中这个变量的值立刻刷回主内存。当一个线程读一个volatile变量时JMM 会先把该线程工作内存中这个变量的缓存置为无效然后从主内存重新读取。这两条规则合起来效果就是对volatile变量的写对后续读这个变量的线程立即可见。注意这里的“立即”是相对的不是说写完之后全世界所有线程在同一纳秒看到而是说读线程在读取这个变量时一定会去主内存拿最新值不会用自己缓存里的旧值。这个保证在大多数业务场景下已经足够了。我见过有人问“那我给变量加个volatile是不是就不用加锁了”这要看你说的“锁”是干什么用的。如果只是为了保证一个线程改了另一个线程能看到那volatile确实够了。但如果涉及复合操作比如countvolatile就无能为力了因为count是“读-改-写”三步中间任何一步都可能被其他线程插进来。3.2 禁止指令重排内存屏障在背后做了什么volatile的第二层语义是禁止指令重排。这个比可见性更隐蔽也更容易被忽略。现代 CPU 和编译器为了性能会对指令进行重排序。只要不改变单线程的执行结果怎么排都行。但在多线程环境下单线程看起来没问题的重排可能导致另一个线程看到“半成品”状态。volatile通过插入内存屏障来限制重排。JMM 定义了四种屏障屏障类型作用LoadLoad禁止读操作与后面的读操作重排StoreStore禁止写操作与后面的写操作重排LoadStore禁止读操作与后面的写操作重排StoreLoad禁止写操作与后面的读操作重排开销最大对volatile写操作会在前面插入 StoreStore 屏障后面插入 StoreLoad 屏障。对volatile读操作会在前面插入 LoadLoad 屏障后面插入 LoadStore 屏障。这些屏障的具体位置和组合保证了volatile写之前的操作不会排到写之后volatile读之后的操作不会排到读之前。用一句更通俗的话说volatile写之前的代码一定在写之前执行完volatile读之后的代码一定在读之后执行。这个特性在双重检查锁定里至关重要。instance new Singleton()中初始化对象的操作必须在引用赋值之前完成否则另一个线程可能拿到未初始化的对象。volatile的 StoreStore 屏障正好保证了这一点。3.3 为什么“不保证原子性”是必须记住的红线volatile最容易被误用的地方就是把它当成轻量级锁以为加了volatile就线程安全了。实际上volatile只保证单次读或单次写的原子性不保证复合操作的原子性。以count为例它实际包含三步从主内存读取count的当前值到工作内存。在工作内存中执行加 1。把结果写回主内存。假设count是volatile的两个线程同时执行count可能出现这样的时序线程 A 读到count 10。线程 B 读到count 10。线程 A 加 1 写回11。线程 B 加 1 写回11。两次自增结果只加了 1。volatile的可见性保证每次读到的都是最新值但读和写之间有时间窗口其他线程可以插进来。这就是为什么volatile不能替代锁。那什么场景下volatile是安全的一写多读且写操作不依赖当前值。比如设置一个停止标志volatile boolean stopped true这个写操作是单次的不依赖之前的值所以安全。再比如状态发布初始化完成后设置volatile boolean initialized true也是单次写安全。记住这条红线只要涉及“读-改-写”或者“检查再执行”volatile就不够用必须上锁或者用原子类。4. volatile、synchronized、Atomic三者到底怎么选4.1 从开销和语义两个维度做对比很多人纠结volatile、synchronized和Atomic系列怎么选。我的经验是从两个维度判断语义够不够和开销能不能接受。先看语义特性volatilesynchronizedAtomic系列可见性保证保证保证原子性单次读写代码块整体单次操作禁止重排保证保证保证阻塞不阻塞阻塞不阻塞CAS自旋适用场景状态标志、一写多读复合操作、临界区计数器、累加器再看开销。volatile的开销最小基本就是内存屏障的成本不涉及线程状态切换。synchronized在 JDK 1.6 之后做了大量优化偏向锁、轻量级锁、锁消除、锁粗化竞争不激烈时开销也不大但一旦竞争激烈线程阻塞和唤醒的成本就上来了。Atomic系列基于 CAS无锁低竞争时性能很好但高竞争时自旋会浪费 CPU。所以选择逻辑很清晰如果只是发布状态、设置标志用volatile。如果是复合操作且竞争不激烈用Atomic。如果是复合操作且涉及多个变量或者需要条件等待用synchronized。4.2 一个计数器场景的三种写法实测对比光说理论不够直观我用一个计数器场景来演示三种写法的差异。写法一volatile 计数器错误示范public class VolatileCounter { private volatile int count 0; public void increment() { count; // 非原子操作多线程下结果错误 } public int getCount() { return count; } }这个写法在单线程下没问题多线程下count的最终值会小于实际调用次数。我实测过10 个线程各加 1000 次结果经常在 8000 左右徘徊丢了不少。写法二synchronized 计数器public class SyncCounter { private int count 0; public synchronized void increment() { count; } public synchronized int getCount() { return count; } }这个写法结果一定正确10 个线程各加 1000 次最终就是 10000。但每次increment都要抢锁竞争激烈时线程会阻塞吞吐量下降。写法三AtomicInteger 计数器public class AtomicCounter { private AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); } public int getCount() { return count.get(); } }这个写法结果也正确底层用 CAS 自旋不阻塞线程。低竞争时性能比synchronized好高竞争时自旋次数增多性能可能反超synchronized。实测数据仅供参考具体因机器和 JDK 版本而异写法10线程×1000次耗时结果正确性volatile约 2ms错误约 8000synchronized约 15ms正确10000AtomicInteger约 5ms正确10000这个对比说明一个道理volatile不是用来做计数的用错地方不仅不解决问题还会掩盖问题。如果你看到有人用volatile修饰计数器基本可以判断他对并发模型的理解还停留在表面。4.3 什么时候volatile是“刚刚好”的选择volatile最合适的场景是状态标志和一次性安全发布。状态标志的典型例子是线程停止public class Worker implements Runnable { private volatile boolean stopped false; public void stop() { stopped true; } Override public void run() { while (!stopped) { // 执行任务 } } }这里stopped只有写和读没有复合操作volatile完全够用。如果用synchronized每次循环都要抢锁性能差很多。一次性安全发布的例子是配置加载public class ConfigHolder { private volatile Config config; public Config getConfig() { if (config null) { synchronized (this) { if (config null) { config loadConfig(); } } } return config; } }这里config是volatile配合双重检查锁定既保证了可见性又避免了每次读都加锁。注意config的写是在synchronized块里但读是在锁外volatile保证了读线程能看到最新的config引用。这两个场景的共同点是写操作不依赖当前值且读操作远多于写操作。满足这个条件volatile就是最优解。5. 双重检查锁定的完整拆解一个被讲烂但依然有人写错的案例5.1 从单例的三种写法看volatile的不可替代性单例模式是volatile最经典的应用场景没有之一。但即使被讲了无数遍我还是在代码审查中见过各种错误写法。写法一饿汉式public class Singleton { private static final Singleton INSTANCE new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } }这个写法线程安全因为类加载机制保证了静态变量只初始化一次。缺点是类一加载就创建实例如果实例创建开销大且不一定用到就浪费了。写法二懒汉式加锁public class Singleton { private static Singleton instance; private Singleton() {} public static synchronized Singleton getInstance() { if (instance null) { instance new Singleton(); } return instance; } }这个写法线程安全但每次调用getInstance都要抢锁性能差。即使实例已经创建好了还是要抢锁。写法三双重检查锁定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否则有指令重排风险。5.2 去掉volatile后重排到底会出什么乱子有人可能会问instance new Singleton()这行代码重排真的会发生吗概率有多大概率不大但一旦发生就是灾难。new Singleton()在字节码层面大致是new指令分配内存。invokespecial指令调用构造方法初始化。putstatic指令把引用赋值给instance。JIT 编译器或者 CPU 可能把步骤 2 和 3 重排变成先赋值引用再初始化。这时候另一个线程在锁外判断instance ! null直接返回一个还没初始化完的对象。这个对象的字段全是默认值用的时候可能空指针可能数据错误而且这种 bug 极难复现因为依赖特定的时序和 JIT 优化。我见过一个真实案例某服务在启动后偶尔报空指针重启就好一直找不到原因。后来发现是单例没加volatile在高并发启动时某个线程拿到了未初始化的实例。加上volatile后问题再没出现过。volatile在这里的作用就是通过 StoreStore 屏障禁止步骤 2 和 3 重排保证引用赋值一定在初始化之后。5.3 为什么局部变量能进一步优化双重检查锁定在《Java 并发编程实践》这本书里提到过一个双重检查锁定的优化写法用局部变量减少volatile的读取次数public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { Singleton result instance; if (result null) { synchronized (Singleton.class) { result instance; if (result null) { result new Singleton(); instance result; } } } return result; } }这个写法的核心思路是volatile读也有开销虽然比锁小但在高频调用下也值得优化。用局部变量result先读一次instance如果非空直接返回避免第二次读volatile。在synchronized块内也先读一次instance赋给result这样后续操作都用局部变量减少volatile访问。这个优化在现代 JVM 上收益可能不明显因为 JIT 会做各种优化但思路值得学习volatile不是免费的能少读一次就少读一次。6. 实战中的volatile三个真实场景与避坑清单6.1 场景一后台任务的优雅停止后台任务通常是一个循环不断从队列取任务执行。停止的时候不能直接Thread.stop()那个方法早就废弃了会强制释放锁导致数据不一致。正确做法是用一个volatile标志位。public class BackgroundTask implements Runnable { private volatile boolean running true; private final BlockingQueueTask queue; public BackgroundTask(BlockingQueueTask queue) { this.queue queue; } public void shutdown() { running false; } Override public void run() { while (running) { try { Task task queue.poll(100, TimeUnit.MILLISECONDS); if (task ! null) { task.execute(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; } } } }这里running是volatileshutdown方法在主线程调用工作线程在循环里检查。poll带超时避免running改成false后线程还阻塞在take上。这个模式我在多个项目里用过稳定可靠。注意running的写操作是单次的不依赖当前值所以volatile足够。如果shutdown里还有其他状态要改比如记录停止时间那就要考虑用Atomic或者加锁。6.2 场景二配置热更新的可见性保障配置热更新是另一个常见场景。配置中心推送新配置后应用需要在不重启的情况下生效。通常做法是用一个volatile引用指向配置对象更新时创建新对象然后替换引用。public class ConfigManager { private volatile Config currentConfig; public Config getConfig() { return currentConfig; } public void updateConfig(Config newConfig) { // 校验新配置 validate(newConfig); // 替换引用volatile保证可见性 this.currentConfig newConfig; } }这个模式的好处是读配置的线程不需要加锁直接读volatile引用性能好。更新配置时创建新对象不影响正在读旧配置的线程避免了并发修改问题。注意Config对象本身必须是不可变的或者至少更新后不再修改。如果Config的字段也是可变的那volatile只能保证引用可见不能保证字段可见。这是很多人容易忽略的点。6.3 场景三状态标志与一次性初始化有些场景下我们需要确保某个初始化操作只执行一次且执行结果对其他线程可见。比如加载本地缓存public class CacheLoader { private volatile boolean loaded false; private MapString, String cache; public void ensureLoaded() { if (!loaded) { synchronized (this) { if (!loaded) { cache loadFromDisk(); loaded true; } } } } public String get(String key) { ensureLoaded(); return cache.get(key); } }这里loaded是volatile保证cache的写入对后续读线程可见。synchronized保证只有一个线程执行加载。双重检查避免每次读都加锁。这个模式在懒加载场景下很常见但要注意cache的写入必须在loaded true之前完成volatile的 StoreStore 屏障保证了这一点。6.4 避坑清单volatile使用中的五个常见错误用了这么多年volatile我总结出五个最容易犯的错误列出来供你对照错误一用 volatile 修饰计数器。前面说过count不是原子操作volatile解决不了。正确做法是用AtomicInteger或LongAdder。错误二用 volatile 修饰多个相关变量。比如volatile int x和volatile int y想保证 x 和 y 的更新是原子的。volatile只保证单个变量的可见性多个变量之间没有原子性保证。这种情况必须加锁。错误三以为 volatile 能替代锁。volatile不阻塞线程不保证复合操作原子性不能替代锁。它只是锁的补充不是替代。错误四忽略 volatile 数组。volatile int[] arr只保证数组引用的可见性不保证数组元素的可见性。要保证元素可见得用AtomicIntegerArray或者加锁。错误五在 volatile 变量上做依赖当前值的操作。比如volatile int count; count count 1;这个写操作依赖当前值多线程下会丢更新。正确做法是用AtomicInteger的incrementAndGet。这五个错误我在代码审查中至少见过三个。每次看到我都会建议对方先补一下 JMM 的基础再回来改代码。7. 从volatile延伸出去那些你必须知道的并发基础7.1 happens-before规则与volatile的关系volatile的语义可以用 happens-before 规则来精确描述。JMM 定义的 happens-before 规则中有一条是对一个volatile变量的写操作happens-before 于后续对这个volatile变量的读操作。这条规则的意思是如果线程 A 写了volatile变量 v线程 B 读了 v那么线程 A 在写 v 之前的所有操作对线程 B 都可见。这个规则比单纯的“可见性”更强。它不仅保证 v 本身可见还保证 v 之前的所有写操作都可见。这就是为什么volatile可以用来做安全发布在写volatile变量之前的所有初始化操作读线程都能看到。理解 happens-before你就能理解为什么双重检查锁定里instance加volatile之后不仅instance引用可见Singleton对象的字段也可见。7.2 volatile与CAS的配合使用volatile和 CAS 经常配合使用。AtomicInteger内部就是用一个volatile int value加上 CAS 操作实现的。public class AtomicInteger { private volatile int value; public final int incrementAndGet() { return unsafe.getAndAddInt(this, valueOffset, 1) 1; } }volatile保证value的可见性CAS 保证更新的原子性。两者结合既无锁又线程安全。这个模式可以推广到自定义的并发数据结构。比如实现一个简单的自旋锁public class SpinLock { private volatile boolean locked false; public void lock() { while (!compareAndSet(false, true)) { // 自旋等待 } } public void unlock() { locked false; } private boolean compareAndSet(boolean expect, boolean update) { // 底层用CAS指令实现 return unsafe.compareAndSwapInt(this, stateOffset, expect ? 1 : 0, update ? 1 : 0); } }这里locked是volatile保证一个线程释放锁后其他线程能立刻看到。CAS 保证只有一个线程能抢到锁。7.3 从volatile到JMM建立完整的并发知识框架volatile是理解 JMM 的一个切入点但不是终点。学完volatile你应该顺着这条线把以下概念串起来原子性synchronized、Atomic系列、LongAdder。可见性volatile、synchronized、final。有序性volatile、synchronized、happens-before 规则。线程安全不可变对象、线程封闭、同步容器、并发容器。性能锁优化、无锁算法、CAS 的 ABA 问题。这些概念不是孤立的它们共同构成 Java 并发编程的知识框架。volatile是其中最简单的一个但也是最容易用错的一个。把volatile吃透再学其他并发工具会顺畅很多。我在带新人的时候通常会让他们先写一个不加volatile的停止标志跑出问题再加上volatile观察变化。然后让他们用AtomicInteger重写计数器对比volatile和synchronized的性能。这种“先踩坑再填坑”的方式比直接讲概念有效得多。最后分享一个我自己的习惯每次写多线程代码只要涉及共享变量我都会问自己三个问题——这个变量需要可见性吗需要原子性吗需要有序性吗三个问题的答案决定了用volatile、synchronized还是Atomic。这个习惯帮我避免了很多并发 bug也推荐你试试。

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

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

免费获取报价 →
↑