ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java volatile关键字详解:从JMM到内存屏障,彻底搞懂并发可见性与有序性

Java volatile关键字详解:从JMM到内存屏障,彻底搞懂并发可见性与有序性 先别急着翻volatile的面试题答案。在 Java 并发编程里volatile是一个特别容易“看着会了、一用就废”的关键字。很多人在写多线程代码时要么不敢用它要么滥用它这两种极端我都见过。这篇东西我打算把它彻底掰开揉碎讲清楚内容包括它是怎么解决可见性和有序性的、底层靠什么机制实现、经典的 DCL 单例为什么离不开它、以及我在真实项目里踩过的几个坑。文章会结合 Java 内存模型JMM、Happens-Before 规则和内存屏障来展开同时给出可直接落地的代码示例和面试高频问题解析。1. volatile 到底解决了什么问题1.1 先从 Java 内存模型说起要理解volatile必须先把 Java 内存模型Java Memory ModelJMM搞清楚。JMM 规定所有变量都存储在主内存中每个线程还有自己的工作内存。线程对变量的所有操作读取、赋值都必须在工作内存中进行不能直接读写主内存。不同线程之间无法直接访问对方的工作内存线程间变量值的传递需要通过主内存完成。这句话翻译成人话就是线程 A 改了变量线程 B 不一定会立刻知道。因为在硬件层面CPU 有寄存器、有各级缓存编译器也会做指令重排序优化JVM 的 JIT 也会做优化。这些优化在单线程下是安全的但在多线程环境下就可能出问题。我举个实际场景。假设有个开关标志public class FlagExample { private boolean running true; public void stop() { running false; // 线程 A 调用 } public void work() { while (running) { // 线程 B 执行循环 } } }这段代码在理想情况下线程 A 调用stop()后线程 B 的循环应该退出。但实际运行中线程 B 很可能永远循环下去。原因就是线程 B 在它的工作内存中缓存了running true线程 A 对running的修改没有及时同步到主内存或者线程 B 根本没有重新从主内存读取这个值。这就是典型的可见性问题。1.2 volatile 的两个核心语义volatile关键字就是为解决这类问题设计的它有两个核心语义第一内存可见性。当一个线程修改了volatile变量的值新值会立即同步回主内存当其他线程读取这个变量时会强制从主内存重新读取而不是用工作内存中的缓存值。这相当于告诉 JVM“这个变量别缓存每次都直接跟主内存打交道。”第二禁止指令重排序。编译器和 CPU 为了优化执行效率可能会将指令顺序打乱。volatile通过插入内存屏障指令限制了对它前后代码的重排序行为。这一点在单例模式的 DCLDouble-Checked Locking写法中至关重要后面我会专门展开。我整理了volatile和synchronized的核心区别如下表所示对比维度volatilesynchronized解决的核心理念可见性、有序性原子性、可见性、有序性通过加锁实现是否阻塞线程否无锁是需要获取监视器锁是否保证复合操作原子性否i这类操作不安全是锁保证了代码块内操作不会交错适用场景状态标志、单例 DCL、轻量级状态同步多线程共享可变状态、需要原子性保障的场景性能开销相对较小但有内存屏障成本较大竞争锁、上下文切换1.3 volatile 不保证原子性这个坑很多人掉进去过面试里最经典的连环问就是“volatile能保证原子性吗”答案是不能。看这段代码public class Counter { private volatile int count 0; public void increment() { count; // 看似一行其实是三步操作 } }count在字节码层面不是一条指令而是“读取值、计算加一、写回值”三个步骤。线程 A 和线程 B 可能同时读到了count 0然后分别在自己的工作内存中加一各自写回结果都是1最终丢了一次累加。两个线程都执行一次increment()期望结果是2实际结果却是1。所以说volatile是“可见性的守护者”但它不是“原子性的保险箱”。如果需要对数字做累加操作应该用AtomicInteger或synchronized。2. 深入底层volatile 是如何做到的2.1 内存屏障volatile 的物理基础前面说了语义但语义是靠底层指令撑起来的。volatile的底层实现依赖于内存屏障Memory Barrier。内存屏障是一种 CPU 指令它的作用是阻止屏障两侧的指令重排序并确保数据可见性。JVM 在生成volatile变量的读写指令时会在合适的位置插入内存屏障。具体来说JVM 对volatile变量的读写插入屏障的策略是在每个volatile写操作的前面插入 StoreStore 屏障后面插入 StoreLoad 屏障。StoreStore 屏障保证在volatile写之前的所有普通写操作已经对其他处理器可见StoreLoad 屏障保证volatile写之后再读取其他变量时能读到最新值。在每个volatile读操作的后面插入 LoadLoad 屏障和 LoadStore 屏障。LoadLoad 屏障保证volatile读操作后续的普通读操作不会被重排到volatile读之前LoadStore 屏障保证volatile读操作后续的普通写操作不会被重排到volatile读之前。这样一套组合拳保证了volatile变量与周围普通变量之间的操作顺序不会被 CPU 或编译器任意调整。2.2 JMM 对 volatile 重排序规则的约束Java 内存模型本身有一个专门的表来规定哪些重排序是允许的、哪些是被禁止的。简单总结就是当第一个操作是普通读或写第二个操作是volatile读时这两个操作不能重排序。当第一个操作是volatile写第二个操作是普通读或写时这两个操作不能重排序。当第一个操作是volatile写第二个操作是volatile读时不能重排序。还有个更微妙的地方volatile读可以被看作是一个“获取”操作它之后的普通读写不能越过它往前排volatile写是一个“释放”操作它之前的普通读写不能越过它往后排。这有点像锁的“进入临界区”和“退出临界区”的概念。2.3 硬件层面的缓存一致性协议除了内存屏障硬件层面还有一个东西在背后帮忙那就是缓存一致性协议典型代表是 MESI 协议。这个协议规定了 CPU 缓存行的四种状态Modified修改、Exclusive独占、Shared共享、Invalid无效。当 CPU 核心写入一个volatile变量时它会基于缓存一致性协议发出信号让其他核心中缓存了该变量的缓存行变为 Invalid 状态。其他核心要读取这个变量时发现缓存行失效就会重新从主内存加载最新值。我在实际测试中发现不同硬件平台对volatile的性能表现略有差异但这套机制在 x86、ARM 架构上都能保证正确性只是内存屏障的具体指令实现不同。x86 有较强的硬件内存模型所以开销相对较小ARM 是弱内存模型需要插入更多的屏障指令开销会大一些。3. volatile 的经典应用场景与代码实战3.1 DCL 单例模式为什么双重检查必须用 volatileDCLDouble-Checked Locking双重检查锁单例应该是最常见的volatile面试题了。public class Singleton { // 重点必须使用 volatile 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 new Singleton()这行代码在字节码层面并不是一个原子操作。它大致做了三件事分配内存空间。调用构造函数初始化对象。将instance引用指向分配的内存地址。在没有volatile的情况下JIT 编译器或 CPU 可能将步骤 2 和步骤 3 重排序变成“先让引用指向内存地址再初始化对象”。这就导致一个严重后果线程 A 执行完步骤 3引用指向了内存地址但对象还没完全初始化线程 B 恰好在这时进入getInstance()第一次检查发现instance ! null直接返回了这个半初始化状态的对象。线程 B 拿到这个对象后去访问它的字段就可能读到默认值0、null 等甚至出现更隐蔽的问题。加上volatile后instance的写入就变成了一个volatile写内存屏障禁止了这个写操作与之前的对象初始化操作重排序。这样就能保证只有当对象完全初始化完成后引用才会对外可见。这是我强烈建议好好研究的例子因为它是volatile禁止重排序语义最经典的体现也是面试官最喜欢深挖的点。3.2 状态标志与生命周期控制volatile也极其适合作为线程间的状态标志。我之前在做一个异步任务调度系统时就用它控制任务的暂停和继续public class TaskRunner { private volatile boolean paused false; public void pause() { paused true; } public void resume() { paused false; } public void run() { while (!paused) { doWork(); } } }这里用volatile非常合适因为paused是一个独立的原子状态写入操作不依赖它的当前值不存在“先读后写”的复合操作。一个线程负责修改状态其他线程只负责读取这种一写多读的场景正好是volatile的主场。3.3 轻量级的“发布安全”场景volatile还有一个容易被忽略的场景安全发布不可变对象。假设现在有一个对象它的所有字段都是final的你通过一个volatile引用将它发布给其他线程。那么其他线程通过这个volatile引用读取对象时不仅能拿到最新的引用还能保证看到这个对象的所有字段的完整初始化状态。这个特性很适合保存配置快照。比如在配置中心里public class ConfigHolder { private volatile AppConfig cachedConfig; public void refresh(AppConfig newConfig) { cachedConfig newConfig; } public AppConfig getConfig() { return cachedConfig; } }只要AppConfig是不可变对象那么cachedConfig加volatile就能保证消费者线程要么看到旧配置的完整状态要么看到新配置的完整状态绝不会看到“新旧混合”的中间状态。这种发布方式开销比加锁小得多非常适合读多写少的配置场景。3.4 复合操作场景volatile 与锁的搭配使用某些场景下volatile可以和锁配合起来降低锁的范围。例如使用volatile保存状态再结合synchronized做状态流转public class StateMachine { private volatile State state State.INIT; public synchronized void transitionTo(State target) { // 这里做状态校验和流转 state target; } public State currentState() { return state; } }状态更新放在同步块里保证正确性状态读取则直接用volatile避免加锁这样读操作的开销极低。在状态多、读取频率高的系统里这个组合可以显著降低锁竞争压力。4. 实践中的高级主题与性能考量4.1 volatile 与 Happens-Before 规则Happens-Before 是 JMM 定义的一套偏序关系它规定了如果操作 A Happens-Before 操作 B那么 A 的结果对 B 是可见的。volatile有一条专属规则对一个volatile变量的写操作Happens-Before 于后续对这个volatile变量的读操作。这条规则的实际意义很深远。它不只让单个变量可见还能“传递可见性”。举个例子// 线程 A config loadConfig(); // 普通写 ready true; // volatile 写 // 线程 B if (ready) { // volatile 读 useConfig(config); // 普通读 }当线程 B 读到ready true时因为ready的写 Happens-Before 于它的读所以线程 A 在写ready之前对config的普通写操作对线程 B 也是可见的。这就是volatile写之前的普通操作会被“连带发布”的原因。这个特性在许多框架中都有体现。比如 Java 并发包中的AbstractQueuedSynchronizerAQS就用volatile int state作为同步状态的载体锁的释放和获取都依赖对 state 的volatile读写来传递其他共享变量的可见性。4.2 volatile 在实际项目中的性能影响很多人以为volatile没有锁性能开销一定是零。这个认知需要修正。volatile确实没有上下文切换和线程阻塞的成本但它有内存屏障的开销代价是限制了 CPU 指令重排序和缓存优化可能降低指令级并行的效率。在 x86 架构下volatile写通常能被 CPU 较好地处理但volatile读在需要等待无效缓存行确认时可能产生延迟。我在一个高并发读取统计系统中做过仿真压测用多线程模拟无锁普通变量的吞吐量比加volatile的版本高一些但差异不大远小于加锁带来的性能下降。所以除非是那种对性能要求极其苛刻、并且已经把锁竞争优化到极致的系统否则不要因为性能顾虑拒绝volatile。volatile的开销是可接受的而用synchronized或ReentrantLock在竞争激烈时开销可能高一个数量级。4.3 volatile 数组与 volatile 引用容易误解的地方volatile修饰数组时很多人的第一反应是“数组元素都具备可见性”这是错误的。volatile数组只保证数组引用本身的可见性和有序性不保证数组元素的可见性。如果你有一个volatile Foo[] arr线程对arr[0] new Foo()赋值其他线程不一定会立即可见。如果要实现数组元素的线程安全常用的方案是使用AtomicReferenceArray或直接加锁。同理volatile修饰引用类型时它保证的是引用的可见性而不是引用所指对象内部状态的可见性。如果你修改了对象的字段这个修改的可见性仍然需要另想办法比如让字段本身变成 final 并且对象不可变。4.4 单例模式之外的进阶应用轻量级读-写锁有时候可以用volatile实现一个轻量级的读多写少的同步场景。假设有一个共享配置对象它的值是整数读操作非常多写操作偶尔发生public class LightweightHolder { private volatile int value; public int get() { return value; } public synchronized void update(int newValue) { this.value newValue; } }读操作完全无锁写操作加锁保护。由于value是int读写是原子的加volatile后读操作永远拿到的都是最近一次写入的值不会有“读到一半”的脏数据问题。这就是我在项目里最常用的一个模式比给读写都加锁的方案性能要好得多。5. 面试高频问题与避坑经验5.1 常见面试题看得见的陷阱围绕volatile有几个高频问题可以说每次面试都会碰到。建议重点准备volatile 和 synchronized 的区别是什么这个要能从可见性、有序性、原子性三个维度作答提一下锁的获取与释放是 synchronized 能够实现可见性的底层原因。volatile 能保证原子性吗为什么因为count这类操作包含读、改、写三步volatile 不能让三步成为一个原子操作。为什么 DCL 单例需要 volatile关键在于对象初始化过程中的指令重排序可能让其他线程提前拿到未完全初始化的对象。volatile 适用什么场景状态标志、一写多读、安全发布不可变对象。Happens-Before 规则中 volatile 的规则是什么volatile 变量的写先于后续的读发生且这种规则具有传递性。回答这些问题时最好能结合 JMM、内存屏障和具体场景来讲而不是只背概念。面试官通常容易从回答中判断你是背了八股还是真的理解。5.2 我在实际项目中踩过的三个坑第一个坑是“误以为 volatile 能保证复合操作安全”。我之前在一个统计在线人数的模块里用volatile int onlineCount记录人数然后做onlineCount和onlineCount--。在上线初期流量不大时没出问题直到一次活动流量上来并发一高统计数字就飘了。排查后发现就是原子性问题最后改成AtomicInteger才彻底解决。第二个坑是“在 volatile 修饰的对象里修改内部字段”。我在一个项目里用volatile修饰了一个Map引用以为这样就能保证并发读写安全。结果测试发现线程 A 往 Map 里放数据线程 B 读取时经常看不到。原因就是volatile只能保证引用本身可见不能保证Map内部状态的线程安全。最后换成了ConcurrentHashMap并调整整体设计问题才解决。第三个坑是“对 volatile 变量的可见性做了过度依赖”。在一个异步队列任务中我用volatile标记一个任务是否被取消。但如果线程内部业务逻辑耗时较长在循环体内已经进入了耗时的数据库调用就算你把取消标志置为 true这个线程也要等当前调用返回后才能检查到变化。这不是volatile的问题而是对取消响应时机理解不够。后来我改了设计在耗时操作的分段节点都检查取消标志并配合中断机制一起处理效果才符合预期。5.3 volatile 在 JDK 源码中的使用范例如果想把volatile用明白建议去读读 JDK 源码。java.util.concurrent.locks.ReentrantLock中的state字段就是volatile用于记录锁的持有状态。AtomicInteger底层用的value也是volatile保证读写可见性配合 CAS 指令实现原子更新。ConcurrentHashMap内部数组的元素数组对象也大量使用volatile来保证某些节点的可见性。看这些源码时你会发现volatile总是出现在“某个状态需要被多个线程看到但更新逻辑本身由其他机制保障原子性”的地方。这个搭配思路比单独学会volatile本身更重要。5.4 一个容易忽略的知识点volatile 与 final 的组合final字段在 JMM 中有自己的可见性保证对象构造函数的末尾会插入 StoreStore 屏障确保final字段在构造函数中写入后外部线程通过正确发布的引用读取该字段时一定能看到正确的值。而volatile字段则没有这个保证它只保证对字段本身的读写不会被无限推迟不保证构造过程中的写入一定对外立即可见。但两者结合使用却能达到很好的效果。比如前面提到的不可变配置对象字段用final保证对象内部的正确发布持有引用的变量用volatile保证引用的更新可见。这是一个非常经典、安全的并发模式项目中经常能用到。6. 小结volatile 的正确使用姿势把volatile的一切讲完后最关键的问题变成了“我到底什么时候该用它”。我个人的经验是第一用来做状态标志位比如线程开关、初始化完成标记这类场景只有一个线程写、多个线程读非常安全。第二作为 DCL 单例的修饰符解决对象初始化指令重排序的隐患。第三安全发布不可变对象比如把新配置、新快照通过volatile引用暴露给所有读取线程。第四在锁保护的写操作之外用volatile优化高频读操作降低锁竞争成本。我在代码评审中有一条非常严格的标准如果一段代码对同一个变量要做两个以上的连续操作比如判断后再修改或者修改后再判断那么volatile多半是不够的得考虑加锁或使用并发原子类。volatile是“可见性的守护者、有序性的调节器”但它不是原子性的魔法师。最后分享一个小技巧调试并发问题时如果一时判断不清楚该不该用volatile可以先用AtomicReference或AtomicInteger顶上功能正确后再用 JMH 压测对比两者性能。一般场景下两者差距不大但Atomic*类能帮你规避很多因误用volatile引起的隐蔽 bug等代码稳定后再决定是否做优化降级。这个“先保证正确再考虑精巧”的思路能帮你省下不少排查问题的精力。
返回列表