Java 线程同步-06:volatile 内存屏障

0 阅读7分钟

Java 内存模型

Java内存模型规定在多线程情况下,线程操作主内存(类比内存条)变量,需要通过线程独有的工作内存(类比CPU高速缓存)拷贝主内存变量副本来进行。此处的所谓内存模型要区别于通常所说的虚拟机堆模型,可以参考如下:

Pasted image 20260226211915.png

需要注意的是如果是一个大对象,并不会从主内存完全拷贝一份,而是这个被访问对象引用的对象、对象中的字段可能存在拷贝。线程独有的工作内存和进程内存(主内存)之间通过几种原子操作来实现,如下所示:

Pasted image 20260226212024.png

volatile 禁止指令重排序

volatile变量的禁止指令重排序是基于内存屏障(Memory Barrier)实现。内存屏障又称内存栅栏,是一个CPU指令,内存屏障会导致JVM无法优化屏障内的指令集。

  • volatile变量的写指令后会加入写屏障,对共享变量的改动,都同步到主存当中
  • volatile变量的读指令前会加入读屏障,对共享变量的读取,加载的是主存中最新数据

屏障位置对比如下:

// 左:volatile 写操作     右:volatile 读操作
public class BarrierPositionComparison {
    // 写操作:屏障在写之后         读操作:屏障在读之前
    // 普通写                    // LoadLoad屏障
    // volatile 写              // LoadStore屏障
    // StoreStore屏障           // volatile 读
    // StoreLoad屏障            // 后续操作
}

在JVM主要存在如下四种内存屏障

// JMM 定义的四种屏障
public class MemoryBarriers {
    // 1. LoadLoad 屏障
    // 确保 Load1 数据的装载先于 Load2 及其后所有装载指令
    // 序列:Load1 → LoadLoad → Load2
    
    // 2. StoreStore 屏障  
    // 确保 Store1 及其前面的写操作数据刷新到内存先于 Store2
    // 序列:Store1 → StoreStore → Store2
    
    // 3. LoadStore 屏障
    // 确保 Load1 的数据装载先于 Store2 及其后所有存储指令
    // 序列:Load1 → LoadStore → Store2
    
    // 4. StoreLoad 屏障(全能屏障,开销最大)
    // 确保 Store1 及其前面的写操作对其他处理器可见先于 Load2
    // 序列:Store1 → StoreLoad → Load2
}

涉及到的屏障插入伪代码可以参考如下:

// 为 volatile 读插入屏障
void insert_barriers_for_volatile_read() {
    // volatile 读操作前插入:
    OrderAccess::loadload();    // LoadLoad 屏障
    OrderAccess::loadstore();   // LoadStore 屏障
    
    // 然后执行实际的读操作
    // T value = *address;
    
    // 屏障位置:屏障 → 读操作
}

// 为 volatile 写插入屏障
void insert_barriers_for_volatile_write() {
    // 先执行实际的写操作
    // *address = value;
    
    // volatile 写操作后插入:
    OrderAccess::storestore();  // StoreStore 屏障
    OrderAccess::storeload();   // StoreLoad 屏障
    
    // 屏障位置:写操作 → 屏障
}

从上面这个伪代码大致可以得出如下内存屏障定位图:

volatile 写操作的时间线:
[普通写][volatile写] → StoreStore屏障 → StoreLoad屏障 → [后续操作]
                     ↑                   ↑
                     写操作完成          屏障在写之后

volatile 读操作的时间线:
[LoadLoad屏障][LoadStore屏障][volatile读][后续操作]
        ↑                ↑
        屏障在读之前       屏障在读之前

在不同的CPU架构中,内存屏障对应的指令也会有所不同,这里仅给出参考。

x86/x64 架构(强内存模型):

// x86 的内存屏障实现相对简单
public class X86BarrierInstructions {
    // LoadLoad 屏障:
    // 在 x86 上,大多数情况下是 no-op(无操作)
    // 因为 x86 保证读操作不会重排序(Load-Load 顺序)
    // 对应汇编:通常什么都不需要
    
    // StoreStore 屏障:
    // 在 x86 上,通常也是 no-op
    // 因为 x86 保证写操作不会重排序(Store-Store 顺序)
    // 对应汇编:通常什么都不需要
    
    // LoadStore 屏障:
    // 在 x86 上,通常也是 no-op
    // 对应汇编:通常什么都不需要
    
