什么是 happen-before 原则?
happen-before 是一个逻辑关系,用于描述两个操作之间的 “先后顺序”—— 如果操作 A happen-before 操作 B,那么 A 的执行结果必须对 B 可见,且 A 的执行顺序在逻辑上先于 B。也就是保证指令有序性和共享变量的可见性。
具体的 happen-before 规则
JMM 定义了 9 条核心 happen-before 规则,每条规则都直接或间接关联可见性:
规则名称 | 描述 | 代码示例 | 可见性体现与说明 |
---|---|---|---|
程序次序规则 | 在同一个线程中,按照程序的控制流顺序,前面的操作 Happens-Before 于后面的任何操作。 | int a = 1; int b = a; // b 一定能看到 a=1 | 线程内,后面的操作一定能看到前面操作对变量的修改。这是单线程语义的基础。 |
管程锁定规则 | 对一个锁的解锁操作 Happens-Before 于后续对同一个锁的加锁操作。 | // 线程A synchronized(lock) { sharedVar = 100; } // 解锁 // 线程B synchronized(lock) { // 加锁 print(sharedVar); // 保证看到100 } | 线程B在获得锁之后,一定能看到线程A在释放同一把锁之前对所有共享变量所做的修改。管程(Monitor) 指锁,synchronized 及 Lock 实现类(如 ReentrantLock )都遵守此规则。 |
volatile 变量规则 | 对一个 volatile 变量的写操作 Happens-Before 于后续任何一个对该变量的读操作。 | // 线程A sharedData = ...; // 普通写 volatileFlag = true; // volatile写 // 线程B if (volatileFlag) { // volatile读 print(sharedData); // 能看到sharedData的修改 } | 线程B读到 volatileFlag 为 true 时,不仅能看到 volatileFlag 的最新值,也能看到线程A在写 volatileFlag 之前的所有写操作。 |
线程启动规则 | 主线程调用子线程的 start() 方法 Happens-Before 于该子线程中的任何操作。 | int x = 10; // 启动前修改 Thread t = new Thread(() -> { int finalX = x; // 子线程读取 System.out.println(finalX); // 输出10 }); // x = 20; // 此处赋值会导致编译错误! t.start(); | 子线程开始执行时,能看到主线程在调用 start() 之前对(effectively final的)变量x 的修改。注意:由于Lambda与匿名内部类要求局部变量是final或effectively final的,主线程无法在创建线程后再修改x 。 如果x是成员变量,那么修改x = 20,子线程可以读取到20 |
线程终止规则 | 一个线程中的所有操作都 Happens-Before 于其他线程成功从该线程的 join() 方法返回。 | // 子线程 Thread t = new Thread(() -> { result = compute(); // 子线程中计算 }); t.start(); t.join(); // 等待子线程终止 System.out.println(result); // 能看到result的修改 | 主线程在 join() 成功返回后,能 guaranteed 看到子线程在执行过程中对共享变量(如result )的所有修改。 |
线程中断规则 | 调用线程 interrupt() 方法 Happens-Before 于被中断线程检测到中断状态。 | // 线程A threadB.interrupt(); // 中断操作 // 线程B if (Thread.interrupted()) { // 一定能感知到中断操作 } | 如果一个线程被中断,它之后检测中断状态时,一定能看到那个中断请求。 |
对象终结规则 | 一个对象的构造函数执行结束 Happens-Before 于它的 finalize() 方法的开始。 | public class MyClass { private int value; MyClass() { value = 50; // 构造器内初始化 } // 构造结束 protected void finalize() { // 此处一定能看到 value == 50 } } | 保证垃圾回收器在回收对象之前,该对象已经被完全正确地初始化了。 |
传递性 | 如果操作 A Happens-Before B,且操作 B Happens-Before C,那么可以得出操作 A Happens-Before C。 | // 线程A sharedVar = 1; // (A) 普通写 volatileFlag = true; // (B) volatile写 // 线程C if (volatileFlag) { // (C) volatile读 (B hb C) // 根据传递性: A hb B, B hb C, 所以 A hb C // 故此处能看到 A 的写入结果 (sharedVar=1) } | 该规则是连接其他规则的桥梁,使得跨线程的可见性保证能够通过中间操作进行传递。 |
final 字段规则 | 对于一个包含 final 字段的对象,其构造函数的结束 Happens-Before 于任何其他线程获取到该对象引用并访问其 final 字段。 | public class FinalExample { private final int x = 42; // final字段 } // 其他线程 FinalExample obj = ...; // 获取对象引用 System.out.println(obj.x); // 保证看到42 | 其他线程在拿到一个包含final字段的对象引用后,无须额外的同步,就能 guaranteed 看到 final 字段被构造器初始化的值。 |
补充说明第四条规则中局部变量与成员变量在匿名内部类中的访问区别
生命周期不匹配:
局部变量 x 存储在栈内存中,其生命周期与 方法的执行周期相同
匿名内部类对象(task)存储在堆内存中,其生命周期可能比 方法更长
如果允许内部类访问非 final 的局部变量,当 方法执行完毕,x 的栈帧被销毁后,内部类对象可能还在运行,这将导致访问无效内存
成员变量 x 存储在堆内存中,与匿名内部类对象具有相同的生命周期
内部类通过隐式持有外部类的引用(RunnableExample.this)来访问成员变量
值捕获机制:
Java 通过值捕获来解决这个问题:在创建内部类实例时,将局部变量的值复制一份到内部类中
为了保证复制值与原始变量的一致性,Java 要求局部变量必须是 final 或 effectively final
这样内部类使用的就是捕获时的值快照,不会受到外部修改的影响
内部类不是捕获成员变量的值,而是通过引用访问它
因此,对成员变量的修改会反映到内部类中
总结对比
特性 | 局部变量 | 成员变量 |
---|---|---|
存储位置 | 栈内存 | 堆内存 |
生命周期 | 与方法调用相同 | 与对象实例相同 |
内部类访问方式 | 值捕获(复制) | 引用访问 |
final 要求 | 必须为 final 或 effectively final | 无要求 |
修改可见性 | 内部类看不到外部修改 | 内部类可以看到外部修改 |
线程安全性 | 由语言机制保证 | 需要开发者自己保证 |
synchronized 关键字
最基础的内置锁,通过同步代码块或同步方法实现:
进入 synchronized 块(加锁)时,线程会清空本地缓存,从主内存加载共享变量的最新值。
退出 synchronized 块(解锁)时,线程会将本地缓存中修改的共享变量刷新到主内存。
示例:
private int count = 0;// 同步方法
public synchronized void increment() {count++; // 解锁时会将修改刷新到主内存
}// 同步代码块
public void getCount() {synchronized (this) {return count; // 加锁时会从主内存加载最新值}
}
java.util.concurrent.locks.Lock 接口的实现类
显式锁,最常用的实现是 ReentrantLock,还包括 ReentrantReadWriteLock 等:
调用 lock() 方法(加锁)时,线程会失效本地缓存,强制从主内存加载变量。
调用 unlock() 方法(解锁)时,线程会将本地缓存中的修改刷新到主内存。
示例(ReentrantLock):
private final Lock lock = new ReentrantLock();
private int count = 0;public void increment() {lock.lock();try {count++; // 解锁时刷新到主内存} finally {lock.unlock();}
}public int getCount() {lock.lock();try {return count; // 加锁时从主内存加载} finally {lock.unlock();}
}
读写锁 ReentrantReadWriteLock
分离读锁和写锁,更细粒度的控制:
写锁(writeLock()):获取时会强制加载最新值,释放时会刷新修改到主内存(同普通锁)。
读锁(readLock()):多个线程可同时获取,能看到之前写锁释放的所有修改(保证读操作可见性)。
著名的双重检查单例模式
public class Singleton {// 关键1:使用volatile修饰单例实例private static volatile Singleton instance;// 关键2:私有构造函数,防止外部直接实例化private Singleton() {// 初始化逻辑}// 关键3:双重检查锁定获取实例public static Singleton getInstance() {// 第一次检查:避免不必要的同步(提高性能)if (instance == null) {// 关键4:同步块,保证多线程安全synchronized (Singleton.class) {// 第二次检查:防止多线程同时进入同步块后重复创建实例if (instance == null) {// 关键5:创建实例(volatile在此处防止指令重排序)instance = new Singleton();}}}return instance;}
}
关键代码解析
volatile 修饰符的作用
volatile 在这里有两个核心作用:
保证 instance 变量的可见性(多线程环境下,一个线程对 instance 的修改会立即被其他线程感知),因为第一次检查并使用synchronized 关键字将instance 包含在内,所以必须使用volatile关键字保证可见性。
禁止指令重排序(这是 DCL 模式中 volatile 的核心价值)。
双重检查的意义
第一次检查(同步块外):避免每次调用 getInstance() 都进入同步块,提高性能(多数情况下 instance 已初始化,无需同步)。
第二次检查(同步块内):防止多个线程同时通过第一次检查后,在同步块内重复创建实例。
volatile 如何禁止指令重排序?
对象创建过程(instance = new Singleton())在 JVM 中会被拆分为三步操作:
1. memory = allocate(); // 分配内存空间
2. ctorInstance(memory); // 初始化对象(执行构造函数)
3. instance = memory; // 将引用指向内存地址
问题场景:
如果没有 volatile 修饰,编译器或 CPU 可能对步骤 2 和 3 进行重排序,导致执行顺序变为:1 → 3 → 2。
此时会出现严重问题:
线程 A 执行到步骤 3 后,instance 已非 null(引用已指向内存),但步骤 2 尚未完成(对象未初始化)。
线程 B 此时进行第一次检查(instance == null),会发现 instance 不为 null,直接返回一个未初始化完成的对象,导致程序异常。
volatile 的解决方案:
volatile 通过在对象创建指令前后插入内存屏障(Memory Barrier) 禁止这种重排序:
在步骤 3 之后插入 StoreStore 屏障:禁止初始化对象(步骤 2)与设置引用(步骤 3)的重排序。
在步骤 3 之后插入 StoreLoad 屏障:确保引用赋值(步骤 3)完成后,才允许其他线程读取 instance。
这两个内存屏障强制保证了执行顺序为 1 → 2 → 3,即对象完全初始化后,才会将引用赋值给 instance,从而避免线程 B 读取到未初始化的对象。