Java高并发底层原理(七)volatile 到底解决了什么问题

发布时间:2026/7/7 16:07:54
Java高并发底层原理(七)volatile 到底解决了什么问题 缓存一致性协议能够协调多个 CPU Core 对同一条 Cache Line 的读写但 Java 程序仍然需要明确一件事一个线程修改变量以后另一个线程什么时候必须看到这个修改volatile就是 Java 提供的一种内存语义。它不负责把一段代码变成临界区也不会阻止多个线程同时执行而是围绕一个变量建立可见性和有序性规则。1. 一个线程为什么可能看不到另一个线程的修改先看一个常见的停止线程示例publicclassStopDemo{privatestaticbooleanrunningtrue;publicstaticvoidmain(String[]args)throwsInterruptedException{ThreadworkernewThread(()-{while(running){// do something}System.out.println(worker stopped);});worker.start();Thread.sleep(1000);runningfalse;}}主线程在一秒后把running修改为false从源码顺序看工作线程似乎应该很快退出循环。但在没有任何同步规则的情况下Java 并不保证工作线程一定能及时观察到这个修改。工作线程可能反复使用寄存器、CPU Cache 或编译器优化后的结果而不是在每次循环时都重新按照源码形式读取共享变量。JIT 编译器只需要保证单线程语义不被破坏在它看来如果当前线程内部没有代码修改running就可能减少重复读取。这并不表示 Java 规定线程必须拥有一份独立的变量副本也不能简单理解为“线程只读自己的缓存”。真实执行可能涉及寄存器、Cache、Store Buffer、JIT 优化和处理器指令。Java 内存模型不要求开发者依赖某一种具体硬件结构而是通过规则规定哪些结果必须被其他线程看到。2. volatile 保证可见性把running声明为volatileprivatestaticvolatilebooleanrunningtrue;主线程写入runningfalse;工作线程读取while(running){// do something}此时Java 内存模型要求工作线程能够观察到对running的最新写入。主线程把它修改为false后工作线程后续读取不能一直使用已经失效的旧值true。这里的核心保证是一个线程对 volatile 变量的写入对随后读取同一个 volatile 变量的线程可见。volatile不会让两个线程停止并发执行也不会要求读线程先获得某把锁。它只是让这个变量的读写具备特殊内存语义。3. volatile 不是每次都直接访问主内存很多资料把volatile描述成“每次读取都从主内存读取每次写入都立即刷新到主内存”。这种说法方便记忆但不够准确。现代 CPU 仍然会使用寄存器和 Cachevolatile不会关闭缓存也不会强制所有访问都绕过 Cache。JVM 会根据目标处理器的内存模型生成合适的指令并在需要的位置加入内存屏障或使用带有顺序语义的读写指令。缓存一致性机制负责让不同 Core 的缓存副本保持协调内存屏障负责限制某些读写的执行顺序。两者共同实现 Java 对volatile的语义要求。因此更准确的理解是volatile 规定线程必须按照特定规则观察读写结果具体如何实现由 JVM 和 CPU 决定。4. 什么是指令重排编译器和处理器可以在不改变单线程结果的前提下调整指令执行顺序这种优化称为指令重排Instruction Reordering。例如inta1;intb2;如果后面的代码不依赖这两个赋值的先后顺序编译器或处理器可能先执行b 2再执行a 1。在单线程中两种顺序得到的结果相同因此这种调整通常没有问题。多线程程序却可能观察到中间状态。假设一个线程发布数据data42;readytrue;另一个线程读取if(ready){System.out.println(data);}程序希望ready表示“数据已经准备完成”。如果两个写操作对其他线程的可见顺序没有约束读线程就可能先看到ready true却仍然没有看到data 42。问题不一定来自源码被简单交换也可能来自写入暂存在 Store Buffer、不同缓存行传播速度不同或者读操作以不同顺序完成。Java 不要求开发者区分每一种硬件原因而是通过内存模型统一规定哪些重排必须被禁止。5. volatile 如何保证有序性把ready声明为volatileprivatestaticintdata;privatestaticvolatilebooleanready;写线程执行data42;readytrue;读线程执行if(ready){System.out.println(data);}volatile在这里不仅保证ready的可见性还建立了一条顺序关系ready true之前的普通写不能被移动到这个 volatile 写之后读取到ready true之后的普通读不能被移动到这个 volatile 读之前。可以用下面的模型表示Write Thread data 42 ↓ volatile write: ready true ═════════════════════════════ volatile read: ready true ↓ read data Read Thread中间的横线表示一条内存可见边界。只要读线程读取到写线程发布的ready true它也必须能够看到这个 volatile 写之前已经完成的普通写因此data应当等于42。6. volatile 与 happens-beforeJava 内存模型使用happens-before描述线程之间的可见性和顺序保证。它并不表示两个操作在现实时间上一定紧挨着发生而是表示前一个操作的结果必须对后一个操作可见并且前一个操作在内存语义上排在后一个操作之前。与volatile直接相关的规则是对一个 volatile 变量的写入happens-before 随后对同一个变量的读取。结合程序内部的执行顺序可以得到data 42 ↓ Program Order ready true ↓ Volatile Rule read ready true ↓ Program Order read data因此data 42的结果能够传递到读线程。这里真正起作用的不是读线程直接读取了写线程的局部数据而是 Java 内存模型通过 volatile 读写建立了跨线程的可见关系。happens-before 是判断并发代码是否具备可靠内存语义的重要工具。没有这类关系时即使某次运行“看起来正常”也不能证明所有线程都一定能够看到预期结果。7. volatile 为什么不能解决 countvolatile保证单次读取能够看到较新的值也保证单次写入能够按照规则发布但它不能把多个步骤合并成一个原子操作。例如privatevolatileintcount0;publicvoidincrement(){count;}count仍然包含读取、计算和写回Read count ↓ Calculate count 1 ↓ Write count假设 Thread A 和 Thread B 都读取到count 0随后分别计算出1并写回。即使每次读取和写入都遵守 volatile 的可见性规则最终仍然可能只有一个1。┌──────┬────────────────────────┬────────────────────────┬───────────┐ │ Step │ Thread A │ Thread B │ count │ ├──────┼────────────────────────┼────────────────────────┼───────────┤ │ 1 │ Read 0 │ │ 0 │ │ 2 │ │ Read 0 │ 0 │ │ 3 │ Calculate 1 │ Calculate 1 │ 0 │ │ 4 │ Write 1 │ │ 1 │ │ 5 │ │ Write 1 │ 1 │ └──────┴────────────────────────┴────────────────────────┴───────────┘volatile解决的是“是否看得到”和“以什么顺序看到”不是“多个步骤能否被其他线程插入”。需要保护复合操作时仍然要使用锁、CAS 或其他能够保证原子性的方案。8. volatile 适合什么场景volatile最适合一个线程写、多个线程读并且每次写入不依赖旧值的场景。8.1 状态标记classTask{privatevolatilebooleanstopped;publicvoidstop(){stoppedtrue;}publicvoidrun(){while(!stopped){doWork();}}privatevoiddoWork(){// business logic}}stop()只是把状态直接设置为true不需要根据旧值计算新值因此不涉及读取后再写回的复合操作。8.2 配置发布classConfigCenter{privatevolatileConfigconfigloadConfig();publicConfiggetConfig(){returnconfig;}publicvoidreload(){configloadConfig();}}写线程先构造完整的新配置对象再一次性替换config引用。读线程读取到新引用后也能看到 volatile 写之前已经完成的对象初始化结果。为了避免对象发布后又被并发修改配置对象通常设计为不可变对象。8.3 完成标记classResultHolder{privateResultresult;privatevolatilebooleancompleted;publicvoidcomplete(Resultvalue){resultvalue;completedtrue;}publicResultgetResult(){if(!completed){returnnull;}returnresult;}}写线程先保存结果再写入 volatile 标记。读线程读取到completed true后能够看到之前写入的result。这些场景都有一个共同特点volatile 变量本身只承担状态发布或引用替换不需要保护多个线程围绕旧值进行竞争更新。9. volatile 不能保证复杂业务状态一致假设账户中有两个字段classAccount{privatevolatileintbalance;privatevolatileinttransactionCount;publicvoidwithdraw(intamount){if(balanceamount){balance-amount;transactionCount;}}}即使两个字段都声明为volatile这段代码仍然不安全。余额检查、余额扣减和交易次数增加属于一个完整业务操作多个线程可以在中间交错执行。volatile只能约束每个字段各自的读写不能自动把多个字段组合成一个事务也不能保证两个字段总是同时变化。只要业务正确性依赖多个操作共同完成就需要更完整的同步边界。10. volatile 与 synchronized 的区别volatile和synchronized都能建立可见性关系但用途不同。对比项volatilesynchronized是否互斥否是是否会阻止其他线程进入代码区域否会可见性保证保证有序性限制 volatile 前后的特定重排同步区域具有进入和退出语义原子性只保证单次读写不保护复合操作可以保护整个临界区适用场景状态标记、引用发布、简单开关多步骤操作、多个关联状态volatile更轻量是因为它不建立线程级互斥这也意味着它不能替代锁。选择时不能只看哪个关键字开销更小而要先判断业务是否需要互斥。11. 双重检查锁为什么需要 volatile单例模式中的双重检查锁是volatile同时发挥可见性和有序性作用的经典场景publicclassSingleton{privatestaticvolatileSingletoninstance;privateSingleton(){}publicstaticSingletongetInstance(){if(instancenull){synchronized(Singleton.class){if(instancenull){instancenewSingleton();}}}returninstance;}}第一次检查避免每次调用都进入同步区域第二次检查防止多个线程先后创建对象。instance必须声明为volatile因为创建对象并赋值给引用并不是一个抽象上的单一步骤可以粗略理解为Allocate memory ↓ Initialize object ↓ Assign reference如果缺少内存顺序约束其他线程可能先观察到非空引用却还不能可靠地看到完整初始化结果。volatile会限制这种不安全发布并保证读取到instance的线程能够看到对象构造期间已经完成的写入。这里不应理解为 JVM 必然机械地把三步交换成某个固定顺序而应理解为没有安全发布规则时其他线程观察对象状态的结果缺乏保证volatile为引用发布建立了必要的 happens-before 关系。12. 常见误解12.1 volatile 会把变量锁住不会。多个线程仍然可以同时读取和写入 volatile 变量volatile不建立临界区也不会让某个线程独占变量。12.2 volatile 能让所有操作变成原子操作不会。它不能保护count、先判断再修改、同时更新多个字段等复合操作。12.3 volatile 等于禁用 CPU Cache不会。CPU 仍然使用 CacheJVM 通过内存屏障、特殊指令和硬件一致性机制实现 volatile 语义。12.4 volatile 保证读到绝对最新的现实时间值更准确地说volatile 读必须遵守 Java 内存模型的可见性规则。并发程序中不存在一个脱离同步规则、所有线程随时共享的“绝对瞬间”。程序应依据 happens-before 判断结果是否可靠而不是依赖现实时间上的先后猜测。12.5 声明了 volatile 就是线程安全线程安全取决于完整业务操作。一个字段声明为 volatile只能保证围绕这个字段的特定内存语义不能自动保证整个类或整个方法安全。13. 如何判断是否适合使用 volatile可以依次判断三个问题写入是否只是直接设置新值而不是根据旧值计算新值是否不需要多个线程互斥执行一段代码是否只需要发布状态、对象引用或完成标记。如果答案都是肯定的volatile通常比较合适。如果操作包含“读取—判断—修改”或者需要同时维护多个字段之间的一致性就不能只依靠volatile。例如runningfalse;confignewConfig;completedtrue;这些操作通常可以使用volatile。而下面这些操作通常不行count;balance-amount;if(stock0){stock--;}判断标准不是代码有几行而是业务操作是否依赖旧状态以及多个线程是否可以在步骤之间交错执行。本章总结volatile为一个变量建立了可见性和有序性规则。一个线程写入 volatile 变量后随后读取同一变量的线程能够观察到这次写入volatile 写之前的普通写也能够通过 happens-before 关系对读线程可见。本章的核心结论包括线程之间的数据可见性不能只用“是否写回主内存”解释volatile不会关闭 Cache也不会让所有访问都直接经过主内存JVM 通过内存屏障、特殊指令和硬件一致性机制实现 volatile 语义volatile 写与随后对同一变量的 volatile 读之间存在 happens-before 关系volatile能限制特定的指令重排volatile不提供互斥不能保护count等复合操作状态标记、配置发布和完成通知是常见使用场景多字段一致性和读取后修改仍然需要锁、CAS 或其他同步方案双重检查锁中的实例引用必须安全发布判断是否适合使用volatile关键在于操作是否依赖旧状态。原子性、可见性和有序性是并发程序中三个不同的问题。synchronized、CAS 和volatile分别从不同角度处理这些问题后续可以继续把它们统一到 Java 内存模型中理解。