Skip to content

可见性、有序性、原子性三体问题

指令重排序

  1. 编译器优化指令重排序

Java 源码转化为字节码或本地机器码时,可能会进行指令重排序提高执行效率,但只保证单线程语义

  • 消除冗余加载
java
// 原始代码
int a = obj.x;
// ...其他不依赖a的操作
int b = obj.x; // 可能会被优化成直接复用寄存器中的a值
// 优化后逻辑
int tmp = obj.x;
int a = tmp;
// ...中间代码
int b = tmp;
  • 指令调度优化
java
// 原始顺序
int x = 1;
int y = 2; // 无数据依赖的两个写操作可能被交换
// 可能优化后的顺序
int y = 2;
int x = 1;
  • 循环展开
java
if (instance == null) {                // 第一次检查
    synchronized (Singleton.class) {
        if (instance == null) {        // 第二次检查
            instance = new Singleton(); // 这里可能被拆分为:1.分配内存 2.初始化 3.赋值引用(步骤2和3可能被重排)
        }
    }
}
  1. 处理器乱序执行

Java 中通过 volatile 写操作插入 StoreStore + StoreLoad 屏障

  1. 内存系统重排序

JDK9 引入 VarHandle 来替代 Unsafe 提供更细粒度的内存顺序控制

可见性

  • 当写一个 volatile 变量时, JMM 会把该线程对应的本地内存中的共享变量值 立即刷新到主内存
  • 当读一个 volatile 变量时, JMM 会把该线程对应的本地内存设置为无效,重新回到主内存中读取最新共享变量

有序性

内存屏障

内存屏障,分为 Store Barrier (写屏障) 和 Load Barrier (读屏障)

  • 读屏障:在读指令之前插入读屏障,让工作内存或CPU缓存中的数据失效,重新回到主内存中读取最新数据
  • 写屏障:在写指令之后插入写屏障,强制把写缓冲中的数据刷新到主内存中
屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2Load1 执行完之后 Load2 再执行
StoreStoreStore1;StoreStore;Store2Store1 执行完之后, Store2 再执行
LoadStoreLoad1;LoadStore;Store1先读完再写
StoreLoadStore1;StoreLoad;Load1先写完再读
  • 每一个 volatile 写操作前面插入一个 StoreStore 屏障
  • 每一个 volatile 写操作后面插入一个 StoreLoad 屏障
  • 每一个 volatile 读操作之前插入一个 LoadLoad 屏障
  • 每一个 volatile 读操作之后插入一个 LoadStore 屏障

如何保证有序性?

  • 对于编译器的重排序, JMM 会根据重排序的规则,禁止特定类型的编译器重排序
  • 对于处理器的重排序, JAVA 编译器在生成指令序列的适当位置,插入内存屏障指令,来禁止特定类型的处理器重排序

原子性

Volatile 不保证原子性,需要配合原子类来保证原子性

可见性保证 ≠ 原子性保证

java
// Thread-1              Thread-2
读取 i=0 →              读取 i=0
计算 i=1 →              计算 i=1
写回 i=1               写回 i=1
  • 虽然 volatile 保证了写操作对后续读取线程立即可见,但两个线程在 "读取-计算-写入" 过程中仍会产生竞态条件
  • 最终 i 的值可能为 1 ,而非预期的 2

