第 04 期:JMM 与 Happens-Before------并发正确性的基石
1) 面试原题
- 解释 Java 内存模型(JMM)的三大性质:可见性 、有序性 、原子性。
volatile
与synchronized
的语义差异是什么?- 什么是 Happens-Before?它如何约束指令重排?
- 举例说明双重检查锁定(DCL)的错误与修复。
2) 第一性拆解(约束 → 成本 → 原语 → 可验证)
约束(Constraints)
- 多核 CPU 有多级缓存;线程写入先 到本地缓存,可能看不见 彼此更新 → 可见性问题。
- 编译器/CPU 为性能会做重排序 ,代码"看起来按顺序",但执行可能换位 → 有序性问题。
- 表达式如
i++
不是不可分割 → 原子性问题。 - 并发正确性必须在这些现实约束下成立。
成本模型(Cost Model)
- 锁 :提供互斥 + 可见性,但带来上下文切换/竞争/阻塞成本。
**volatile**
:插入内存屏障 、刷新/失效缓存行;相比加锁开销低,但不提供互斥。- CAS :无锁原子更新,低冲突高吞吐;高冲突下自旋浪费 CPU。
最小原语(Primitives)
- HB(Happens-Before)规则:建立"先行发生"关系以禁止破坏一致性的重排。
- 内存屏障 :
LoadLoad
/LoadStore
/StoreLoad
/StoreStore
约束读写顺序。 - 同步原语 :
volatile
、synchronized
、CAS(Atomic*
/VarHandle
)、final
字段的发布语义。
可验证(Check)
- 编写乱序复现小程序,观察"不可能值"出现。
- 用
volatile/锁/CAS
修复并验证消失。 - 通过循环放大/多次运行,观察统计特征而非"偶然正确"。
3) 概念透视:JMM 三大性质与一个核心关系
- 可见性(Visibility):一个线程的写,何时被另一个线程看见(缓存一致性 + 屏障)。
- 有序性(Ordering) :在不影响单线程语义的前提下,编译器/CPU会重排;HB 规则界定不能被重排的边界。
- 原子性(Atomicity) :操作是否不可分割;
long/double
在现代 JDK 已保证单次读写的原子性(64 位),但复合操作不原子。 - Happens-Before(HB) :核心 关系。若 A HB B,则 A 的结果对 B 可见 ,且 A 与 B 不重排。
记忆法 :HB = "可见 + 不乱序"的契约。volatile
/锁/线程启动终止等,都是建立 HB 的方法。
4) HB 规则清单(面试可直接背)
- 程序次序规则:同一线程内,按代码顺序 HB。
- 监视器锁规则:对同一锁的解锁(unlock)HB 之后对该锁的加锁(lock)。
**volatile**
** 变量规则**:对volatile
的写 HB 之后对同一变量的读。- 线程启动规则 :
Thread.start()
HB 线程内的第一个动作。 - 线程终止规则 :线程内的最后一个动作 HB 其它线程对
Thread.join()
的返回。 - 中断规则 :对线程
interrupt()
的调用 HB 被中断线程检测到中断(isInterrupted()/InterruptedException
)。 - 对象终结规则 :对象构造完成 HB 其
finalize()
开始(几乎过时,仅了解)。 - 传递性:A HB B,B HB C ⇒ A HB C。
加分: **final**
** 字段语义**
- 构造函数结束前 将
final
字段写入主内存,且引用安全发布 后,其他线程读取到的final
字段值不可见被重写 (除非反射/Unsafe 破坏),用于构建不可变对象。
5) 乱序"出现不可能值"与修复(可复现)
5.1 复现:乱序导致 (0,0)
java
// 演示用途,可能需要多次运行才触发
public class ReorderingDemo {
static int x, y, a, b;
public static void main(String[] args) throws Exception {
int count = 0;
while (true) {
count++;
x = y = a = b = 0;
Thread t1 = new Thread(() -> { a = 1; x = b; });
Thread t2 = new Thread(() -> { b = 1; y = a; });
t1.start(); t2.start();
t1.join(); t2.join();
if (x == 0 && y == 0) {
System.out.println("Observed (0,0) at iteration: " + count);
break;
}
}
}
}
原因 :无 HB 约束时,编译器/CPU 允许将写/读乱序,导致两个线程都在对方写之前读到初始值 0。
5.2 修复 A:volatile
建立写→读 HB
java
public class VolatileFix {
static volatile int a, b;
static int x, y;
public static void main(String[] args) throws Exception {
for (int i = 0; i < 1_000_00; i++) {
x = y = 0; a = b = 0;
Thread t1 = new Thread(() -> { a = 1; x = b; });
Thread t2 = new Thread(() -> { b = 1; y = a; });
t1.start(); t2.start();
t1.join(); t2.join();
if (x == 0 && y == 0) throw new AssertionError("HB violated");
}
System.out.println("No (0,0) with volatile.");
}
}
5.3 修复 B:synchronized
(互斥 + 可见性)
java
public class SyncFix {
static int a, b, x, y;
static final Object lock = new Object();
public static void main(String[] args) throws Exception {
for (int i = 0; i < 100_000; i++) {
x = y = 0; a = b = 0;
Thread t1 = new Thread(() -> { synchronized (lock) { a = 1; x = b; }});
Thread t2 = new Thread(() -> { synchronized (lock) { b = 1; y = a; }});
t1.start(); t2.start();
t1.join(); t2.join();
if (x == 0 && y == 0) throw new AssertionError();
}
System.out.println("No (0,0) with synchronized.");
}
}
6) 双重检查锁定(DCL)错误与修复
6.1 错误版本(缺乏有序性)
java
public class SingletonBroken {
private static SingletonBroken INSTANCE; // 非 volatile
private final byte[] data = new byte[1024];
private SingletonBroken() {}
public static SingletonBroken getInstance() {
if (INSTANCE == null) { // 1. 读
synchronized (SingletonBroken.class) {
if (INSTANCE == null) { // 2. 再次读
INSTANCE = new SingletonBroken(); // 3. 分配→构造→赋值(可能被重排)
}
}
}
return INSTANCE; // 4. 可能读到"未完全构造"的对象
}
}
问题 :赋值与构造可能被重排(先把引用写入,再执行构造),其他线程读到非空引用但对象未完全初始化。
6.2 正确版本(volatile
阻止重排 + 可见性)
java
public class Singleton {
private static volatile Singleton INSTANCE; // 关键:volatile
private final byte[] data = new byte[1024];
private Singleton() {}
public static Singleton getInstance() {
if (INSTANCE == null) {
synchronized (Singleton.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton(); // 构造完成的发布对其他线程可见
}
}
}
return INSTANCE;
}
}
更进一步 :若能容忍类初始化时创建实例,静态初始化 或枚举实现更简单且天生安全:
java
// 静态内部类方式
public class HolderSingleton {
private HolderSingleton() {}
private static class Holder { static final HolderSingleton I = new HolderSingleton(); }
public static HolderSingleton getInstance() { return Holder.I; }
}
// 枚举
public enum EnumSingleton { INSTANCE; }
7) volatile
能与不能
能:
- 保证可见性(写-读建立 HB)。
- 限制重排(写前不会把后续操作移到写之前,读后不会把之前操作移到读之后)。
- 适用于:状态标志 (停止位)、一次发布 (单写多读)、配置热更新(读多写少)。
不能:
- 不保证复合操作原子性 (
count++
仍然竞争)。 - 不提供互斥,无法保护不变量/组合条件。
- 高吞吐计数需用
LongAdder
或带 CAS 的AtomicLong
;复杂不变量需锁 或不可变数据结构。
8) 安全发布(Safe Publication)清单
确保"构造好的对象"对其他线程一次性 、完整地可见:
- 通过
**volatile**
** 写**(把引用写入 volatile 字段)。 - 通过 锁(在解锁前构造并写入,另一线程加锁读取)。
- 通过 静态初始化(类初始化阶段天生有 HB 约束)。
- 将对象发布到 并发容器 (如
ConcurrentHashMap
、CopyOnWriteArrayList
)。 **final**
** 字段**:只要对象构造期间没有把this
逸出,final
字段对读者天然可见为构造后的值。
反例 :在构造函数中把 this
传给别的线程或注册到可被别的线程访问的全局结构,打破安全发布。
9) 工程实践:选择与权衡
- 标志/配置 :
volatile
字段 + 本地缓存读取频率控制。 - 计数器 :低竞争用
AtomicLong
;高并发下用LongAdder
(分段累加,读时合并)。 - 队列生产-消费 :用
BlockingQueue
(内部用 AQS/锁/条件队列保障 HB)。 - 复杂不变量:锁保护(或使用不可变对象 + 复制写)。
- 热点路径性能:先保证正确性,再用火焰图/JFR 定位锁竞争,评估改为 CAS 或分区化的性价比。
10) 常见误区 & 诊断
- "用了
**volatile**
就线程安全" :错。volatile
≠ 互斥,复合操作仍然竞态。 - "没复现就没问题" :错。并发 bug 随时间/硬件/编译优化而变;要用构造性测试 + 放大循环。
- "final 一定不可变" :仅对字段引用 不可变;若
final List
,其内部元素仍可变。 - "锁一定很慢" :现代 JVM 有偏向/轻量级/自适应自旋,短临界区代价可接受;要用数据说话。
诊断手段
- 压测 + 火焰图(锁热点、CAS 失败重试)
jfr
/jcmd
观察线程状态(BLOCKED、RUNNABLE 自旋)- 打开
-Xint/-Xmixed
对比乱序复现概率(仅辅助理解)
11) 速答卡(30 秒背诵)
- JMM 关心:可见性 / 有序性 / 原子性。
- HB 规则 :程序次序、
volatile
写→读、锁解锁→加锁、start()
/join()
、传递性。 **volatile**
:可见 + 部分禁止重排;不提供互斥,复合操作仍需 CAS/锁。- DCL :未加
volatile
会因重排读到"半初始化对象";正确解法是volatile
或静态初始化/枚举。 - 安全发布 :
volatile
、锁、静态初始化、并发容器、final
字段构造后不逸出。
12) 代码清单(可直接改造实验)
12.1 可取消的生产者-消费者:volatile
标志 vs 中断
java
import java.util.concurrent.*;
public class CancelDemo {
static volatile boolean running = true;
public static void main(String[] args) throws Exception {
BlockingQueue<Integer> q = new ArrayBlockingQueue<>(1024);
Thread producer = new Thread(() -> {
int i = 0;
while (running) {
try { q.put(i++); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
});
Thread consumer = new Thread(() -> {
while (running) {
try { q.take(); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
});
producer.start(); consumer.start();
TimeUnit.SECONDS.sleep(1);
running = false; // 通过 volatile 可见
producer.interrupt(); // 响应阻塞状态
consumer.interrupt();
producer.join(); consumer.join();
System.out.println("Stopped.");
}
}
要点:
- 计算中 可用
volatile
标志停止; - 阻塞中 必须用中断 唤醒(
take/put
),两者结合才可靠。
12.2 计数对比:AtomicLong
vs LongAdder
java
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
public class CounterCompare {
static final int THREADS = 32;
static final int OPS = 1_000_00;
public static void main(String[] args) throws Exception {
benchAtomic();
benchAdder();
}
static void benchAtomic() throws Exception {
AtomicLong c = new AtomicLong();
run(() -> { for (int i = 0; i < OPS; i++) c.incrementAndGet(); }, "AtomicLong");
System.out.println(c.get());
}
static void benchAdder() throws Exception {
LongAdder c = new LongAdder();
run(() -> { for (int i = 0; i < OPS; i++) c.increment(); }, "LongAdder");
System.out.println(c.sum());
}
static void run(Runnable task, String label) throws Exception {
long t0 = System.nanoTime();
ExecutorService es = Executors.newFixedThreadPool(THREADS);
for (int i = 0; i < THREADS; i++) es.submit(task);
es.shutdown();
es.awaitTermination(1, TimeUnit.MINUTES);
long t1 = System.nanoTime();
System.out.printf("%s took %.2f ms%n", label, (t1 - t0) / 1e6);
}
}
预期 :高并发下 LongAdder
更快(减少单点 CAS 冲突),但读取开销略高(需要合并)。
13) 小挑战(可验证、可评分)
- 乱序频度实验 :在
ReorderingDemo
中加入"无意义计算"或Thread.onSpinWait()
,观察 (0,0) 触发频率变化,写出结论。 - DCL 拆解 :用
-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation
观察getInstance()
的编译情况,解释为何volatile
能阻止错误。 - 安全发布 :写一个不可变配置类
Config{ final String a; final int b; }
,对比用volatile
引用发布 vs 非volatile
发布,设计测试证明读取一致性差异。 - 面试复述 :用"约束→成本→原语→结论 ",讲清
volatile
能与不能。
14) 加分项(深入但不必死记)
- x86 与 ARM 的内存模型差异 :x86 较强(TSO),
StoreLoad
最敏感;ARM 更弱,volatile
成本更高。 - JIT 优化与屏障折叠:JIT 可能合并/消除冗余屏障;理解这一点帮助解释"看不见的优化"。
- 不可变对象的威力:用不可变数据 + 写时复制(Copy-On-Write)绕开大量同步复杂度。
15) 小结(面试说法)
Java 并发的本质是在弱内存模型上用 HB 建立跨线程的"可见 + 不乱序"关系 。
volatile
负责轻量的可见/顺序 ,synchronized
/CAS 负责原子性/互斥 。
正确性优先、再谈性能,选择时遵循:标志/发布→volatile ,简单计数→原子类/LongAdder ,复杂不变量→锁或不可变设计。