【JVM原理详解】42-原子性与可见性与有序性

📅 2026/8/9 12:28:35
【JVM原理详解】42-原子性与可见性与有序性
42-原子性与可见性与有序性引言上一篇我们建立了JMM的基础抽象主内存、工作内存、8种原子操作、内存屏障。这套抽象的存在本质上是为了回答并发编程的三个根本问题——原子性、可见性、有序性。这就是著名的并发三要素。几乎所有并发bug都能归结到这三者之一的被破坏。本篇逐一拆解三大特性的含义、JMM如何保证它们、以及指令重排序与数据竞争这些容易踩坑的概念。我们会用经典的i问题和双重检查锁定DCL作为贯穿示例把抽象规则落到代码层面。原子性**原子性Atomicity**指一个操作或一系列操作不可分割——要么全部执行且不被打断要么都不执行。在JMM语境下原子性关注的是一个线程执行某操作时其他线程会不会看到中间状态。JMM保证的基本原子操作上一篇讲的8种操作中lock、unlock、read、load、use、assign、store、write每一个都是原子的。这意味着即使没有额外同步JMM也保证基本类型除long/double外的读写是原子的——一个线程写int另一个线程读到的要么是旧值要么是新值不会读到半个值引用类型的读写也是原子的32位JVM上也是JDK 5后规范明确例外long和double的普通读写。JMM允许64位的long/double的read/load/store/write不保证原子性允许拆成两次32位操作。这在64位JVM上几乎不会出问题HotSpot 64位实现保证原子性但在32位JVM上理论存在风险。实践上可用volatile修饰long/double强制原子性。更大范围的原子性基本读写原子并不够用。i看似一条语句实际是读-改-写三步1. read i // 从主内存读 2. i 1 // 执行引擎计算 3. write i // 写回主内存三步之间可能被打断因此i不是原子操作。要让更大范围的操作原子化JMM提供两条路径1. synchronized / Locksynchronized(lock){i;}synchronized块对应的monitorenter/monitorexit在JMM层面是lock/unlock保证块内操作对外不可见中间状态。Lock接口如ReentrantLock通过AQS的CASpark实现等价语义。2. 原子类Atomic*AtomicIntegerainewAtomicInteger(0);ai.incrementAndGet();// 基于CAS的原子自增java.util.concurrent.atomic包下的原子类基于**CASCompare-And-Swap**实现对应CPU的cmpxchg指令x86。CAS是无锁原子操作不需要线程挂起性能在低竞争场景优于synchronized。原子性的边界原子性只保证操作不被打断不保证多步操作的逻辑正确。例如if(map.get(key)null){map.put(key,value);// get和put各自原子但组合不是}get和put各自原子但中间可能有其他线程插入导致重复put或覆盖。这就是复合操作问题需要用putIfAbsent或computeIfAbsent这类原子复合API。可见性可见性Visibility指一个线程修改了共享变量后其他线程能否立刻看到这个修改。JMM的默认行为是不保证立刻可见——一个线程在工作内存修改了变量副本何时同步回主内存、其他线程何时重新load都是尽量快但不保证。可见性失效的根源是工作内存缓存寄存器优化。线程A在自己工作内存改了值线程B还在读自己工作内存的旧副本于是B看不见A的修改。保证可见性的手段JMM提供三种机制保证可见性1. volatileprivatevolatilebooleanrunning;volatile变量的写会立即刷新到主内存读会强制从主内存重新加载。volatile写还会让其他线程工作内存中该变量的副本失效。下一篇会详述volatile的底层实现。2. synchronizedsynchronized(lock){x1;}JMM规定unlock变量前必须把该变量同步回主内存执行storewrite。对应的lock变量时会清空工作内存中该变量的副本强制后续从主内存重新load。所以synchronized既保证原子性也保证可见性。3. finalpublicclassConfig{privatefinalintthreshold;publicConfig(intt){this.thresholdt;// 构造函数内对final字段的写}}final字段的可见性保证是JDK 5 JSR-133重构的重要内容在构造函数完成时final字段的值对所有线程可见且保证是构造函数设置的值不会被重排序到构造函数外。前提是构造函数没有把this引用泄漏——如果this在构造完成前被其他线程拿到final保证失效。可见性与原子性的区别初学者常混淆两者。一个形象比喻原子性一个转账操作要么完整成功要么完全回滚不会停在钱从A扣了但没到B可见性转账成功后A查余额能立刻看到新余额而不是看到旧的缓存值volatile只保证可见性不保证原子性synchronized两者都保证。这是volatile和synchronized的核心差异之一。有序性有序性Ordering指程序执行的顺序与代码编写的顺序的一致性。JMM允许编译器和CPU为了性能进行指令重排序只要不改变单线程语义as-if-serial。但重排序在多线程下会破坏预期顺序。保证有序性的手段1. volatile禁止重排序volatile通过插入内存屏障禁止特定重排序。例如volatile写前的StoreStore屏障保证前面的普通写不重排到volatile写之后。2. synchronized保证块内有序synchronized块内的操作不会被重排序到块外块外代码可能重排进块内吗不会因为lock/unlock是有序的屏障。但注意synchronized不保证块内操作的顺序与代码一致——块内仍可重排只是对外表现为块整体原子可见。3. happens-before规则happens-before是JMM的核心抽象规则用偏序关系描述如果A happens-before B那么A的结果对B可见且A的执行顺序先于B。happens-before包含以下规则程序顺序规则同一线程内前一条语句happens-before后一条as-if-serial管程锁规则一个锁的unlock happens-before后续对同一锁的lockvolatile规则对一个volatile变量的写happens-before后续对它的读线程启动规则Thread.start() happens-before该线程的所有动作线程终止规则线程的所有动作happens-before Thread.join()返回线程中断规则Thread.interrupt()调用happens-before被中断线程检测到中断对象初始化规则构造函数结束happens-before finalize方法开始传递性A happens-before BB happens-before C则A happens-before Chappens-before的关键洞察是它不要求A物理上先执行只要求A的结果对B可见且顺序上不被破坏。两个没有happens-before关系的操作之间JMM不提供任何顺序保证——这就是数据竞争的根源。指令重排序**指令重排序Instruction Reordering**是编译器和处理器为了提升性能打乱代码执行顺序的优化。重排序分三个层面1. 编译器重排序JIT编译器C1/C2在生成机器码时会重排指令。只要不改变单线程语义数据依赖不变编译器可以自由调整顺序。inta1;// 语句1intb2;// 语句2intcab;// 语句3依赖1、2语句1和语句2无依赖编译器可能先执行语句2再执行语句1结果在单线程下完全一致。2. CPU指令级重排序现代CPU采用流水线、乱序执行Out-of-Order Execution指令在CPU内部可能乱序执行。只要满足数据依赖CPU会尽量填满流水线。3. 内存系统重排序这是最隐蔽的一类。CPU的写缓冲Store Buffer和失效队列Invalidate Queue会导致先写的值后到。例如线程A执行: x1; y2 线程B看到: y2 先于 x1 可见这不是指令被重排了而是写x和写y分别进入不同缓存行MESI的失效消息处理顺序不同导致可见性顺序不一致。这类可见性顺序被打乱被称为内存系统重排序。重排序的安全边界JMM用以下策略约束重排序单线程下遵守as-if-serial不改变程序结果多线程下遵守happens-before有happens-before关系的不能重排没有关系的可以重排一个经典的重排序陷阱// 线程1a1;// 操作Aflagtrue;// 操作Bflag无volatile// 线程2if(flag){// 操作Cintra;// 操作D期望读到1}操作A和操作B无依赖可能被重排为B先A后操作C和操作D也无依赖。重排后线程2可能读到flagtrue但a0。用volatile修饰flag由于volatile写前的StoreStore屏障操作A无法重排到操作B之后问题解决。数据竞争与竞态条件**数据竞争Data Race和竞态条件Race Condition**是两个相关但不同的概念理解它们能帮你定位并发bug的本质。数据竞争JMM定义在一个线程写一个变量、另一个线程读同一变量、且二者没有happens-before关系时就发生数据竞争。数据竞争下程序结果不可预测——可能读到旧值、新值甚至撕裂的值long/double的非原子读写。intcount0;// 线程1: count (写)// 线程2: print(count) (读)// 没有同步存在数据竞争消除数据竞争的方法是建立happens-before关系——加锁、用volatile、用原子类或利用线程启动/终止规则。竞态条件竞态条件是指程序的正确性依赖于多个线程的相对执行时序。即使每个操作都原子时序不同也可能产生错误结果。if(map.containsKey(key)){// 原子// 其他线程可能在这里插入keymap.put(key,value);// 原子但组合不正确}竞态条件不一定有数据竞争如上例每个操作都原子但都需要用同步保证复合操作的原子性。数据竞争关注可见性/原子性竞态条件关注复合操作的逻辑正确性。数据竞争不一定是bugJLS允许程序存在数据竞争只要你不指望它有确定结果。但正确性要求下必须消除数据竞争——这是并发编程的基本原则。JMM提供happens-before就是为了让你有工具消除竞争。代码示例i问题i是说明原子性缺失的经典例子。我们用一个并发自增程序展示问题。// 适用 JDK 11/17publicclassIncrementDemo{privatestaticintcounter0;publicstaticvoidmain(String[]args)throwsInterruptedException{intthreads100;intperThread10_000;Thread[]tsnewThread[threads];for(inti0;ithreads;i){ts[i]newThread(()-{for(intj0;jperThread;j){counter;// 非原子操作}});ts[i].start();}for(Threadt:ts)t.join();System.out.println(Expected: (threads*perThread));System.out.println(Actual: counter);}}预期输出100000实际几乎一定小于100000。原因有三读-改-写非原子counter是读旧值→加1→写回两线程可能读到相同的旧值各自加1写回丢失一次自增可见性延迟一个线程的写未必及时对另一线程可见造成基于旧值的更新重排序JIT可能把循环中的读、写重排加剧竞争修复方案对比// 方案1synchronized原子可见synchronized(IncrementDemo.class){counter;}// 方案2AtomicIntegerCAS原子privatestaticAtomicIntegercounternewAtomicInteger();counter.incrementAndGet();// 方案3LongAdder高并发场景更优JDK 8privatestaticLongAddercounternewLongAdder();counter.increment();低竞争下三者性能接近高竞争下LongAdder通过分散热点Cell数组显著优于AtomicInteger是计数场景的首选。代码示例双重检查锁定**双重检查锁定Double-Checked Locking, DCL**是单例模式的经典写法也是有序性问题的教科书案例。错误版本JDK 5之前publicclassSingleton{privatestaticSingletoninstance;publicstaticSingletongetInstance(){if(instancenull){// 第一次检查无锁synchronized(Singleton.class){if(instancenull){// 第二次检查instancenewSingleton();// 致命对象创建非原子}}}returninstance;}}问题出在instance new Singleton()。这条语句分三步1. 分配内存 2. 调用构造函数初始化对象 3. 把内存地址赋给 instance步骤2和步骤3之间可能被重排序编译器/CPU都可能做变成1→3→2。于是线程A执行到3instance非null但对象未初始化线程B在第一次检查时看到instance非null直接返回了一个半初始化的对象——字段还是默认值构造函数没跑完。正确版本JDK 5publicclassSingleton{// volatile禁止对象创建的重排序privatestaticvolatileSingletoninstance;privatefinalintconfig;publicSingleton(){this.config42;}publicstaticSingletongetInstance(){if(instancenull){synchronized(Singleton.class){if(instancenull){instancenewSingleton();}}}returninstance;}}volatile修饰instance后构造函数的写步骤2与instance的赋值步骤3之间插入StoreStore屏障禁止重排序。JDK 5的JSR-133重新定义了volatile语义让DCL模式终于安全。DCL展示了有序性缺失比可见性缺失更隐蔽——半初始化对象不会立刻崩溃而是产生诡异的字段值错误极难复现和定位。实践要点优先用高层并发工具。java.util.concurrent包的锁、原子类、并发集合已经处理好三要素尽量不要手写wait/notify或裸synchronized。volatile只解决可见性和有序性不解决原子性。volatile int i; i依然不安全。需要原子自增用AtomicInteger或LongAdder。复合操作需要整体同步。即使每个操作都原子如get和put组合起来仍可能出错。优先用putIfAbsent、computeIfAbsent这类原子复合API。final字段要配合构造函数安全。不要在构造函数里把this泄漏出去如启动新线程传this、注册回调传this否则final字段的可见性保证失效。警惕看似无依赖的重排序。像DCL里分配内存和初始化看似无数据依赖都写同一对象的不同部分被重排后却产生半初始化对象。涉及对象发布的场景要么用volatile要么用final要么用synchronized包裹发布动作。happens-before是推理工具不是性能约束。不要因为怕重排序就到处加volatile。只在需要跨线程可见性/有序性的地方加同步其余交给编译器优化。压测不能证明并发正确。并发bug概率性出现压测跑过一万次不代表没bug。正确性要靠JMM规则推理验证压测只是辅助发现手段。小结原子性操作不可分割。基本读写JMM保证原子除long/double的例外更大范围靠synchronized/Lock/Atomic*可见性修改对其他线程可见。volatile、synchronized、final三种机制保证有序性执行顺序符合预期。volatile禁止重排序synchronized保证块整体有序happens-before是推理规则指令重排序分编译器、CPU指令级、内存系统三层单线程遵守as-if-serial多线程遵守happens-before数据竞争是缺少happens-before的读写冲突竞态条件是依赖时序的逻辑错误二者常共存但不同经典案例i丢失自增原子性可见性、DCL半初始化对象有序性下一篇我们聚焦JMM最重要的关键字——volatile深入它的两大内存语义、底层Lock前缀指令与MESI实现、以及与synchronized的对比。更多内容JVM调优实战