Java并发(二):JMM、volatile 与线程安全
Java并发(二):JMM、volatile 与线程安全
导语:本篇回答「为什么多线程会出问题」这一根本命题:JMM 的抽象模型、happens-before 规则与内存屏障,volatile 的能力与边界,以及线程安全的实现思路与安全发布。共 12 题。
一、JMM 与内存语义
1. 什么是 Java 内存模型(JMM)?
答: JMM(Java Memory Model)是 Java 为屏蔽不同硬件与操作系统的内存访问差异而定义的一套抽象规范,规范了线程工作内存与主内存之间的交互规则,以及变量的读写可见性。
- 主内存:所有共享变量存放的地方(对应堆内存);
- 工作内存:每个线程私有,保存其使用到的共享变量的副本(对应 CPU 缓存/寄存器)。
规则:线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存;修改后需刷回主内存,其他线程需要重新从主内存加载才能看到。
JMM 通过 happens-before 规则 + 内存屏障来保证多线程下的可见性与有序性,而不需关心底层是 x86 还是 ARM。
三大特性在 JMM 中的落点:原子性由
synchronized/锁/CAS 保证;可见性由volatile/synchronized/final保证;有序性由volatile/synchronized禁止重排保证。
2. 并发编程的三大特性是什么?各自由什么保证?
答:
| 特性 | 含义 | 保证手段 |
|---|---|---|
| 原子性 | 一个或多个操作要么全部执行、要么全不执行,中间不可被中断 | synchronized、ReentrantLock、CAS/java.util.concurrent.atomic |
| 可见性 | 一个线程修改共享变量后,其他线程能立即看到 | volatile、synchronized、final、锁 |
| 有序性 | 程序执行顺序符合预期,不被编译器和 CPU 过度重排 | volatile(禁止特定重排)、synchronized(临界区串行化)、happens-before |
关键区分:
volatile只保证可见性与有序性,不保证原子性(i++依然不安全);synchronized三者都保证,代价是可能阻塞。
3. 什么是 happens-before 原则?列举主要规则。
答: happens-before(先行发生)是 JMM 定义的偏序关系:若 A happens-before B,则 A 的操作结果对 B 可见,且 A 在 B 之前发生。它是程序员视角的可见性保证,与指令实际执行顺序无关。
主要规则:
| 规则 | 内容 |
|---|---|
| 程序次序规则 | 单线程内,前面的操作 happens-before 后面的操作 |
| volatile 规则 | 对 volatile 变量的写 happens-before 后续对该变量的读 |
| 锁规则 | unlock happens-before 后续对同一把锁的 lock |
| 线程启动规则 | Thread.start() happens-before 该线程的任何动作 |
| 线程终止规则 | 线程的所有动作 happens-before 其他线程从 join() 返回 |
| 中断规则 | interrupt() 调用 happens-before 被中断线程检测到中断 |
| 对象终结规则 | 构造函数结束 happens-before finalize() 开始 |
| 传递性 | A hb B 且 B hb C ⇒ A hb C |
实用价值:写并发代码时不用记底层屏障,只要保证操作之间存在 happens-before 链,就能保证可见性。例如"用
volatile标志位通知另一个线程数据已就绪",就是靠 volatile 规则 + 程序次序规则 + 传递性建链。
4. 什么是内存屏障?JMM 有哪几种?
答: 内存屏障(Memory Barrier / Fence)是处理器或编译器层面的指令,用于禁止特定类型的指令重排序并强制刷新/失效缓存,是 volatile 与 synchronized 语义的底层实现手段。
JMM 定义了四种屏障:
| 屏障类型 | 作用 |
|---|---|
| LoadLoad | 禁止前面的读与后面的读重排 |
| StoreStore | 禁止前面的写与后面的写重排 |
| LoadStore | 禁止前面的读与后面的写重排 |
| StoreLoad | 禁止前面的写与后面的读重排,开销最大(通常需要清空写缓冲区) |
volatile 的屏障插入策略(JMM 保守实现):
- 写
volatile变量时:写前插StoreStore,写后插StoreLoad; - 读
volatile变量时:读后插LoadLoad,读后插LoadStore。
这组屏障保证 volatile 变量不会被其前后的读写操作"越过",等价于 acquire/release 语义:写相当于 release(之前的操作全部生效后才发布),读相当于 acquire(之后的操作必然看到写前的所有结果)。
二、volatile
5. volatile 的两大作用是什么?
答:
- 保证可见性:写
volatile变量会立即刷回主内存,并使其他 CPU 中该变量的缓存行失效;读时强制从主内存重新加载,从而保证其他线程能立刻看到最新值。 - 禁止指令重排序(保证有序性):通过在变量前后插入内存屏障,保证该变量前后的指令不会跨过它重排。典型应用是 DCL 单例 中防止"半初始化对象"逸出。
注意:
volatile不保证原子性(除单次读/写本身是原子的),也不保证复合操作的线程安全。
6. volatile 的底层实现原理是什么?
答: 分三个层次看:
1)字节码层面:字段被标记 ACC_VOLATILE 访问标志。
2)JVM 层面:JIT 在读写该字段时按 JMM 要求插入对应的内存屏障(见第 4 题),组织指令重排。
3)CPU/汇编层面:写 volatile 变量会生成带 lock 前缀的指令(x86 上形如 lock addl $0x0,(%rsp),用一个空操作携带 lock 前缀)。lock 前缀触发两件事:
- 将当前处理器写缓冲区中的数据立即写回主内存;
- 依据 MESI 缓存一致性协议,使其他 CPU 中该内存地址的缓存行失效(invalidate),迫使它们下次读时从主内存重新加载。
一句话总结:
volatile= 内存屏障(禁止重排)+ lock 前缀指令(缓存失效、强制刷主存),二者共同实现可见性与有序性。
7. 为什么 volatile 不能保证原子性?举例说明。
答: volatile 只保证单次读/写操作的原子性,不保证"读-改-写"复合操作的原子性。
例如 i++ 在字节码层面是三步:getfield(读)→ iadd(加)→ putfield(写)。多个线程可能同时读到相同的值,各自加一后写回,导致丢失更新:
private volatile int count = 0;
// 多线程并发执行 1000 次 count++,最终结果通常 < 1000正确做法:
AtomicInteger.incrementAndGet()(CAS 自旋);- 或用
synchronized/ReentrantLock包裹复合操作; - 若是纯计数统计,可用
LongAdder。
判断口诀:只要操作的"新值依赖旧值",
volatile就不够用。
8. volatile 的典型使用场景有哪些?
答:
- 状态标志位:如
volatile boolean running控制线程退出循环(见第 8 题:优雅停止线程); - DCL 单例中修饰实例引用,防止指令重排导致的半初始化对象逸出;
- 一次性安全发布:把对象写入
volatile字段即完成安全发布,代价低于加锁; - 独立观察量:如温度采样值,各次读取互不依赖,只关心"能读到最新值"。
使用前提(两个条件同时满足):① 变量的写操作不依赖当前值;② 该变量不与其他状态变量构成不变式约束。任一条不满足,就必须用锁。
9. synchronized 与 volatile 的区别?
答:
| 维度 | synchronized | volatile |
|---|---|---|
| 原子性 | 保证(整个临界区) | 仅保证单次读/写 |
| 可见性 | 保证 | 保证 |
| 有序性 | 保证(临界区串行化) | 保证(禁止特定重排) |
| 是否阻塞 | 可能阻塞(竞争时) | 不阻塞 |
| 作用范围 | 方法、代码块、类 | 仅变量 |
| 本质 | 互斥锁(悲观) | 轻量级同步机制 |
| 性能开销 | 较高(有竞争时涉及内核态) | 极低 |
选择原则:能用 volatile 就不加锁(更轻量);但一旦涉及复合操作或多变量的一致性,volatile 无能为力,必须用锁。
10. 双重检查锁定(DCL)单例为什么要加 volatile?
答: 不加 volatile 时,instance = new Singleton() 可能被重排为三步:① 分配内存 → ② 将引用指向内存 → ③ 初始化对象。若 ②③ 发生重排,另一线程可能在第 ② 步之后、第 ③ 步之前进入第一个 if 判断,拿到非 null 但未初始化完成的引用,访问到半初始化对象。
用 volatile 修饰 instance 可禁止该重排(写后插 StoreLoad 屏障),保证对象初始化完成后引用才对其他线程可见:
public class Singleton {
private static volatile Singleton instance; // volatile 是必需的
public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁,提升性能)
synchronized (Singleton.class) {
if (instance == null) // 第二次检查(持锁,防重复创建)
instance = new Singleton();
}
}
return instance;
}
}更好的替代方案:静态内部类(Holder) 或 枚举单例——前者利用类加载的线程安全与延迟初始化,后者还能天然防反射与反序列化破坏,都不需要
volatile与双重判断。
三、线程安全与安全发布
11. 什么是线程安全?如何实现线程安全?
答: 当多个线程访问某个类时,该类始终能表现出正确的行为,无需调用方额外做同步或协调,则称其是线程安全的。核心挑战来自竞态条件(多个线程对共享可变状态的非原子交错操作)。
实现思路分三类:
- 不共享(最彻底):
- 线程封闭:
ThreadLocal、栈封闭(局部变量天然安全)、Ad-hoc线程封闭;
- 线程封闭:
- 不可变:
final字段、不可变对象(状态创建后不可改,天然线程安全,如String、Integer);
- 同步:
- 互斥:
synchronized、ReentrantLock; - 无锁:CAS、
Atomic*、volatile; - 并发容器:
ConcurrentHashMap、CopyOnWriteArrayList。
- 互斥:
判断口诀:"共享可变"才是问题的根源——消灭任一个(不共享、不可变、或让可变状态同步),问题就消失了。
12. 什么是对象逸出与安全发布?this 逸出有哪些危害?
答: 逸出(Escape) 指对象在尚未构造完成时就被其他线程可见/访问。安全发布则是指在对象构造完成后,以正确的、能保证可见性与有序性的方式将其引用暴露给其他线程。
this 逸出的常见形式:
public class ThisEscape {
private int value;
public ThisEscape(EventSource source) {
// ① 构造器内向外部注册监听器(内部类隐式持有 this)
source.registerListener(e -> System.out.println(this.value));
value = 42; // 可能"发布"先于"赋值"
}
// ② 构造器内启动线程并传入 this
public ThisEscape() {
new Thread(this::doSomething).start();
}
}危害:其他线程可能拿到半初始化对象,读到的字段是默认值(0/null)而非构造器设的值,且没有任何同步机制会提醒你——这是典型的"看起来偶发、极难复现"的 bug。
安全发布的四种方式:
- 在静态初始化器中初始化对象引用(借助类加载的线程安全);
- 将对象引用保存到
volatile字段或AtomicReference中; - 将对象引用保存到由锁保护的字段中;
- 将对象放入并发容器(如
ConcurrentHashMap、BlockingQueue)。
final的内存语义:JMM 对final字段有特殊保证——只要构造器中没有this逸出,final字段在构造完成前必定已正确初始化,且不会重排到构造器之外,其他线程无需同步即可看到正确的final值。这也是"不可变对象天然线程安全"的底层依据;而非final字段没有这层保证,这正是第 10 题 DCL 必须加volatile的根因。
