Java内存模型面试必背:happens-before的8条规则及代码验证
Java内存模型面试必背:happens-before的8条规则及代码验证
适读人群:Java中高级开发 | 难度:★★★★★ | 出现频率:高
开篇故事
这是我被问到过的、最难讲清楚的面试题之一。
不是因为概念多,而是因为Java内存模型(JMM)本质上是一套关于"可见性"和"有序性"的规范,它讨论的是那些你平时写代码时觉得"天经地义"的事,其实背后都有复杂的规则保障。
我还记得第一次深入看JMM规范时的感觉——整本JSR-133读下来,一半时间都在怀疑人生:为什么两行代码的执行顺序在不同CPU上可能不一样?为什么不加volatile,另一个线程可能永远看不到我修改的值?
happens-before是JMM给程序员的一个承诺:在这些规则覆盖的场景下,你不需要额外同步,JVM保证可见性和有序性。今天把这8条规则彻底搞清楚。
一、高频考点拆解
JMM这道题考察的核心是:
第一层:基础概念 主内存、工作内存、可见性、有序性、原子性——这是基础词汇,答不上来直接判不合格。
第二层:happens-before 知道这个概念存在,能说出几条规则。这是中级水平。
第三层:实际应用 能用happens-before解释double-checked locking为什么需要volatile,能说出指令重排序在什么情况下会导致问题,能写出验证代码。这是高级水平。
二、深度原理分析
2.1 JMM的基本模型
Java内存模型定义了线程与内存之间的交互规则。
JMM规定:所有共享变量(实例变量、类变量)存放在主内存中。每个线程有自己的工作内存,工作内存中保存了主内存变量的副本。线程对变量的所有操作必须在工作内存中进行,不能直接操作主内存。不同线程之间无法直接访问对方的工作内存,线程通信通过主内存完成。
这个模型抽象了真实硬件的多级缓存(L1/L2/L3缓存、CPU寄存器等),解释了为什么一个线程修改了变量,另一个线程可能看不到——因为修改只写到了工作内存,还没有同步到主内存;或者同步到了主内存,但另一个线程用的还是自己工作内存里的旧副本。
2.2 三大特性
原子性:一个或多个操作要么全部执行且不被中断,要么全都不执行。
- Java基本类型(除long/double)的读写是原子的
i++不是原子的(读、加1、写三步操作)- synchronized保证代码块原子性
可见性:一个线程对共享变量的修改,另一个线程能立即看到。
- volatile保证可见性
- synchronized保证可见性(解锁时写回主内存,加锁时从主内存读取)
有序性:程序执行的顺序按照代码的先后顺序执行。
- 编译器和CPU可能对指令进行重排序(在单线程下结果不变,但在多线程下可能出问题)
- volatile的内存屏障禁止特定的重排序
2.3 指令重排序的危险
// 单线程下,JVM保证结果正确,但实际执行顺序可能是 2→1
int a = 1; // 指令1
int b = 2; // 指令2(可能被重排到指令1之前)单线程看不到重排序的影响,但多线程就不一样了:
// 双重检查锁(DCL)的错误实现
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()这行代码底层分三步:
- 分配内存空间
- 初始化对象(调用构造函数)
- 将引用指向内存空间
步骤2和3可能被重排序(3在2之前)。如果线程A执行了1和3,但还没执行2,此时线程B看到instance不为null,直接返回了一个未初始化完成的对象。
修复方案是给instance加volatile:
private static volatile Singleton instance; // volatile禁止重排序三、happens-before的8条规则
规则1:程序顺序规则
同一个线程中,前面的操作happens-before后面的操作。
// 线程内:a的赋值 happens-before b的赋值
int a = 1; // 操作1
int b = a + 1; // 操作2,一定能看到a=1这是最基本的,保证单线程内的程序语义正确。
规则2:监视器锁规则
一个锁的解锁操作happens-before后续对同一个锁的加锁操作。
// 线程A
synchronized (lock) {
x = 1; // 解锁前,x=1写入主内存
}
// 线程B(在线程A解锁之后加锁)
synchronized (lock) {
System.out.println(x); // 一定能看到 x=1
}规则3:volatile变量规则
对一个volatile变量的写操作happens-before后续对该变量的读操作。
volatile boolean flag = false;
// 线程A
result = compute(); // 1
flag = true; // 2. 写volatile
// 线程B
if (flag) { // 3. 读volatile(happens-after 写volatile)
use(result); // 4. 一定能看到result(因为1 hb 2 hb 3 hb 4,传递性)
}这里有个关键点:volatile的可见性不只是flag本身,而是volatile写之前的所有操作,对volatile读之后的所有操作都可见(内存屏障的作用)。
规则4:线程启动规则
Thread.start()操作happens-before该线程的所有操作。
int x = 0;
Thread t = new Thread(() -> {
System.out.println(x); // 一定能看到 x=10
});
x = 10;
t.start(); // start() happens-before 线程t中的所有操作规则5:线程终止规则
线程中的所有操作happens-before对该线程的Thread.join()返回。
int result = 0;
Thread t = new Thread(() -> {
result = heavyCompute(); // 线程t中的操作
});
t.start();
t.join(); // join()等待t终止,t的所有操作 happens-before join()返回
System.out.println(result); // 一定能看到正确结果规则6:线程中断规则
对线程interrupt()的调用happens-before被中断线程检测到中断的代码(InterruptedException或isInterrupted())。
Thread t = new Thread(() -> {
try {
Thread.sleep(1000); // 等待中断
} catch (InterruptedException e) {
// 这里一定能看到中断前主线程的所有操作
System.out.println("被中断了");
}
});
t.start();
// 主线程操作...
t.interrupt(); // interrupt() happens-before InterruptedException规则7:对象终结规则
一个对象的构造函数执行完毕happens-before该对象的finalize()方法开始执行。
这条规则保证了finalize()能看到对象完整初始化后的状态。(finalize已被废弃,了解即可)
规则8:传递性规则
如果A happens-before B,B happens-before C,则A happens-before C。
这是最强大的规则,把以上7条规则组合起来,构成了复杂场景下的可见性保证链。
四、标准答案 + 代码验证
4.1 验证volatile的happens-before
public class VolatileHappensBefore {
private int value = 0;
private volatile boolean ready = false; // volatile变量
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10000; i++) {
runTest();
}
}
static void runTest() throws InterruptedException {
VolatileHappensBefore test = new VolatileHappensBefore();
Thread writer = new Thread(() -> {
test.value = 42; // 在volatile写之前的操作
test.ready = true; // volatile写
});
Thread reader = new Thread(() -> {
while (!test.ready) { // volatile读
Thread.yield();
}
// 由于happens-before:volatile写 hb volatile读
// 且:value=42 hb volatile写(程序顺序规则)
// 所以:value=42 hb 这里(传递性)
// value一定是42,不可能是0
assert test.value == 42 : "value应该是42,但实际是" + test.value;
});
writer.start();
reader.start();
writer.join();
reader.join();
}
}4.2 验证没有volatile的问题
public class WithoutVolatileDemo {
private static boolean stop = false; // 没有volatile!
public static void main(String[] args) throws InterruptedException {
Thread t = new Thread(() -> {
int i = 0;
while (!stop) { // 可能永远看不到stop=true!
i++;
}
System.out.println("线程停止,i=" + i);
});
t.start();
Thread.sleep(100);
stop = true; // 修改可能不会被线程t看到
System.out.println("已设置stop=true");
t.join(1000);
if (t.isAlive()) {
System.out.println("线程t仍在运行!stop的修改没有被感知到");
t.interrupt();
}
}
}4.3 双重检查锁的正确实现
public class SafeSingleton {
// volatile 禁止 new Singleton() 的指令重排序
private static volatile SafeSingleton instance;
private SafeSingleton() {}
public static SafeSingleton getInstance() {
if (instance == null) { // 第一次检查(无锁,高性能)
synchronized (SafeSingleton.class) {
if (instance == null) { // 第二次检查(加锁,安全)
instance = new SafeSingleton(); // volatile确保顺序正确
}
}
}
return instance;
}
// 更推荐的方式:静态内部类(利用类加载机制保证线程安全)
static class LazyHolder {
static final SafeSingleton INSTANCE = new SafeSingleton();
}
public static SafeSingleton getInstanceV2() {
return LazyHolder.INSTANCE; // 类加载是线程安全的,天然单例
}
}五、面试官追问
追问1:volatile能保证i++的原子性吗?
我的回答:不能。i++在JVM层面是三个操作:从工作内存读取i的值,加1,写回工作内存。volatile只保证每次读写都从主内存操作(可见性),但不能保证这三个操作的整体原子性。两个线程同时执行i++,可能都读到同一个值,各自加1后写回,导致只增加了1而不是2。要保证原子性,需要使用AtomicInteger或synchronized。
追问2:内存屏障是什么,volatile是如何用内存屏障实现禁止重排序的?
我的回答:内存屏障是CPU指令,强制刷新缓存和禁止特定的指令重排序。Java规范中定义了四种内存屏障:LoadLoad(禁止后面的读重排到前面的读之前)、StoreStore(禁止后面的写重排到前面的写之前)、LoadStore(禁止后面的写重排到前面的读之前)、StoreLoad(最强屏障,同时具备前三种效果)。volatile写操作之后会插入StoreLoad屏障,volatile读操作之前会插入LoadLoad屏障,这样就确保了volatile写对后续volatile读的可见性,以及禁止了volatile写前后的操作被重排序。
追问3:long和double的操作为什么不是原子的?
我的回答:JMM规范规定基本类型(除long和double)的读写是原子的,但64位的long和double,在32位JVM上,读写可能分成两个32位操作。如果线程A正在写一个long的高32位,线程B可能读到"半个"新值加"半个"旧值,得到一个完全错误的数值。JDK5之后64位JVM上这个问题基本不存在了,但JMM规范层面为了兼容,对long/double特殊处理。实际上现代64位JVM已经保证long/double的原子性,但如果想要在规范层面有保证,加volatile。
六、踩坑实录
坑一:单例模式没加volatile,并发下拿到了未初始化的对象
早期项目里实现了一个配置管理器的单例,双重检查锁但没加volatile。压测时偶发性地出现NPE,因为有线程拿到了还没完成初始化的配置管理器对象。这个bug极难复现(依赖CPU的重排序时机),在测试环境从未出现,只在生产压测时偶发。
教训:DCL模式中instance字段必须加volatile,这不是可选的。
坑二:误以为synchronized可以保证所有操作的可见性
有个同事写了这样的代码:线程A在一个synchronized块里修改了字段x,线程B在另一个不相关的synchronized块里读取字段x,以为synchronized能保证可见性。
实际上,synchronized的可见性只在同一个锁的释放-获取之间有保证。不同锁之间没有happens-before关系,x的修改不保证对线程B可见。
坑三:发布对象时的可见性问题
// 危险的发布方式
public class HolderContainer {
public Holder holder;
}
public class Holder {
int n;
public Holder(int n) { this.n = n; }
public void assertSanity() {
if (n != n) throw new AssertionError("数据损坏!");
}
}
// 在主线程中
HolderContainer container = new HolderContainer();
container.holder = new Holder(42); // 1. 不安全发布
// 在另一个线程中读取
container.holder.assertSanity(); // 可能看到部分初始化的Holder!解决方案:通过volatile、final、静态初始化器,或者在同步块中发布对象,确保对象完整初始化后才对其他线程可见。
七、总结
Java内存模型和happens-before是Java并发编程的理论基础。把这8条规则记清楚:
- 程序顺序规则:同线程内前操作hb后操作
- 监视器锁规则:解锁hb后续加锁
- volatile规则:volatile写hb后续volatile读
- 线程启动规则:start() hb 线程内所有操作
- 线程终止规则:线程内所有操作hb join()返回
- 线程中断规则:interrupt() hb 检测到中断
- 对象终结规则:构造完成hb finalize()开始
- 传递性规则:A hb B,B hb C,则A hb C
实际工作中最重要的是规则2、3、8(传递性)。能用happens-before解释volatile为什么能解决DCL问题,基本就达到了大厂面试的要求。
