指令重排序及可见性问题


小渣渣,如有问题,欢迎指正。

指令重排序

概念

指令重排序,顾名思义,就是对指令的执行顺序重新进行排序。
举个例子

test{
	A();
	B();
	C();
	……
}

加入一个这样的方法,执行过程为A(),B(),C(),而且这3个指令互不影响,相互隔离。

  • 假设,A执行时没有拿到需要的资源,所以A进入了等待,按照顺序执行,B要等A执行完毕之后才会开始执行,而C要等B,后续指令的运行都需要依次等待下去。
  • 再假设,A执行时没有拿到需要的资源,A进入等待,这时由于B、C与A互不影响,相互隔离,B、C不等A执行完毕,直接开始执行,A拿到资源之后再开始执行。
    比较这两种情况,很明显,第二种方式的执行速度远远比第一种快,而在高并发的情况下,拿不到资源往往是经常发生的,以第二种方式来执行对速度的提升超乎想象,这第二种的执行方式就是“指令重排序”。

重排序的情况

  1. 真重排序:编译器、底层硬件(CPU等)、JVM虚拟机等,处于优化加速的目的,按照某种规则进行指令重排序。
  2. 伪重排序:由于缓存同步等问题引发的指令重排序的效果,实际上并没有进行指令重排序。
    缓存同步问题
    假设在程序中先更新了V1,再更新了V2,顺序执行。有两个缓存C1,C2。
  • C1先更新了缓存中的V1,再更新缓存中的V2
  • C1读取V1的值,这时C1的缓存被其它程序填满,把最远使用的V2写回内存
  • C2中的缓存本来存有V1变量,直接读取V2变量
    这种情况的后果:变量更新顺序为先更新V1,再更新V2,但是缓存C2中只有V2的更新值,就发生了类似重排序的现象;这也不光是重排序问题,V1和V2的值都被更新了,但是在C2中V1没有被更新,导致了V1的可见性问题,也可以说是重排序导致了可见性问题。

编译器编译时重排序问题
编译器在编译代码时,不会等待阻塞指令完成,而是先去编译执行其他指令,目的和处理器执行时乱序优化一样,但是效果你上更好,它可以完成更大范围、效果更好的指令乱序优化。

处理器执行时乱序优化
乱序优化,实际上也遵循着一定规则:只要两个指令之间不存在数据依赖,就可以对这两个指令乱序。不必关心数据依赖的精确定义,可以理解为:只要不影响程序单线程、顺序执行的结果,就可以对两个指令重排序。
处理器乱序优化节省了大量的等待时间,提高了处理器的性能。
如果是单核情况下,乱序优化完全没有问题,因为它保证了单线程的执行结果不变。但是在多核时代的多线程并行情况下,不同线程之间共享了数据,就会出现问题。以下是很经典的一段代码:

public class OutofOrderExecution {
    private static int x = 0, y = 0;
    private static int a = 0, b = 0;
    
