【Java 内存模型】— volatile 的内存语义

volatile 的特性

关键字 volatile 可以说是 Java 虚拟机提供的最轻量级的同步机制。

当一个变量被定义为 volatile 之后,它将具备两种特性,可见性和禁止指令重排。

可见性

这里的“可见性”是指当一个线程修改了 volatile 变量,其他线程是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成

禁止重排序

使用 volatile 的第二个特性是禁止指令排序优化,普通变量仅仅会在保证在该方法执行过程中所有赋值结果的地方都能获到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。因为在一个线程的方法执行过程中无法感知到这点,这也就是 JMM 中描述的“线程内表现为串行的语义”。

volatile 与 happen-before 关系

Java 内存模型 一文中提到 happen-before(先行发生) 原则,其中有一项是关于 volatile 的:

volatile 变量规则(Volatile Variable Rule):对于 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”是指时间上的先后顺序。

看下面使用 volatile 的示例代码:


class VolatileDemo {
    int a = 0;
    volatile boolean flag = false;
    
    public void write() {
        a = 1;              // 操作 1
        flag = true;        // 操作 2
    }
    
    public void read() {
        if (flag) {         // 操作 3
            int i = a;      // 操作 4
        }
    }
}

假设线程 A 执行 write 方法,线程 B 执行 read 方法,根据先行发生原则可以做以下推断:

  • 根据程序次序规则可知:1 先行发生于 2;3 先行发生于 4
  • 根据volatile 变量规则可知:2 先行发生于 3
  • 根据传递性可知:1 先行发生于 4

这里 A 线程写一个 volatile 变量,B 线程同时读一个 volatile 变量。在 A 线程写完这个 volatile 变量,立即变得对 B 线程可见。

volatile 写-读的内存语义

volatile 写的内存语义如下:

当写一个 volatile 变量时,JVM 会把该线程对应的本地内存中的共享变量的值刷回到主内存。

volatile 读的内存语义:

当读一个 volatile 变量时,JVM 会把当前线程工作内存中的变量值置为无效,然后从主内存中读取该变量。

volatile 内存语义的实现

重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JVM 会分别限制这两种类型的重排序。

限制编译器重排序

是否能重排序 第二个操作
第一个操作 普通读/写 volatile 读 volatile 写
普通读/写 NO
volatile 读 NO NO NO
volatile 写 NO NO

限制处理器重排序

对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM 采取如下保守策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作后面插入一个 LoadStore 屏障。

DCL 问题和 volatile 的增强

在单例模式中,有这么一种称为“双重检查锁定(Double-Checked Locking)”的实现方法:


public class DCLDemo {
    private volatile static Instance instance;
    
    public static Instance getInstance() {
        if (instance == null) {
            synchronized(DCLDemo.class) {
                if (instance == null) {
                    instance = new Instance();
                }
            }
        }
        return instance;
    }
}

在旧的内存模型中,上面代码是有问题的,根源在于instance = new Instance()这句代码,这行代码可以分解一下三步伪代码:


memory = allocate();    // 1. 分配对象的内存地址
ctorInstance(memory);   // 2. 初始化对象
instance = memory;      // 3. 设置 instance 指向刚分配的内存地址

上面三步中2、3步可能会重排序,因为1、2存在数据依赖性,2、3没有,那重排序后的伪代码如下:


memory = allocate();    // 1. 分配对象的内存地址
instance = memory;      // 3. 设置 instance 指向刚分配的内存地址,这时候对象还没初始化
ctorInstance(memory);   // 2. 初始化对象

假如线程 A 执行到3,然后线程 B 执行到第一个if (instance == null),由于 volatile 具有内存可见性,所以这时候线程 B 是知道instance != null的,于是就直接返回了还没有初始化的对象。

那为什么这里使用 volatile 关键字还是会重排序?

在 JSR-133 之前的旧 Java 内存模型中,虽然不允许 volatile 变量之间的重排序,但是允许 volatile 变量和普通变量之间的重排序。

于是 JSR-133 专家组决定增强 volatile 的内存语义:

严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写-读和锁的释放-获取具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语义,这种重排序就会被编译器重排序规则处理器内存屏障插入策略禁止。

volatile 总结

说了这么多,是时候来波总结了:

  • volatile 禁止重排序:编译器重排序和处理器插入内存屏障。
  • 保证内存可见性:工作内存写完立马刷回主内存,其它线程将工作内存该变量置为无效,而从主内存获取。

由于 volatile 只能保证可见性,在使用时需要满足一下两条规则:

  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
  • 变量不需要与其他的状态变量共同参与不变约束
posted @ 2022-06-08 18:16  Tailife  阅读(58)  评论(0编辑  收藏  举报