一、核心概念总述
Java 内存模型(JMM)定义了共享变量在多线程间的访问规则 ,原子性、可见性、有序性是保障多线程并发安全的三大核心特性。三者的目标是解决多线程环境下因工作内存与主内存隔离、指令重排、操作拆分导致的并发问题。
二、原子性
1. 定义
一个或多个操作,要么全部执行且执行过程中不被任何线程打断,要么全部不执行,即操作是 "不可分割" 的最小执行单元。
2. 核心问题(反例)
多线程下的复合操作会被拆分,导致结果异常。
- 例如:
count++实际分为 3 步:- 从主内存读取
count到线程工作内存; - 工作内存中执行
count+1; - 将结果写回主内存。
- 从主内存读取
- 多线程执行时,步骤会被交叉打断,导致最终结果小于预期值。
3. 保障机制
| 保障手段 | 原理说明 | 适用场景 |
|---|---|---|
| synchronized 关键字 | 通过monitorenter/monitorexit指令实现互斥锁,同一时刻仅一个线程进入同步块,同步块内所有操作串行执行,视为原子整体。 |
所有需要原子性的复合操作(如count++、多步业务逻辑) |
| Lock 接口(如 ReentrantLock) | 基于 AQS 实现的显式锁,通过lock()加锁、unlock()解锁保证互斥,效果与synchronized一致,支持更灵活的锁控制。 |
需超时锁、公平锁等高级功能的场景 |
| CAS(原子类,如 AtomicInteger) | 基于 CPU 的Compare-And-Swap硬件指令,无锁实现原子操作:比较内存值与预期值,相等则更新,否则重试。 |
高频读、低频写的简单原子操作(如计数器) |
| JMM 原生支持 | 基本数据类型(long/double除外)的单次读写操作 天然具备原子性(如int a = 1)。 |
简单变量的单次赋值 / 读取 |
4. 面试关键点
long/double类型的单次读写不保证原子性 (JVM 允许拆分为两次 32 位操作),需用volatile或原子类保障。volatile不保证原子性,仅能保证可见性和有序性。
三、可见性
1. 定义
一个线程修改了共享变量的值,其他线程能立刻看到该修改后的最新值 ,解决多线程因工作内存与主内存隔离导致的 "数据不一致" 问题。
2. 核心问题(反例)
JMM 中,线程操作共享变量时,先将变量从主内存 拷贝到线程工作内存,修改后再写回主内存。若未保证可见性:
- 线程 A 修改了工作内存中的变量,但未及时写回主内存;
- 线程 B 仍从主内存读取旧值,导致线程 B 无法感知线程 A 的修改。
3. 保障机制
| 保障手段 | 原理说明 | 适用场景 |
|---|---|---|
| volatile 关键字 | 1. 写屏障 :线程写volatile变量时,强制将工作内存中的最新值刷新到主内存;2. 读屏障 :线程读volatile变量时,强制失效工作内存中的旧值,必须从主内存重新读取。 |
读多写少的状态标记(如volatile boolean flag控制线程启停) |
| synchronized 关键字 | 1. 加锁时 :失效当前线程工作内存的共享变量,从主内存重新读取;2. 解锁时 :强制将工作内存的修改刷新到主内存;3. 结合happen-before 原则:同一锁的解锁操作 happen-before 于后续加锁操作,确保修改被后续线程感知。 | 需同时保证原子性 + 可见性的场景 |
| Lock 接口 | 与synchronized原理一致,unlock()时强制刷新主内存,结合happen-before原则保障可见性。 |
显式锁场景 |
| final 关键字 | 被final修饰的变量,初始化完成后(无逸出),其他线程能看到其最终值,不可被修改。 |
不可变变量的可见性保障 |
4. 面试关键点
volatile的可见性不依赖锁,是轻量级方案,但无法解决复合操作的原子性问题。- 可见性的本质是强制内存同步,打破 "工作内存与主内存的隔离"。
四、有序性
1. 定义
程序执行的顺序,符合代码的编写顺序 ,解决因编译器优化和CPU指令重排导致的 "代码执行顺序混乱" 问题。
2. 核心问题:指令重排
为提升执行效率,编译器和 CPU 会在不改变单线程执行结果的前提下,对指令执行顺序进行重排。但多线程下,重排会导致执行逻辑与预期不符。
-
典型反例:双重检查锁(DCL)单例模式
java
运行
public class Singleton { private static Singleton instance; // 未加volatile public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { if (instance == null) { // 第二次检查 instance = new Singleton(); // 指令重排风险 } } } return instance; } }instance = new Singleton()实际分为 3 步:- 分配内存空间;
- 初始化对象;
- 将
instance指向内存地址。
- 编译器可能重排为
1→3→2:线程 A 执行到 3 时,instance已非null,但对象未初始化;此时线程 B 进入第一次检查,会获取到 "半初始化" 的对象,导致程序异常。
3. 保障机制
| 保障手段 | 原理说明 | 适用场景 |
|---|---|---|
| volatile 关键字 | 通过内存屏障 禁止指令重排:1. 写volatile变量时,禁止其之前的指令重排到之后;2. 读volatile变量时,禁止其之后的指令重排到之前。 |
防止指令重排的场景(如 DCL 单例的instance变量) |
| synchronized 关键字 | 1. 同步块内代码串行执行 ,不存在多线程指令交叉,自然符合编写顺序;2. 结合happen-before原则:前线程同步块内的操作顺序,会传递给后续获取同一锁的线程。 |
需同时保证原子性 + 有序性的场景 |
| happen-before 原则 | JMM 的核心规则,定义了操作之间的 "先后顺序",满足该原则则保证有序性。关键规则:1. 解锁操作 happen-before 后续同一锁的加锁操作;2. volatile变量的写操作 happen-before 后续的读操作;3. 线程内操作按代码顺序 happen-before。 |
所有有序性保障的底层逻辑 |
4. 面试关键点
- 指令重排不影响单线程执行结果,仅在多线程下引发问题。
- DCL 单例模式中,
instance必须加volatile,否则会因指令重排导致 "半初始化对象" 问题。
五、三大特性与核心关键字能力对比(面试必背)
| 特性 | synchronized | volatile | AtomicInteger(CAS) | Lock 接口 |
|---|---|---|---|---|
| 原子性 | ✅ 支持复合操作 | ❌ 不支持 | ✅ 支持简单原子操作 | ✅ 支持复合操作 |
| 可见性 | ✅ | ✅ | ✅ | ✅ |
| 有序性 | ✅ | ✅ | ❌ 不保证 | ✅ |
| 核心优势 | 全能型,无需手动释放锁 | 轻量级,无锁 | 无锁,高性能 | 灵活控制(公平锁 / 超时锁) |
| 核心劣势 | 重量级,可能引发上下文切换 | 不支持原子性 | 高并发下可能自旋消耗 CPU | 需手动释放锁,易遗漏 |
六、面试答题思路总结
- 先定义:分别解释原子性(不可分割)、可见性(修改立即可见)、有序性(执行顺序符合代码)。
- 讲问题 :结合反例说明不保障三大特性的后果(如
count++原子性问题、线程看不到修改的可见性问题、DCL 的有序性问题)。 - 说机制 :分点阐述每种特性的保障手段,重点对比
synchronized和volatile的差异。 - 划重点 :强调
volatile不保证原子性、DCL 必须加volatile、synchronized是全能型锁。
面试追问问题:如果synchronized是全能的,那为什么DCL要加volatile?
一、核心结论
synchronized的 "有序性保障" 有边界 :它只能保证同步块内的代码串行执行 ,但无法禁止同步块内的指令重排 (只要重排不影响单线程执行结果);而 DCL 的问题恰恰出在instance = new Singleton()的指令重排上,这个重排发生在synchronized同步块内,synchronized管不住,必须靠volatile禁止重排。
二、拆解 DCL 的执行逻辑(为什么 synchronized 不够)
先回顾 DCL 的核心代码:
public class Singleton {
// 关键:不加volatile会出问题
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // ① 第一次检查(无锁)
synchronized (Singleton.class) { // ② 加锁
if (instance == null) { // ③ 第二次检查(加锁后)
instance = new Singleton(); // ④ 问题核心:指令重排
}
} // ⑤ 解锁
}
return instance; // ⑥ 返回实例
}
}
1. instance = new Singleton()的真实指令(3 步)
这行代码看似是 "一步赋值",实际被编译器 / CPU 拆分为 3 条指令:
plaintext
1. 分配内存空间(给Singleton对象);
2. 初始化对象(执行构造方法,给属性赋值);
3. 将instance引用指向刚分配的内存地址(此时instance≠null)。
2. 编译器的 "合法重排"(synchronized 管不住)
为了提升效率,编译器在不改变单线程执行结果的前提下,会把指令重排为:
plaintext
1. 分配内存空间;
3. 将instance指向内存地址(instance≠null);
2. 初始化对象。
⚠️ 重点:这个重排发生在 synchronized 同步块内 ,但synchronized只保证 "同步块内的代码串行执行",不禁止 "单线程内的指令重排"------ 因为重排后单线程执行结果没变(最终 instance 都指向初始化后的对象),所以 JVM 允许这种优化。
3. 重排导致的线程安全问题(synchronized 无法解决)
假设有两个线程 A、B 同时调用getInstance():
| 线程 A(进入同步块) | 线程 B(未进入同步块) |
|---|---|
| 执行指令 1:分配内存 | - |
| 执行指令 3:instance≠null(但对象未初始化) | - |
| 准备执行指令 2:初始化对象(还没执行) | 执行①第一次检查:发现 instance≠null → 直接返回 instance |
| 执行指令 2:初始化对象 | 线程 B 拿到 "半初始化" 的对象,调用其方法时 NPE / 数据异常 |
此时synchronized的作用完全失效:
synchronized保证了 "只有线程 A 能进入同步块执行初始化",但管不住 "线程 B 在同步块外的第一次检查";- 线程 B 看到的
instance≠null是重排后的 "虚假非空",此时对象还没初始化,导致程序崩溃。
4. volatile 的 "兜底作用"
给instance加volatile后:
private static volatile Singleton instance;
volatile通过内存屏障 禁止了上述指令重排,强制指令按1→2→3的顺序执行 ------ 只有当对象完全初始化(指令 2 执行完)后,instance 才会被赋值为非 null。
此时线程 B 的第一次检查:
- 要么看到
instance=null(进入同步块排队); - 要么看到
instance≠null(此时对象已完全初始化);彻底避免了 "半初始化对象" 问题。
三、synchronized vs volatile:有序性保障的边界(面试必背)
| 特性 | synchronized | volatile |
|---|---|---|
| 有序性保障范围 | 1. 同步块内代码串行执行(多线程无交叉);2. 解锁操作 happen-before 后续加锁操作(保证顺序可见性)。 | 禁止被修饰变量的指令重排(无论是否在同步块内)。 |
| 对 "单线程指令重排" 的态度 | 允许(只要单线程结果不变) | 禁止(针对被修饰变量的读写指令) |
| 在 DCL 中的作用 | 保证 "只有一个线程执行初始化"(原子性) | 禁止初始化指令重排(有序性) |
总结
synchronized在 DCL 中只解决了 "原子性" 问题(保证只有一个线程初始化对象),但解决不了 "有序性" 的核心问题(指令重排导致的半初始化对象);volatile的核心价值是禁止指令重排 ,补上了synchronized在 "单线程指令重排" 上的漏洞;- DCL 必须同时用
synchronized(保证原子性)+volatile(保证有序性),缺一不可。