重排序,可见性,内存屏障和Happens-Before

1. 重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

请看下面的一个例子:

public static void main(String[] args) throws InterruptedException {
    Thread one = new Thread(new Runnable() {
        public void run() {
            a = 1;    // 1
            x = b;    // 2
        }
    });

    Thread other = new Thread(new Runnable() {
        public void run() {
            b = 1;    // 3
            y = a;    // 4
        }
    });
    one.start();other.start();
    one.join();other.join();
    System.out.println(“(” + x + “,” + y + “)”);
}

这段代码的运行结果可能为(1,0)、(0,1)或(1,1),因为线程one可以在线程two开始之前就执行完了,也有可能反之,甚至有可能二者的指令是同时或交替执行的。

甚至这段代码的执行结果也可能是(0,0)。因为可能执行的顺序为 2341,程序执行时发生了重排序。

2. 可见性

除了指令重排序带来的执行结果的不确定性,多线程间内存的可见性也会造成程序执行结果的不确定性。

我们知道,每一个线程都拥有自己的私有内存cache,因此两个线程一前一后写-读某个变量时,有可能写和读的都是自己的私有内存cache,从而造成了数据不正确的情况。下图展示了两个CPU cache和主内存之间的关系:


重排序,可见性,内存屏障和Happens-Before_第1张图片
CPU Cache.PNG

如图所示,CPU执行load读数据时,把读请求放到LoadBuffer,这样就不用等待其它CPU响应,先进行下面操作,稍后再处理这个读请求的结果。在执行store写数据时,把数据写到StoreBuffer中,待到某个适合的时间点,把StoreBuffer的数据刷到主存中。

由于StoreBuffer的存在,CPU在写数据时,真实数据并不会立即表现到内存中,所以对于其它CPU是不可见的。同样的道理,LoadBuffer中的请求也无法拿到其它CPU设置的最新数据。

可是这样不能保证CPU在load的时候可以拿到最新数据,因此Java提供了了volatile关键字来保证可见性。

3. volatile和内存屏障

通过给变量添加volatile修饰符,我们就可以保证一个线程的写入一定对所有其他读线程可见,即其他读线程一定能给读取到最新的结果。

一个最简单的想法是,所有针对volatile变量的写入,都会立即刷新到主内存;所有的针对volatile变量的读取,也都会从主内存中读取,这样就保证了可见性。而事实上,JVM是通过CPU提供的内存屏障指令来实现volatile语义的。

内存屏障

内存屏障是组CPU指令。它的作用主要有两个:禁止重排序和写主内存。

  • 插入一个内存屏障,相当于告诉CPU和编译器出现在这个内存屏障之前的命令必须先执行,然后再执行这个内存屏障之后的其他命令。
  • 内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到主存,这样任何试图读取该数据的线程将得到最新值。

内存屏障分为以下4种:

  • LoadLoad屏障(Load1,LoadLoad, Load2):确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。通常能执行预加载指令或/和支持乱序处理的处理器中需要显式声明Loadload屏障,因为在这些处理器中正在等待的加载指令能够绕过正在等待存储的指令。 而对于总是能保证处理顺序的处理器上,设置该屏障相当于无操作。
  • LoadStore屏障(Load1,LoadStore, Store2):确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。
  • StoreStore屏障(Store1,StoreStore,Store2):确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。通常情况下,如果处理器不能保证从写缓冲或/和缓存向其它处理器和主存中按顺序刷新数据,那么它需要使用StoreStore屏障。
  • StoreLoad屏障(Store1,StoreLoad,Load2):确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。StoreLoad屏障可以防止一个后续的load指令 不正确的使用了Store1的数据,而不是另一个处理器在相同内存位置写入一个新数据。

StoreLoad屏障是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障。执行该屏障的开销会有昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中。

再谈volatile

因此,JVM就是通过内存屏障来实现volatile的内存语义的。下面是JVM针对volatile变量插入内存屏障的策略:

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

这里说的内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。编译器可以根据具体情况省略不必要的屏障。

4. Happens-Before规则

前面我们说到,重排序和内存可见性问题严重影响着程序的执行结果。通过volatile变量保证可见性是一种方式。JVM为了确保程序执行结果的确定性,提出了一系列Happens-Before规则。

在介绍这些Happens-Before规则前,先解释以下Happens-Before的语义:

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,并且第一个操作的执行顺序排在第二个操作之前。

因此,Happens-Before的语义包含了两个最重要的保证:

1. 如果A操作happens-before于B操作,那么A操作一定在B操作之前执行。即禁止了重排序。
2. 如果A操作happens-before于B操作,那么A操作的执行结果一定对B可见。即保证了内存可见性。

Happens-Before规则

下面是JVM严格保证的一些Happens-Before规则:

1. 程序顺序规则:在一个线程内部,按照程序代码的书写顺序,书写在前面的代码操作Happens-Before书写在后面的代码操作。两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序不非法(也就是说,JVM允许这种重排序)。JVM如果能发现多个操作之间有先后依赖关系,则不允许对这些操作进行重排序。

2. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。众所周知,synchronized的一个功能就是使多个线程串行执行,但根据happens-before语义,它们拥有第二个功能 —— 保证变量的可见性。因此,一个新线程获得锁之后,能够读到上一个释放锁的线程对于变量的修改。

3. volatile变量规则:对一个volatile变量的写操作及这个写操作之前的所有操作Happens-Before对这个变量的读操作及这个读操作之后的所有操作。在volatile变量写操作发生后,A线程会把volatile变量和书写在它之前的那些操作的执行结果一起同步到主内存中。最后,当B线程读取volatile变量时,B线程会使自己的CPU缓存失效,重新从主内存读取所需变量的值。

4. 线程启动规则:Thread对象的start方法及书写在start方法前面的代码操作Happens-Before此线程的每一个动作。调用start方法时,会将start方法之前所有操作的结果同步到主内存中,新线程创建好后,需要从主内存获取数据。这样在start方法调用之前的所有操作结果对于新创建的线程都是可见的。

6. 线程终止规则:线程中的任何操作都Happens-Before其它线程检测到该线程已经结束。假设两个线程s、t,在线程s中调用t.join()方法,则线程s会被挂起,等待t线程运行结束才能恢复执行。当t.join()成功返回时,s线程就知道t线程已经结束了。所以根据本条原则,在t线程中对共享变量的修改,对s线程都是可见的。类似的还有Thread.isAlive方法也可以检测到一个线程是否结束。

7. 终结器规则:一个对象的构造函数执行结束Happens-Before它的finalize()方法的开始。“结束”和“开始”表明在时间上,一个对象的构造函数必须在它的finalize()方法调用时执行完。根据这条原则,可以确保在对象的finalize方法执行时,该对象的所有field字段值都是可见的。

8. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

5. Happens-Before规则的意义

我们已经知道,导致多线程间可见性问题的两个“罪魁祸首”是重排序和CPU缓存。重排序和CPU高速缓存有利于计算机性能的提高,但却对多CPU处理的一致性带来了影响。为了解决这个矛盾,我们通过JVM保证的Happens-Before规则,能够确保程序按照我们的预期执行,从而消除了重排序和CPU缓存带来的负面影响,保证了多线程程序的正确性。

参考文章

  • 【细谈Java并发】内存模型之volatile
  • https://zhuanlan.zhihu.com/p/43526907
  • https://segmentfault.com/a/1190000011458941

你可能感兴趣的:(重排序,可见性,内存屏障和Happens-Before)