    public static void main(String[] args)
        throws InterruptedException {
        Thread t1 = new Thread(new Runnable() {
            public void run() {
                a = 1;
                x = b;
            }
        });
        Thread t2 = new Thread(new Runnable() {
            public void run() {
                b = 1;
                y = a;
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println((+ x +,+ y +));
    }
}

以上代码执行结果可能很多

  • t1→t2,x=0,y=1;
  • t2→t1,x=1,y=0;
  • t1、t2并行,a=1,b=1,x=b,y=a,这时,x=1,y=1;

容易想到的就是如上三种结果,但是可以看到程序中每个线程中的两条指令都是互不影响的,所以在执行的过程中,可能会被重排序,导致x = b,b = 1,y = a,a = 1,这时,输出就为x=0,y=0。

可见性

可见性:指某一个变量再被更新的时候,其它所有要用到这个变量或者存有这个变量的地方,都能在使用之前获取到被更新的最新值。其本质是读取了写之前的数据使得更新的数据对读取者不可见。
解决方式:可见性问题的解决不难,一种是在每次修改后,都同步的修改其它地方的值,另一种是在使用时获取最新的修改值。显然后者性能更加优秀。

CPU层面(MESI协议)

CPU层面通过MESI协议来保证重排序问题和数据可见性。
MESI协议是CPU中提供的数据一致性保证,解决了CPU的可见性问题,MESI协议中定义了缓存的四种状态:

  • M(修改, Modified): 本地处理器已经修改缓存行, 即是脏行, 它的内容与内存中的内容不一样. 并且此cache只有本地一个拷贝(专有)。
  • E(专有, Exclusive): 缓存行内容和内存中的一样, 而且其它处理器都没有这行数据。
  • S(共享, Shared): 缓存行内容和内存中的一样, 有可能其它处理器也存在此缓存行的拷贝。
  • I(无效, Invalid): 缓存行失效, 不能使用。

MESI协议示例

  • 在C1修改了V之后,发送一个信号,标记旧值已经失效,并且将新值会写到内存。
  • C1可能会多次进行修改,每次丢改都发送一个信号,C2中的V一直保持着失效的状态。
  • C2在使用V的时候,发现自己的V已经失效了,就重新从内存中加载V获取最新的值。

具体实现:内存屏障

先了解两个指令:
Store:将处理器缓存的数据刷新到内存中。
Load:将内存存储的数据拷贝到处理器的缓存中。

屏障类型 指令示例 作用
LoadLoad Barriers Load1;LoadLoad;Load2 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
StoreStore Barriers Store1;StoreStore;Store2 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
LoadStore Barriers Load1;LoadStore;Store2 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad Barriers Store1;StoreLoad;Load2 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令。同时具备其它三个屏障的效果,也被称为全能屏障,但是开销相对比较贵

不同的CPU架构对内存屏障的实现方式和实现程度不一样。但是不管哪种CPU架构,内存屏障的实现都是针对乱序执行的过程不会触发相关问题而设计的。

x86架构的内存屏障

x86架构使用了MESR协议的变种,没有实现全部的内存屏障,只有SS,LL和SL,对应的内存屏障暂以sfence,lfence,mfence来命名。

SS
SS即StoreStore Barriers,所有在sfence之前的store指令都在sfence之前被执行完毕,发送相应的缓存失效信号,并且把store buffer中的数据刷新到CPU的L1 Cache中;所有在sfence之后的store指令,都在sfence指令执行之后执行。
总结来说就是,禁止sfence前后store指令的重排序跨越sfence,使得sfence之前的内存更新都是可见的。

LL
LL即LoadLoad Barriers,所有在lfence之前的load指令都在lfence之前执行完毕,并且一直等load buffer被CPU读取完毕才会执行lfence之后的load指令。
总结来说就是,禁止lfence指令前后load指令的重排序跨越lfence。

SL
SL即StoreLoad Barriers,综合了SS和LL,使所有在mfence之前的指令都在mfence之前执行完毕,所有mfence之后的指令都在mfence之后执行。
总结来说就是禁止mfence前后的store/load指令的重排序跨越mfence,使得所有mfence之前的操作对之后的操作都是可见的。

编译器层面(volatile和final)

volatile
可见性问题不仅仅存在于CPU和CPU缓存之间,在JVM内存中也有可见性问题,而且JVM中没有MESI协议来保证数据一致性。
JVM中通过volatile标记,解决编译器层面的可见性与重排序问题,volatile关键字作用为取消变量的缓存和重排序。
(注:如果硬件架构本身保证了内存可见性,那么volatile就是个空标记,不会插入相关的内存屏障)
如果不保证,以x86架构为例,JVm对volatile变量的处理如下:

  • 在写volatile变量v之后,插入一个sfence。这样,sfence之前的所有store(包括写v)不会被重排序到sfence之后,sfence之后的所有store不会被重排序到sfence之前,禁用跨sfence的store重排序;且sfence之前修改的值都会被写回缓存,并标记其他CPU中的缓存失效。
  • 在读volatile变量v之前,插入一个lfence。这样,lfence之后的load(包括读v)不会被重排序到lfence之前,lfence之前的load不会被重排序到lfence之后,禁用跨lfence的load重排序;且lfence之后,会首先刷新无效缓存,从而得到最新的修改值,与sfence配合保证内存可见性。

两者的结合使用,保证了变量相关的Heppens-Before关系。

final
final关键字:定义了对象或者变量的不可修改
但是很少有人知道final关键字修饰的时候,JVM在final变量后插入一个sfence,sfence禁用了sfence前后对store的重排序,并且保证了sfence之前的内存更新对sfence之后都是可见的

类的final字段的初始化在<clinit>()方法中完成
其可见性由JVM类加载过程保证
final字段的初始化在<init>()方法中完成
可见性由sfence保证
展开阅读全文

没有更多推荐了,返回首页