ARTICLE DETAIL

资讯详情

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

Java volatile关键字深入解析:从JMM原理到双重检查锁的工程实践

Java volatile关键字深入解析:从JMM原理到双重检查锁的工程实践 有人问过我一个问题Java里哪个关键字最容易被背会了但用错我的答案一直是volatile。早年在线上维护过一个订单状态模块一个用volatile修饰的状态标志位逻辑上只有true/false两种值结果线上出了多次状态错乱。排查到最后问题出在另一个服务通过极端时序绕过了我以为volatile就够了的判断。那一次之后我重新把Java内存模型、缓存一致性协议、指令重排序这些底层机制完整啃了一遍才算真正理解这个关键字。这篇文章就把整个思路复盘一遍覆盖volatile的作用边界、JMM底层原理、为什么不保证原子性、双重检查锁里为什么必须用它以及实战中怎么选型尽量讲得透彻、能直接用在工程和面试里。1. 从一次看起来没问题的状态标志说起1.1 一个典型的状态标志场景很多Java开发者接触volatile的第一个案例就是线程间传递状态信号。比如下面的代码public class ServiceLifecycle { private volatile boolean running true; public void stop() { running false; } public void work() { while (running) { // 执行核心任务 } } }这段代码里线程A调用stop()把running改成false线程B在work()里的while循环会立刻看到新值并退出。volatile在这里做的就是保证线程A的修改对线程B可见。如果没有volatileJIT编译器可能把running优化成寄存器里的常量线程B会一直死循环根本停不下来。这个场景我在项目里实际遇到过现象就是停服接口调用了但服务进程纹丝不动最后dump线程栈才发现循环还在跑。1.2 同一个字段换到累加场景就翻车同样是volatile修饰的字段一旦换成复合操作表现就完全不一样了public class VolatileCounter { private static volatile int count 0; public static void increment() { count; } }8个线程各执行10000次increment()你可能会预期得到80000。但实测结果经常是79000多、78000多甚至更低。原因很简单count不是一个原子操作。volatile保证了读取是最新的但读取-加一-写回这三步之间线程可能会被切换两个线程同时读到同一个旧值各自加一后写回结果等于只加了1这就是经典的丢失更新问题。所以volatile绝不是万能的它管不了原子性。1.3 我最初的一个误区把可见理解成了同步我刚工作那会儿有个错误认知觉得volatile既然能让一个线程的修改立刻被其他线程看到那它不就等于某种轻量级同步吗后来才想明白同步的本质是互斥同一时刻只允许一个线程操作共享数据。volatile完全没有这个能力它不阻止多个线程同时进入临界区它只承诺每次读都能拿到最新值。这两个维度是正交的一个是数据一致性一个是访问控制。把可见性当成互斥是volatile用错的第一大原因。提示判断一个场景能不能用volatile先问自己一句——这里允许多个线程同时操作同一个变量吗如果允许且操作不依赖旧值volatile才有戏如果存在竞争写或者读改写操作基本可以排除。2. JMM底层原理拆解volatile的内存语义与内存屏障2.1 JMM到底在管什么Java内存模型Java Memory ModelJMM定义了线程和内存之间的抽象关系它把我们平时说的主内存和工作内存划开了。每个线程有自己的工作内存相当于工位上的草稿纸操作共享变量时先读一份到草稿纸上改改完再同步回主内存。问题在于这个同步回主内存没有硬性时间点线程A改了草稿纸线程B手里的草稿纸还是旧版本两边各写各的最终谁先同步回去谁就覆盖了对方。volatile的作用就是强制给这份草稿纸加规则写volatile变量时必须立刻同步回主内存读volatile变量时必须先从主内存拉最新值不能用本地旧缓存。这套规则就是JMM为volatile定义的内存语义。2.2 volatile写和读的屏障规则JMM通过内存屏障Memory Barrier来限制指令重排序。指令重排序是编译器和CPU为了优化代码执行顺序而做的调换单线程内没问题多线程共享变量时就会出事。volatile的屏障规则可以归纳成四点在每个volatile写操作的前面插入StoreStore屏障禁止普通写和volatile写重排在每个volatile写操作的后面插入StoreLoad屏障禁止volatile写和后面可能的volatile读/写重排在每个volatile读操作的后面插入LoadLoad屏障禁止volatile读和后面的普通读重排在每个volatile读操作的后面插入LoadStore屏障禁止volatile读和后面的普通写重排。用大白话解释volatile写就像在快递单上盖了个加急章前面所有普通写入必须全部到达主内存之后这次volatile写才能发出volatile读就像收货时必须先验货后续的普通读和写都必须等这次volatile读完成之后才能进行。通过这四道屏障volatile在一定程度上保证了有序性。2.3 MESI缓存一致性协议的配合讲到硬件层面不得不提CPU的缓存一致性协议。现代CPU都有多级缓存同一个变量可能同时存在于L1、L2、主内存的多份拷贝里。为了让多核CPU看到一致的缓存数据硬件层面有MESI协议把缓存行标记为四种状态状态含义说明MModified已修改当前CPU缓存行与主内存不一致且只有本CPU持有EExclusive独占当前CPU缓存行与主内存一致且只有本CPU持有SShared共享多个CPU缓存行与主内存一致IInvalid失效缓存行已失效读取时必须重新从主内存加载当线程A修改了一个volatile变量当前CPU会通过总线嗅探机制向其他CPU广播这个缓存行失效的信号。其他CPU收到信号后把自己缓存里对应变量标记为IInvalid状态下次再读这个变量时就必须从主内存重新拉一次数据。这一套流程配合编译器和JIT插入的内存屏障最终实现了volatile的可见性和禁止重排序语义。注意JMM是语言层面的规范MESI是硬件层面的实现两者不是一回事。但在x86这类强内存模型平台上volatile语义的落地主要就是靠屏障指令加MESI协同完成的。在ARM这类弱内存模型平台上实现的细节会更复杂但开发者只需要面向JMM编程即可。3. volatile不保证原子性i问题的完整排查与验证3.1 第一时间准备一段Demo来复现纸上谈兵不如跑一次。我平时排查这种并发问题时习惯先用最简短的代码做压力验证下面这段就是典型的volatile原子性演示import java.util.concurrent.*; public class VolatileCounterDemo { private static volatile int count 0; private static final int THREAD_COUNT 8; private static final int LOOP_COUNT 10000; public static void main(String[] args) throws InterruptedException { ExecutorService pool Executors.newFixedThreadPool(THREAD_COUNT); CountDownLatch latch new CountDownLatch(THREAD_COUNT); for (int i 0; i THREAD_COUNT; i) { pool.submit(() - { for (int j 0; j LOOP_COUNT; j) { count; } latch.countDown(); }); } latch.await(); pool.shutdown(); System.out.println(最终count值: count); } }在这台机器上跑过很多次count很少能到80000大部分时候落在79000到79999之间。值得注意的是这个结果取决于线程调度时机每次跑都可能不一样有时候甚至能到79999看起来只差1但这也足以证明操作不是原子的。3.2 从字节码看count的三个步骤为了看清这一步到底做了什么我用javap -c反编译了increment方法核心字节码大概是这样的javap -c VolatileCounterDemo// 以increment方法的字节码为例 public static void increment(); Code: 0: getstatic #2 // Field count:I 3: iconst_1 4: iadd 5: putstatic #2 // Field count:I 8: return这4条字节码指令getstatic是把count的当前值压到操作数栈iconst_1是压入常量1iadd是把两个数相加putstatic是把结果写回count。问题就在这getstatic和putstatic之间线程完全可能被切换。线程A执行完getstatic拿到100还没来得及putstatic线程B也执行getstatic拿到了100两个线程各自加1都写回101。等于两次自增只生效了一次。3.3 三种正确解法以及怎么选如果这里必须做原子自增有几种常见方案。我整理了一张对比表方便实际项目里选型方案保证原子性高并发性能代码复杂度适用场景synchronized是一般锁竞争有开销低逻辑简单并发量不大追求稳定AtomicInteger是较好CAS并发度高时自旋重试低并发量适中需要读改写原子操作LongAdder是高并发下更好分段累加中写多读少对最终值一致性不敏感synchronized走的是锁互斥路线简单粗暴但高并发下锁竞争会让吞吐量明显下降。AtomicInteger走的是CAS比较并交换路线无锁无阻塞但热点变量竞争激烈时大量线程会陷入自旋重试白白消耗CPU。LongAdder是Java 8后引入的内部做了分段处理每个线程往不同的段里累加最后再求和在高并发写入场景下性能明显优于AtomicInteger代价是读取结果时需要sum()累加所有段适合写多读少的场景。如果业务是简单的计数器可以直接用AtomicInteger如果已经能预料到写入量非常大LongAdder是更稳的选择。4. 双重检查锁单例里的volatile指令重排序的完整时间线4.1 标准的DCL单例写法双重检查锁Double-Checked LockingDCL是面试必聊的话题也是最容易暴露对volatile理解深度的场景。先看标准写法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; } }这段代码中synchronized保证了同一时刻只有一个线程能进到内部创建实例外层if是为了避免每次调用都去抢锁。很多初学者以为有了synchronized就够了为什么instance还要用volatile修饰核心在于instance new Singleton()这行代码并不是肉眼看到的一步完成。4.2 去掉volatile后new对象的三步可能被重排在JVM中new Singleton()可以拆成三个底层步骤在堆上分配一块内存空间在内存空间上初始化Singleton对象执行构造函数把instance引用指向这块内存。正常顺序是1、2、3但JIT编译器和CPU出于优化目的可能会把2和3调换变成1、3、2。也就是说先把引用指向尚未完成初始化的内存再回头执行构造函数。这个重排序在单线程内没有任何问题因为单线程里你根本感知不到顺序变化但在多线程环境下就会出事。4.3 一次完整的踩雷时间线假设线程A先进入getInstance()走到了instance new Singleton()这一步线程A执行完步骤1分配好内存线程A执行步骤3把instance引用指向了这块内存此时对象还没初始化构造函数还没跑此时线程A时间片用完被切换出去线程B进入getInstance()看到instance不为null直接return instance线程B拿着一个半初始化的对象去调用方法里面的成员变量可能还是默认值0或null程序行为完全不可控。加入volatile修饰instance之后volatile的写屏障会禁止步骤2和步骤3之间的重排序保证对象初始化完成之后才允许把引用指向这块内存。线程B再检查instance时要么看到null进入同步块等待要么看到一个完整初始化的对象不会拿到半成品。4.4 一个易被忽略的版本细节和静态内部类方案这里有个冷知识点很容易在面试中成为加分项JDK 5之前即JSR-133内存模型落地之前volatile的语义并不足以阻止DCL场景的重排序问题所以早期的DCL写法实际上是不安全的。直到JDK 5修订了内存模型volatile的语义被增强DCL才成为一种可靠的懒加载单例模式。另外很多人会把DCL和静态内部类单例放一起对比public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }静态内部类方案不需要volatile是因为类加载机制本身就具备天然的线程安全保证JVM在加载Holder类时会加锁并且保证clinit方法在同一线程中只执行一次所以INSTANCE只会被初始化一次。这种方案代码更短但如果你需要支持参数化构造或者延迟加载时机更灵活DCL加volatile依然是常见选择。5. 实战选型适合用volatile的场景和千万别用volatile的场景5.1 三个真正适合volatile的典型场景第一个是状态标志位。最典型的就是前文提到的running标志多个线程读、一个线程写写入值跟旧值没有依赖关系只要保证可见性即可。第二个是一次性安全发布。比如一个配置对象在初始化阶段由构建线程完全构造好然后通过volatile引用的方式发布给其他线程读取之后不再修改。这种情况下volatile能保证读线程不会看到一个尚未完全构造好的对象。第三个是独立观察值比如系统监控指标、传感器读数、外部配置项这类数据的读操作远多于写操作且写入方不依赖当前值做复合计算。5.2 四个千万别用volatile的场景结合我踩过的坑下面四类场景碰都不要碰计数与累加任何依赖先读旧值再算新值的操作包括count、countn、i i * 2这些都是复合操作volatile保证不了原子性多变量联合一致性比如一对坐标(x, y)必须同时更新volatile单独修饰x和y不能让两个变量更新成为一个原子动作读线程可能看到x是新的、y是旧的容器内部状态volatile修饰HashMap引用只能保证引用本身可见不能保证HashMap内部的put/remove操作安全更不能防止结构被破坏条件阻塞等待如果一个线程需要等待某个条件满足才能继续用volatile自旋轮询既费CPU又容易在复杂条件下出错应该用LockSupport或Condition这类专门工具。5.3 volatile、synchronized、Atomic类怎么选从语义完整度来看volatile覆盖的是可见性和有序性synchronized覆盖了可见性、有序性和互斥Atomic类通过CAS覆盖了原子性和可见性。实际选型时我先看有没有竞争写只有一个线程写、其他线程读优先volatile多个线程写且操作是简单的读改写优先Atomic系列如果还需要锁的语义比如等待条件变量、可中断获取锁才考虑synchronized或ReentrantLock。一个容易走弯路的地方是为了追求性能把本该用锁的同步块硬改成volatile自旋。比如多个线程要对同一个List做append操作只把List引用声明成volatilevolatile能保证引用可见但内部结构的安全完全没保障。性能优化要建立在正确性之上这个顺序不能反。6. 面试高频考点复盘以及我自己的学习路径6.1 面试官层层递进的追问链准备面试时volatile基本是绕不开的考点。我梳理过一轮高频追问链发现面试官特别喜欢沿着是什么-为什么-怎么写这条线往下挖volatile的作用是什么标准答法保证可见性禁止指令重排序不保证原子性。这里一定要主动把不保证原子性说出来因为很多人会忽略或者混淆volatile和synchronized的区别是什么从语义、粒度、性能、使用场景四个维度展开volatile的底层实现原理是什么往JMM内存屏障和缓存一致性协议方向讲讲到MESI会显得自己不是背八股DCL单例为什么要加volatile回答时要引导到new对象三步和重排序风险如果让你设计一个多线程读、单线程写的开关你会用volatile还是AtomicBoolean面试官其实想考察你的工程选型能力。面试时最重要的是别在不保证原子性上翻车。我听过不少候选人说volatile能保证原子性这个口误几乎等于直接暴露基础不牢。其实避免这个错误很简单只要多举一个count翻车的例子就能证明自己真正理解了这个边界。6.2 容易翻车的三个细节除了原子性还有几个细节值得留意把可见性理解成具体值已经被其他线程看到。可见性语义说的是每次读都从主内存拉最新值但线程什么时候读、读几次取决于代码逻辑volatile不保证实时性到纳秒级以为volatile能替代锁做互斥。volatile没有锁的排他语义两个线程同时写一个volatile变量仍然是竞争写结果不可预期忽略JDK版本对volatile语义的影响。JDK 5之前volatile语义较弱DCL在这种老版本上不成立。虽然后续版本都安全了但面试里提到这个细节能加分。6.3 我建议的学习路径如果只靠背面试题来学volatile很可能背完就忘。我自己当初是这么啃下来的第一读JSR-133规范里的FAQ很多概念比各种二手博客清晰得多第二把DCL单例里的volatile去掉写一个高并发demo亲自观察安全失效的现象第三用JMH写一个对比实验测synchronized、AtomicInteger、LongAdder在不同并发量下的吞吐实际数据比任何结论都有说服力第四去读Netty或Spring这类开源框架里对volatile的用法看它们是怎么在真实场景里控制可见性和有序性的。顺带提一个环境上的小坑如果本机装了JDK 17但IDE编译设置里的source/target版本不一致会看到警告: 源发行版 17 需要目标发行版 17这类提示。跑并发demo前最好先确认编译版本一致否则实验结果可能因为编译行为差异而失真。回想起来我在volatile上栽过的跟头大部分不是因为它难而是因为没搞清它的边界。现在我写并发代码前习惯先问自己三个问题这个字段会不会被多个线程同时写这个操作依赖字段的旧值吗有没有更合适的并发原语如果三个问题都不成立我才放心地写下volatile。希望这篇文章能帮你建立同样的判断力真正把这个关键字从八股文里的知识点变成手里的可靠工具。
返回列表