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 / Lock
synchronized(lock){i++;}synchronized块对应的monitorenter/monitorexit在JMM层面是lock/unlock,保证块内操作对外不可见中间状态。Lock接口(如ReentrantLock)通过AQS的CAS+park实现等价语义。
2. 原子类(Atomic)*
AtomicIntegerai=newAtomicInteger(0);ai.incrementAndGet();// 基于CAS的原子自增java.util.concurrent.atomic包下的原子类基于**CAS(Compare-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. volatile
privatevolatilebooleanrunning;volatile变量的写会立即刷新到主内存,读会强制从主内存重新加载。volatile写还会让其他线程工作内存中该变量的副本失效。下一篇会详述volatile的底层实现。
2. synchronized
synchronized(lock){x=1;}JMM规定:unlock变量前,必须把该变量同步回主内存(执行store+write)。对应的,lock变量时,会清空工作内存中该变量的副本,强制后续从主内存重新load。所以synchronized既保证原子性,也保证可见性。
3. final
publicclassConfig{privatefinalintthreshold;publicConfig(intt){this.threshold=t;// 构造函数内对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后续对同一锁的lock
- volatile规则:对一个volatile变量的写happens-before后续对它的读
- 线程启动规则:Thread.start() happens-before该线程的所有动作
- 线程终止规则:线程的所有动作happens-before Thread.join()返回
- 线程中断规则:Thread.interrupt()调用happens-before被中断线程检测到中断
- 对象初始化规则:构造函数结束happens-before finalize方法开始
- 传递性:A happens-before B,B happens-before C,则A happens-before C
happens-before的关键洞察是:它不要求A物理上先执行,只要求A的结果对B可见且顺序上不被破坏。两个没有happens-before关系的操作之间,JMM不提供任何顺序保证——这就是数据竞争的根源。
指令重排序
**指令重排序(Instruction Reordering)**是编译器和处理器为了提升性能打乱代码执行顺序的优化。重排序分三个层面:
1. 编译器重排序
JIT编译器(C1/C2)在生成机器码时会重排指令。只要不改变单线程语义(数据依赖不变),编译器可以自由调整顺序。
inta=1;// 语句1intb=2;// 语句2intc=a+b;// 语句3,依赖1、2语句1和语句2无依赖,编译器可能先执行语句2再执行语句1,结果在单线程下完全一致。
2. CPU指令级重排序
现代CPU采用流水线、乱序执行(Out-of-Order Execution),指令在CPU内部可能乱序执行。只要满足数据依赖,CPU会尽量填满流水线。
3. 内存系统重排序
这是最隐蔽的一类。CPU的写缓冲(Store Buffer)和失效队列(Invalidate Queue)会导致"先写的值后到"。例如:
线程A执行: x=1; y=2 线程B看到: y=2 先于 x=1 可见这不是指令被重排了,而是写x和写y分别进入不同缓存行,MESI的失效消息处理顺序不同,导致可见性顺序不一致。这类"可见性顺序被打乱"被称为内存系统重排序。
重排序的安全边界
JMM用以下策略约束重排序:
- 单线程下:遵守as-if-serial,不改变程序结果
- 多线程下:遵守happens-before,有happens-before关系的不能重排;没有关系的可以重排
一个经典的重排序陷阱:
// 线程1a=1;// 操作Aflag=true;// 操作B(flag无volatile)// 线程2if(flag){// 操作Cintr=a;// 操作D,期望读到1}操作A和操作B无依赖,可能被重排为B先A后;操作C和操作D也无依赖。重排后线程2可能读到flag=true但a=0。用volatile修饰flag,由于volatile写前的StoreStore屏障,操作A无法重排到操作B之后,问题解决。
数据竞争与竞态条件
**数据竞争(Data Race)和竞态条件(Race Condition)**是两个相关但不同的概念,理解它们能帮你定位并发bug的本质。
数据竞争
JMM定义:在一个线程写一个变量、另一个线程读同一变量、且二者没有happens-before关系时,就发生数据竞争。数据竞争下,程序结果不可预测——可能读到旧值、新值,甚至"撕裂"的值(long/double的非原子读写)。
intcount=0;// 线程1: count++ (写)// 线程2: print(count) (读)// 没有同步,存在数据竞争消除数据竞争的方法是建立happens-before关系——加锁、用volatile、用原子类,或利用线程启动/终止规则。
竞态条件
竞态条件是指程序的正确性依赖于多个线程的相对执行时序。即使每个操作都原子,时序不同也可能产生错误结果。
if(map.containsKey(key)){// 原子// 其他线程可能在这里插入keymap.put(key,value);// 原子,但组合不正确}竞态条件不一定有数据竞争(如上例每个操作都原子),但都需要用同步保证复合操作的原子性。数据竞争关注"可见性/原子性",竞态条件关注"复合操作的逻辑正确性"。
数据竞争不一定是bug
JLS允许程序存在数据竞争,只要你不指望它有确定结果。但正确性要求下必须消除数据竞争——这是并发编程的基本原则。JMM提供happens-before就是为了让你有工具消除竞争。
代码示例:i++问题
i++是说明原子性缺失的经典例子。我们用一个并发自增程序展示问题。
// 适用 JDK 11/17publicclassIncrementDemo{privatestaticintcounter=0;publicstaticvoidmain(String[]args)throwsInterruptedException{intthreads=100;intperThread=10_000;Thread[]ts=newThread[threads];for(inti=0;i<threads;i++){ts[i]=newThread(()->{for(intj=0;j<perThread;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可能把循环中的读、写重排,加剧竞争
修复方案对比:
// 方案1:synchronized(原子+可见)synchronized(IncrementDemo.class){counter++;}// 方案2:AtomicInteger(CAS原子)privatestaticAtomicIntegercounter=newAtomicInteger();counter.incrementAndGet();// 方案3:LongAdder(高并发场景更优,JDK 8+)privatestaticLongAddercounter=newLongAdder();counter.increment();低竞争下三者性能接近;高竞争下LongAdder通过分散热点(Cell数组)显著优于AtomicInteger,是计数场景的首选。
代码示例:双重检查锁定
**双重检查锁定(Double-Checked Locking, DCL)**是单例模式的经典写法,也是有序性问题的"教科书案例"。
错误版本(JDK 5之前)
publicclassSingleton{privatestaticSingletoninstance;publicstaticSingletongetInstance(){if(instance==null){// 第一次检查,无锁synchronized(Singleton.class){if(instance==null){// 第二次检查instance=newSingleton();// 致命:对象创建非原子}}}returninstance;}}问题出在instance = new Singleton()。这条语句分三步:
1. 分配内存 2. 调用构造函数初始化对象 3. 把内存地址赋给 instance步骤2和步骤3之间可能被重排序(编译器/CPU都可能做),变成1→3→2。于是线程A执行到3(instance非null但对象未初始化),线程B在第一次检查时看到instance非null,直接返回了一个半初始化的对象——字段还是默认值,构造函数没跑完。
正确版本(JDK 5+)
publicclassSingleton{// volatile禁止对象创建的重排序privatestaticvolatileSingletoninstance;privatefinalintconfig;publicSingleton(){this.config=42;}publicstaticSingletongetInstance(){if(instance==null){synchronized(Singleton.class){if(instance==null){instance=newSingleton();}}}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调优实战