    // StoreLoad 屏障:
    // 这是 x86 上唯一需要显式指令的屏障!
    // 对应汇编:mfence 指令 或 lock 前缀
    // mfence 指令:内存屏障指令
    // lock 前缀:锁定总线,更重量级
    
    // 总结:x86 上大部分屏障是"无操作",只有 StoreLoad 需要 mfence
}

ARM/POWER 架构(弱内存模型):

// ARM 需要显式的内存屏障指令
public class ARMBarrierInstructions {
    // ARM 的所有屏障都需要显式指令!
    
    // LoadLoad 屏障:
    // 对应指令:dmb ishld  (数据内存屏障,内部共享域,仅对加载有效)
    // 或:dmb ish  (更强的屏障)
    
    // StoreStore 屏障:
    // 对应指令:dmb ishst  (数据内存屏障,仅对存储有效)
    // 或:dmb ish
    
    // LoadStore 屏障:
    // 对应指令:dmb ish  (全能屏障)
    // ARM 没有单独的 LoadStore 屏障指令
    
    // StoreLoad 屏障:
    // 对应指令:dmb ish  (全能屏障)
    
    // 注意:ARM 的 dmb ish 是全能屏障,对应所有四种屏障
    // 但 JVM 会根据情况选择更精确的屏障类型
}

单例模式懒汉变量加 volatile

问题代码:

// 没有 volatile 的双重检查锁
public class UnsafeSingleton {
    private static Singleton instance;
    
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (UnsafeSingleton.class) {
                if (instance == null) {
                    // 这里可能被重排序!
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

分解 new Singleton():

// instance = new Singleton(); 分解为:
memory = allocate();    // 1. 分配内存
ctorInstance(memory);   // 2. 调用构造函数(初始化字段)
instance = memory;      // 3. 赋值给引用

// 可能被重排序为:1 → 3 → 2
// 此时 instance != null,但对象还没初始化!

volatile 如何解决:

private static volatile Singleton instance;

// 有 volatile 时:
instance = new Singleton();
// 编译为:
memory = allocate();
ctorInstance(memory);   // 初始化
// StoreStore 屏障(禁止步骤3重排序到步骤2前面)
instance = memory;      // volatile 写
// StoreLoad 屏障(保证立即对其他线程可见)

// 其他线程:
if (instance != null) {  // volatile 读
    // LoadLoad 屏障
    // LoadStore 屏障
    instance.method();   // 此时对象一定初始化完成
}

注意点

  1. volatile变量是一种稍弱的同步机制在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此volatile变量是一种比synchronized关键字更轻量级的同步机制。
  2. 从内存可见性的角度看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。
  3. 在代码中如果过度依赖volatile变量来控制状态的可见性,通常会比使用锁的代码更脆弱,也更难以理解。仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用它。一般来说,用同步机制会更安全些。
  4. 加锁机制(即同步机制)既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性,原因是声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。

当且仅当满足以下所有条件时,才应该使用volatile变量:

  1. 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  2. 该变量没有包含在具有其他变量的不变式中。

下面分别给出符合和不符合volatile变量应用条件的例子:

符合条件的例子

  1. 对变量的写入操作不依赖变量的当前值
public class VolatileExample {  
    private volatile boolean flag = false;  
​  
    public void setFlag() {  
        flag = true;  
    }  
​  
    public boolean isFlag() {  
        return flag;  
    }  
}

在这个例子中,flag变量的写入操作不依赖于当前值,只是简单地将其设置为true。这种情况下适合使用volatile修饰。

不符合条件的例子

  1. 变量的写入操作依赖变量的当前值
public class NonVolatileExample {  
    private volatile int count = 0;  
​  
    public void increment() {  
        count++;  
    }  
​  
    public int getCount() {  
        return count;  
    }  
}

在这个例子中,count变量的写入操作依赖于当前值的增加。多个线程同时调用increment方法可能会导致竞态条件,因此不适合使用volatile修饰。

  1. 变量包含在具有其他变量的不变式中
public class InvariantExample {  
    private volatile int x = 0;  
    private int y = 0;  
​  
    public void updateX() {  
        x = y + 1;  
    }  
​  
    public int getX() {  
        return x;  
    }  
​  
    public void updateY() {  
        y = 10;  
    }  
​  
    public int getY() {  
        return y;  
    }  
}

在这个例子中,x变量的更新依赖于y变量的值,两者存在关联。如果使用volatile修饰x,可能会导致多线程环境下xy之间的关系出现问题,不适合使用volatile修饰x