指令重排与内存屏障

什么是指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重排,一般分为以下三种:
源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令
编译器优化的重排:编译器的优化前提是在保证不改变单线程语义的情况下,重新安排语句的执行顺序。
指令并行的重排:如果代码中某些语句之间不存在数据依赖,处理器可以改变语句对应机器指令的顺序。
内存系统的重排:处理器和主内存之间存在一二三级缓存。这些读写缓存的存在,使得程序的加载和存取操作,可能是乱序无章的。

单线程环境里面确保最终执行结果和代码顺序是一致的,多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。在多线程环境,指令重排是必须要考虑指令之间的数据依赖性
比如看下面例子:

public class Demo {

    public void mySort() {
        int x = 0;
        int y = 0;
        x = x + 5;
        y = x * x;
    }
}

按照正常单线程环境,执行顺序是 1 2 3 4。但是在多线程环境下,可能出现以下的顺序:2 1 3 4 ,1 3 2 4上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样,但结果都是一致的
但是指令重排也是有限制的,即不能出现下面的顺序4 3 2 1。因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性
因为步骤 4:需要依赖于 y的申明,以及x的申明,故因为存在数据依赖,无法首先执行。上面搞明白了指令重排,在看看例子:

public class Demo {
    static int a = 0;
    static boolean flag = false;

    public static void method01() {
        a = 1;
        flag = true;
    }

    public static void method02() {
        if (flag) {
            a = a * 1;
            System.out.println("reValue:" + a);
        }
    }

    public static void main(String[] args) {
        Thread threads = new Thread(new Runnable() {
            @Override
            public void run() {
                method01();
            }
        });
        threads.start();


        Thread threads1 = new Thread(new Runnable() {
            @Override
            public void run() {
                method02();
            }
        });
        threads1.start();
    }
}

我们按照正常的顺序,分别调用method01() 和 method02() 那么,最终输出就是 a = 1。但是如果在多线程环境下,因为方法1 和 方法2,他们之间不能存在数据依赖的问题,在强调一下数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

因此原先的顺序可能是:

a = 1;
flag = true;
a = a * 1;
System.out.println("reValue:" + a);

但是在经过处理器,-或者内存系统的重排后,有概率会出现这样的情况

flag = true;
a = a *  1;
System.out.println("reValue:" + a);
a = 1

也就是先执行 flag = true后,另外一个线程马上调用方法2,满足 flag的判断,最终让a *1,结果为0,这样就出现了数据不一致的问题
为什么会出现这个结果:多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。a和flag变量前加了volatile,后这种情况就可以避免。

volatile针对指令重排做了啥
Volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象

首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏:内存屏障分为两种:Load Barrier 和 Store Barrier,读屏障和写屏障。内存屏障有两个作用:
1.阻止屏障两侧的指令重排序;
2.强制把工作内存中的脏数据等写回主内存,让别的线程的工作内存中相应的数据失效。

对于Load Barrier来说,在指令前插入Load Barrier,可以让工作内存中的数据失效,强制从新从主内存加载数据;
对于Store Barrier来说,在指令后插入Store Barrier,能让写入工作内存中的最新数据更新写入主内存,让其他线程可见。

java的内存屏障又进一步分为LoadLoad,StoreStore,LoadStore,StoreLoad四种屏障,实际上也是上述两种的组合,完成一系列的屏障和数据同步功能。
LoadLoad屏障:
对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:
对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作已完成,对其它线程可见。
LoadStore屏障:
对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:
对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入写入操作已完成,对其它线程可见。它的开销是四种屏障中最大的。在大多数的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能

volatile变量的内存屏障策略非常严格保守,非常悲观且毫无安全感的心态:
在每个volatile写操作前插入StoreStore屏障,在写操作后插入StoreLoad屏障;
在每个volatile读操作前插入LoadLoad屏障,在读操作后插入LoadStore屏障;
由于内存屏障的作用,避免了volatile变量和其它指令重排序、线程之间实现了通信,使得volatile表现出了锁的特性。

posted @ 2022-01-10 19:45  *乐途*  阅读(479)  评论(0)    收藏  举报