# 什么是 Java 中的原子性、可见性和有序性?
Table of Contents
什么是 Java 中的原子性、可见性和有序性?
Java 中的原子性、可见性和有序性
【核心定义】
原子性、可见性和有序性是 Java 内存模型(JMM)定义的、用于保证多线程环境下程序正确执行的三个核心特性,它们是理解并发编程中线程安全问题的基石。
【关键要点】
-
原子性:指一个或多个操作作为一个不可分割的整体执行,要么全部成功,要么全部失败,中间状态对外不可见。
- 原理简述:JMM 直接保证对基本数据类型(除 long/double 外)的读写操作具有原子性。对于更大范围的原子性保证,需要通过
synchronized关键字或Lock接口来实现。
- 原理简述:JMM 直接保证对基本数据类型(除 long/double 外)的读写操作具有原子性。对于更大范围的原子性保证,需要通过
-
可见性:指当一个线程修改了共享变量的值后,其他线程能够立即得知这个修改。
- 原理简述:JMM 通过在主内存与线程工作内存之间同步变量的规则来实现可见性。关键字
volatile、synchronized和final可以保证可见性。
- 原理简述:JMM 通过在主内存与线程工作内存之间同步变量的规则来实现可见性。关键字
-
有序性:指程序执行的顺序按照代码的先后顺序执行。但在多线程环境下,由于编译器和处理器会进行指令重排序,可能导致程序执行顺序与代码顺序不一致。
- 原理简述:JMM 通过 happens-before 规则来禁止特定类型的指令重排序,从而为程序员提供有序性的保证。
volatile和synchronized关键字可以保证有序性。
- 原理简述:JMM 通过 happens-before 规则来禁止特定类型的指令重排序,从而为程序员提供有序性的保证。
【深度推导/细节】
这三个特性共同解决了多线程环境下的核心矛盾:数据竞争和指令重排序带来的不确定性。
-
原子性的冲突场景:经典的“i++”问题。该操作包含读取、计算、写入三个步骤,不具备原子性。多线程并发执行时,可能导致最终结果小于预期。
- 逻辑拆解:
- Step 1: 线程 A 读取变量 i 的原始值(例如 0)。
- Step 2: 线程 B 也读取变量 i 的原始值(0)。
- Step 3: 线程 A 执行 i+1 操作,并将结果(1)写回主内存。
- Step 4: 线程 B 执行 i+1 操作,并将结果(1)写回主内存。
- 最终结果:i 的值为 1,而不是正确的 2。
- 逻辑拆解:
-
可见性的冲突场景:线程“死循环”。一个线程修改了共享变量的值,但另一个线程却一直读取到旧值。
- 逻辑拆解:
- Step 1: 主内存中共享变量
flag = false。 - Step 2: 线程 A 将
flag读取到自己的工作内存。 - Step 3: 线程 A 在自己的工作内存中将
flag改为true,但未及时刷新到主内存。 - Step 4: 线程 B 从主内存读取
flag,得到的仍然是false,导致其无法退出循环。
- Step 1: 主内存中共享变量
- 逻辑拆解:
-
有序性的冲突场景:单例模式下的“双重检查锁定(DCL)”问题(在旧版本 JDK 中)。
- 逻辑拆解:
- 对象初始化
new Instance()并非原子操作,可能被重排序为:1) 分配内存空间,2) 将引用指向内存地址(此时对象尚未初始化),3) 初始化对象。 - 如果发生重排序,线程 A 执行到步骤 2 后,线程 B 可能判断引用不为 null,从而直接返回一个未初始化完成的对象,导致程序错误。
- 对象初始化
- 逻辑拆解:
【关联/对比】
-
synchronizedvsvolatile:synchronized关键字可以同时保证原子性、可见性和有序性。它通过加锁和解锁机制,使得临界区内的操作成为一个原子块,并在解锁前将变量刷新到主内存,同时禁止块内的指令重排序。volatile关键字只能保证可见性和有序性(禁止指令重排序),但不保证原子性。它通过内存屏障(Memory Barrier)实现,强制将对 volatile 变量的读写操作与主内存同步。
-
Java 内存模型(JMM) vs 物理内存/缓存:
- JMM 是一个抽象概念,定义了线程和主内存之间的抽象关系。每个线程有自己的工作内存,存储了该线程使用到的变量的主内存副本。
- 物理架构上,工作内存可能对应着 CPU 的高速缓存(L1, L2)和寄存器,而主内存对应着物理内存。JMM 的规则屏蔽了底层硬件的差异。
『面试官追问』
-
long和double类型的读写为什么不是原子的?在什么情况下是原子的?- 在 32 位 JVM 上,对 64 位的
long和double变量的读写操作可能被拆分为两个 32 位的操作。如果多个线程同时操作,可能读到“半个变量”(高 32 位来自一次写入,低 32 位来自另一次写入)。 - 如果该变量被声明为
volatile,则其读写操作具有原子性。
- 在 32 位 JVM 上,对 64 位的
-
happens-before原则具体有哪些规则?- 程序次序规则:一个线程内,按照代码顺序,前面的操作 happens-before 于后面的操作。
- 管程锁定规则:对一个锁的解锁 happens-before 于随后对这个锁的加锁。
volatile变量规则:对一个volatile变量的写操作 happens-before 于后续对这个变量的读操作。- 线程启动规则:
Thread.start()调用 happens-before 于该线程内的任何操作。 - 线程终止规则:线程中的所有操作都 happens-before 于其他线程检测到该线程已经终止(如
Thread.join()返回)。 - 线程中断规则:对线程
interrupt()的调用 happens-before 于被中断线程检测到中断事件。 - 对象终结规则:一个对象的初始化完成 happens-before 于它的
finalize()方法的开始。 - 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
-
final关键字如何保证可见性?- 在构造函数中对一个
final域的写入,与随后将被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。 - 初次读一个包含
final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。 - 这确保了其他线程在拿到对象引用时,一定能看到在构造函数中初始化的
final域的值,从而保证了可见性。
- 在构造函数中对一个
【直击痛点】
-
为什么
volatile不能替代synchronized? 这是最常见的混淆点。关键在于volatile不保证复合操作的原子性。例如,对于volatile int count;执行count++,这个“读-改-写”操作本身不是原子的,volatile只能保证每次读到的都是最新值,但无法阻止多个线程同时读到同一个旧值并进行修改,从而导致更新丢失。而synchronized可以将整个count++块锁住,保证同一时刻只有一个线程执行该操作。 -
指令重排序一定会导致问题吗? 不一定。在单线程环境下,指令重排序遵守 as-if-serial 语义,即不管怎么重排序,程序的执行结果必须与代码顺序执行的结果一致。因此,重排序是为了优化性能,且对单线程程序是透明的。问题只出现在多线程环境下,因为线程间的交互破坏了 as-if-serial 的前提。
【版本差异】
- DCL(双重检查锁定)问题的修复:
- 在 JDK 1.5 之前,即使使用
volatile声明单例实例,DCL 依然可能因指令重排序而失败。 - 从 JDK 1.5(JSR-133) 开始,增强了
volatile的内存语义。对一个volatile域的写操作,会与后续对这个域的读操作建立 happens-before 关系,并且会禁止与这个写操作相关的指令重排序。因此,在 JDK 1.5+ 中,使用private volatile static Instance instance;可以正确实现线程安全的 DCL 单例。
- 在 JDK 1.5 之前,即使使用