volatile是如何保证有序性的?

发布时间 2023-07-02 17:38:37作者: 旅途的痕迹

为什么需要保证有序性?

有如下代码,在int i = a;执行了的情况下,i的值最终会为几?

public class NoVolatileExample {
    int a = 0;
    boolean flag = false;
    public void writer() {
        a = 1;
        flag = true;
    }
    public void reader() {
        if (flag) {
            int i = a;
        }
    }
}

假设现在有线程A首先执行writer()方法,然后线程B执行reader()方法,那么线程A和B执行完毕之后i一定会为1?答案是不一定,根据as-if-serial原则,对与单线程执行程序,如果执行的结果不会改变,运行重排序。那么就可能会出现以下的情况。

  • 线程A 执行flag = true
  • 线程B 执行if (flag)
  • 线程B 执行int i = a;
  • 线程A 执行a = 1;

最终结果 i = 0;和我们认为的执行结果不一样。

内存屏障介绍

为了性能优化,JVM会在不改变数据依赖性的情况下,允许编译器和处理器对指令序列进行重排序,而有序性问题指的就是程序代码执行的顺序与程序员编写程序的顺序不一致,导致程序结果不正确的问题。而加了volatile修饰的共享变量,则通过内存屏障解决了多线程下有序性问题。

内存屏障分为以下四种

volatile 内存语义的实现

下面来看看 JMM 如何实现 volatile 写/读的内存语义。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。下表是 JMM 针对编译器制定的 volatile 重排序规则表。

举例来说,第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。

从上表我们可以看出。

  • 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
  • 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
  • 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略。

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

针对以上代码如何保证有序性?

对flag 添加volatile修饰

public class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
    public void writer() {
        a = 1;  // 1
        flag = true; //2
    }
    public void reader() {
        if (flag) { //3
            int i = a; //4
        }
    }
}


根据 happens-before规则(不了解的可以先去了解一下happens-before原则),这个过程建立的 happens-before 关系可以分为 3 类:
根据程序次序规则,1 happens-before 2;3 happens-before 4。
根据 volatile 规则,2 happens-before 3。
根据 happens-before 的传递性规则,1 happens-before 4。
所有对于线程A对a的写入(a=1),b线程在执行4的时候是可见的,最终结果i=1.