volatile 适用场景

  1. 单一赋值操作
  2. 状态标志位 (如:isRunning
  3. 开销较低的读、写锁策略
java
public  class Counter {
    //当读远多于写时,使用 volatile 保证读取操作的可见性,利用 synchronized 保证复合操作的原子性
    private volatile int value;
    
    public int getValue() {
        return value;
    }
    
    public synchronized int increment() {
        return value++;
    }
}
  1. DSL (Double-Checked-Locking) 单例
java
public class SafeDoubleCheckSingleton {

    // ⚡️ 关键1:使用 volatile 修饰实例变量
    private volatile static SafeDoubleCheckSingleton singleton;

    private SafeDoubleCheckSingleton() {
        //防止反射破坏单例
        if (singleton != null) {
            throw new RuntimeException("禁止通过反射创建实例")
        }
    }

    public static SafeDoubleCheckSingleton getInstance() {
        // 第一次检查非同步,减少锁竞争
        if (singleton == null) {
            synchronized (SafeDoubleCheckSingleton.class) {
                // 第二次检查同步,保证线程安全
                if (singleton == null) {
                    singleton = new SafeDoubleCheckSingleton();
                }
            }
        }
        return singleton;
    }
    
}

总结:

  • volatile 写之前的操作,都禁止重排序到 volatile 之后 ----- 保证所有前置操作对读线程可见
  • volatile 读之后的操作,都禁止重排序到 volatile 之前 ----- 保证后续操作基于最新数据执行
  • volatile 写之后 volatile 读,禁止重排序 ----- 防止线程读取到正在写入中的中间状态

实战应用面试题

DSL 单例模式为什么必须使用 volatile ?

java
// 分解为 1.分配内存 2.初始化对象 3.赋值引用
instance = new Singleton();
  • 若发生指令重排,其他线程可能获取到未完成初始化的对象

是否可用 volatile 修饰数组?

可以修饰数组引用,但不保证数组元素的可见性

happens-before 八大原则

JDK5 重写了 JAVA 内存模型(JMM),定义了 Happens-Before 规则:

尽管编译器/CPU 会进行重排序,但也必须保证符合规则的可见性和顺序性

  1. 单线程规则

同一线程内,操作 ② 的最终执行顺序一定在操作 ① 之后

java
int a = 1;         // 操作 ①
int b = a + 2;     // 操作 ②
  1. 管程锁定规则

JVM 在释放锁时强制刷新写缓冲区的数据到主内存;获取锁时本地缓存失效

java
synchronized(lock) {  // A线程释放锁 
    x = 42;
}
// 其他线程获取同一锁时
synchronized(lock) {  // B线程获得锁
    int tmp = x;      // 必定看到x=42
}
  1. volatile 变量规则

volatile 变量的可见性保证

java
volatile boolean flag = false;

// 线程A
void write() {
    flag = true;     // volatile写
}

// 线程B
void read() {
    if (flag) {      // volatile读,必定看到线程A的写入
        // 安全操作
    }
}
  1. 线程启动规则
java
Thread t = new Thread(() -> {
    System.out.println(data); // 能正确看到主线程设置的初始值
});
// 主线程修改共享数据
data = initData;     
t.start();           // start()调用 happens-before 子线程的任何操作
  1. 线程终止规则

join() 确保子线程修改对主线程可见

java
Thread t = new Thread(() -> {
    x = 42; 
});
t.start();
t.join();            // 主线程在此等待
System.out.println(x); // 确保看到x=42

注意join() 不会自动清除中断状态,异常处理要清理中断状态,以免影响后续代码

java
try {
    t.join();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();  // 恢复中断标志
    // 处理中断逻辑
}
  1. 中断规则
java
Thread t = new Thread(() -> {
    while (!Thread.currentThread().isInterrupted()) {
        // 执行任务
    }
    // 正确处理中断
});

t.start();
t.interrupt();  // happens-before 中断状态被检测到
  1. 终结器规则

finalizer 本身设计存在问题,这条规则没多大实际意义

  1. 传递性规则

借助 volatile 变量建立跨线程的 happens-before 链,是解决复杂可见性问题的核心

java
// 若 A happens-before B 且 B happens-before C,则 A happens-before C
volatile int x = 0;
int y = 0;

// 线程A
x = 1;              // volatile写

// 线程B
if(x == 1) {        // volatile读,建立happens-before
    y = 42;         // 此时写y对线程C可见  
}

// 线程C
if(y == 42) {
    // 保证能看到线程A对x的写入
}