java从源代码到指令序列的重排序

认识重排序

在执行程序的时侯,为了提高性能,编译器和处理器常常会对指令做重排序,重排序分为三种类型:

  1. 编译器重排序
    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  2. 指令级并行重排序。
    如果不存在数据依赖性,cpu可以改变语句对应的机器指令的执行顺序,将多条指令重叠执行。
  3. 内存系统重排序
    由于处理器使用缓存和读写缓冲区,这使得加载和存储数据看上去像是在不按顺序执行。

从java程序代码到最终实际执行的指令序列,会经历下面三种重排序:
在这里插入图片描述

重排序是否会造成多线程程序的线程不安全?

你可能会问了,cpu没那么智能,如果我们的多线程程序本来设计的让线程之间安全,那么会不会重排序之后就没法保证线程安全了?

不会,JMM(java内存模型)的编译器重排序规则会禁止某些特定类型的编译器重排序,而且对于cpu重排序规则,cpu在重排序的时候,会插入特定类型的内存屏障,使用内存屏障来禁止特定类型的处理器重排序,以此来避免重排序造成多线程程序线程不安全的问题。

重排序为什么会提高性能?

因为一个指令会涉及到很多步骤,每个步骤可能会用到不同的寄存器,CPU使用了流水线技术,CPU有多个功能单元(如获取、解码、运算和结果),一条指令也分为多个单元执行,那么第一条指令执行还没完毕,就可以执行第二条指令,前提是这两条指令功能单元相同或类似,所以一般可以通过指令重排序使得具有相似功能单元的指令接连执行来减少流水线中断的情况。

举个例子,我们要吃一袋子薯片,是拿出一片薯片、放到嘴里、吃完再拿下一片比较快,还是从袋子里先一片一片地把所有薯片都拿在手心,然后再一起放进嘴里比较舒服?对于计算机来说,后者更为合适,因为第二种在计算机中会减少Io操作,也就是减少伸手进袋子拿薯片再把手拿出袋子的次数。所以假如你原本的代码写的是第一种吃薯片方式,有可能在执行的时候就会被重排序为第二种吃薯片方式。而程序的执行结果都是:吃到薯片了,所以我们程序员只看到执行的结果,而对具体的执行过程就常常忽略掉了。

数据依赖性

那么,编译器和cpu在什么时候会不对指令进行重排序呢?

答:在两个操作之间存在数据依赖性的时候,编译器和cpu在什么时候会不对指令进行重排序。

什么是数据依赖性?

数据依赖性指的是两个操作同时访问一个共同的东西,并且其中至少一个操作对共同的东西进行了写操作,此时我们就称这两个操作之间存在数据依赖性。
数据依赖性存在三种情况:

  1. 先写后读
    如:
int a = 1;
int b = a;
  1. 先读后写
a = b;
b = 1;
  1. 先写再写
int a = 1;
a=2;

在这三种情况下,如果还对其进行重排序的话,程序的执行结果就会被改变。

这里的数据依赖性仅仅针对单个处理器单线程情况下的操作,多个处理器和多个线程之间的数据依赖性不被考虑。

指令重排序对写缓冲区的影响

现代的处理器使用写缓冲区来临时保存向内存中写入的数据,以此来保证cpu指令流水线运行,写缓冲区就相当于刚才的吃薯片例子中我们的手心,使用写缓冲区可以避免处理器向内存中写入数据的延迟。写缓冲区的工作流程如下:
在这里插入图片描述

这种设计,在多个处理器的时候,再去进行重排序的时候是否会对程序的运行结果造成影响呢?
答案是会的,因为每个处理器上的写缓冲区仅仅对它所在的处理器可见,这个特性会对内存操作的执行顺序造成重大影响,处理器对内存的读写代码的执行顺序不一定与内存实际发生的读写顺序一致。

例如,此时我们写了一段代码,交给一台电脑上的两个cpu去运行,如下:

处理器A 处理器B
初始状态 a=b=0; a=b=0;
代码 a=1;x=b; b=2;y=a;

运行流程图如下,图中的A1,A2,A3,B1,B2,B3代表了执行顺序:
在这里插入图片描述
运行结果分为三种情况:

1.x=y=0

当执行顺序为:A1,B1,A2,B2,A3,B3的时候,A1,B1修改的值储存在了写缓冲区中,并且在还没有执行A3,B3刷新到内存中的时候就执行了A2,B2,这个时候读出来的a和b的值还是初始值0,所以x=y=0。

2.x=0,y=1

当A1,A2,A3全部执行完成再执行B1,B2,B3的时候,A过程修改的值已经储存在了内存中,B还没有修改值到内存中,所以这个时候,a=1,b=0所以x=0,y=1。

3.x=2,y=0

当B1,B2,B3全部执行完成再执行A1,A2,A3的时候,B过程修改的值已经储存在了内存中,A还没有修改值到内存中,所以这个时候,a=0,b=2所以x=2,y=0。

现在来假想一下,如果计算机没有进行重排序,结果会是什么样的呢?

后两个结果不会改变,但是第一个结果不会出现,取而代之的是出现一个新的结果:x=2,y=1,这是因为,此时的执行顺序变成了A1,A3,B1,B3,A2,B2,这就出现了这样的结果。

现在我们知道了,我们的代码中,a=1;这一句其实是由A1+A3两个操作完成的;b=2;这一句也其实是由B1+B3两个操作完成的。

那么,如果我们不想因为重排序而搞乱了我们代码的顺序,那应该怎么办?
这就要我们通过使用java关键字例如volatile和synchronized来为程序加锁,控制程序得到我们想要的结果,事实上,即使是使用了这些措施,也并不是禁用了重排序,系统依然会为我们的程序进行重排序,只不过最后呈现在我们程序员眼中的代码执行顺序是顺序执行的结果而已。那么系统是依照什么规则进行重排序的呢?那就是happens——before规则

happens—before规则

happens——before的中文意思就是发生在…之前,那么这个规则的具体细节是什么样的?
1.程序顺序规则:一个线程中的每个操作,发生在该线程中任意后续操作之前
这个就解释了刚刚上面我们的重排序例子中,为什么无论A,B的操作如何乱序混排,但是A的执行顺序永远是A1->A2->A3
2.监视器锁规则:对一个锁的解锁,发生在随后对这个锁的加锁之前
3.volatile变量规则:对一个volatile域的写,发生在任意后续对这个volatile域的读之前
4.传递性:如果A发生在B之前,B发生在C之前,那么A一定发生在C之前

在遵从这个规则的条件下,再进行重排序,就会既能保证重排序的优点,又能让程序的结果按我们程序员的心意输出。

展开阅读全文

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