Skip to content

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

字数: 0 字 时长: 0 分钟

指令重排序

  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) {        // 第二次检查
            // 这里可被拆分为:1.分配内存 2.初始化 3.赋值引用(步骤2和3可能被重排)
            instance = new Singleton(); 
        }
    }
}
  1. 处理器乱序执行

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

  1. 内存系统重排序

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

可见性

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

有序性

对于编译器的重排序, JMM 会根据重排序的规则,禁止特定类型的编译器重排序

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

屏障类型指令示例说明
LoadLoadLoad1;LoadLoad;Load2Load1 执行完之后 Load2 再执行
StoreStoreStore1;StoreStore;Store2Store1 执行完之后, Store2 再执行
LoadStoreLoad1;LoadStore;Store1先读完再写
StoreLoadStore1;StoreLoad;Load1先写完再读

原子性

volatile 关键字不保证原子性,需要配合原子类来保证原子性

可见性保证 ≠ 原子性保证

java
// Thread-1              Thread-2
读取 i=0 →              读取 i=0
计算 i=1 →              计算 i=1
写回 i=1               写回 i=1

虽然 volatile 保证了写操作对后续读操作线程立即可见,但两个线程在 "读取-计算-写入" 过程中仍会产生竞态条件

volatile 经典场景 --- DCL 单例

DCL (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;
    }
    
}

注意

singleton = new SafeDoubleCheckSingleton(); 可以被分解为三个部分

  1. 分配内存
  2. 初始化对象 new SafeDoubleCheckSingleton()
  3. 赋值引用 singleton =

指令重排序可以导致步骤 23 顺序错乱,因此必须使用 volatile 保证有序性

否则,其他线程可能获取 singleton 引用时,是一个未完成初始化的对象

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 变量的可见性保证
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 本身设计存在问题,这条规则没多大实际意义

  2. 传递性规则:若 A happens-before BB happens-before C,则 A happens-before C

java
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的写入
}