happen-before原则
什么是 happen-before 原则?
happen-before 是一个逻辑关系,用于描述两个操作之间的 “先后顺序”—— 如果操作 A happen-before 操作 B,那么 A 的执行结果必须对 B 可见,且 A 的执行顺序在逻辑上先于 B。也就是保证指令有序性和共享变量的可见性。
具体的 happen-before 规则
JMM 定义了 8 条核心 happen-before 规则,每条规则都直接或间接关联可见性:
规则名称 | 具体描述 | 可见性体现举例 |
---|---|---|
程序次序规则 | 单线程内,按代码顺序,前面的操作 happen-before 后面的操作。 | 线程内先给 a=1 ,再打印 a ,一定能读到 1 (单线程可见性天然成立)。 |
管程锁定规则 | 解锁操作 happen-before 后续对同一把锁的加锁操作。 | 线程 A 解锁前修改 x=2 ,线程 B 加锁后一定能读到 x=2 (锁保证可见性)。 例如:synchronized 关键字、java.util.concurrent.locks.Lock 接口的实现类包括ReentrantLock、ReentrantReadWriteLock |
volatile 变量规则 | 对 volatile 变量的写操作 happen-before 后续对该变量的读操作。 | 线程 A 写 volatile x=3 ,线程 B 读 x 一定能得到 3 (volatile 保证可见性)。 |
线程启动规则 | Thread.start() 操作 happen-before 线程内的任意操作。 | 主线程启动子线程前设置 flag=true ,子线程启动后一定能读到 flag=true 。 |
线程终止规则 | 线程内的任意操作 happen-before 其他线程检测到该线程终止(如 join() )。 | 子线程修改 count=5 ,主线程通过 join() 等待子线程结束后,一定能读到 count=5 。 |
线程中断规则 | 对线程的中断操作 happen-before 被中断线程检测到中断事件(如 isInterrupted() )。 | 线程 A 中断线程 B,线程 B 后续调用 isInterrupted() 一定能感知到中断。 |
传递性规则 | 若 A happen-before B,且 B happen-before C,则 A happen-before C。 | 若 A 写 volatile x ,B 读 x 后写 y ,C 读 y 则能感知 A 的修改(传递可见)。 |
final 字段规则 | 对象初始化完成(构造函数返回)happen-before 对其 final 字段的访问。 | 对象构造时给 final x=10 ,其他线程访问 x 一定能得到 10 (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 读取到未初始化的对象。