【搞定Java并发编程】第13篇:重排序

上一篇:happens-before:https://blog.csdn.net/pcwl1206/article/details/84929752

目  录:

1、数据依赖性

2、as-if-serial语义

3、程序顺序规则

4、重排序对多线程的影响

5、指令重排的案例讲解


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

1、数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。

名称代码示例说明
写后读

        a = 1;

        b = a;

写一个变量之后,再读这个位置
写后写

        a = 1;

        a = 2;

写一个变量之后,再写这个变量
读后写

        a = b;

        b = 1;

读一个变量之后,再写这个变量

上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序

        注意,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。


2、as-if-serial语义

as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。

比如之前讲过的计算圆面积的案例:

double pi = 3.14;           // A
double r = 1.0;             // B
double area = pi * r * r;   // C
A、B和C三个操作之间的依赖关系

从上图可以看出来,A和C以及B和C之间存在数据依赖关系,但是A和B之间并不存在数据依赖关系。因此,编译器和处理器可以重排序A和B之间的执行顺序。

程序的两种执行顺序

 

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按照程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。


3、程序顺序规则

按照happens-before的程序顺序规则,上面计算圆面积的代码存在3个happens-before关系:

1、A    happens-before    B;

2、B    happens-before    C;

3、A    happens-before    C;

A  happens-before  C是根据传递性推导出来的。这里的A  happens-before  B,但是实际执行时B却可以排在A之前执行,JMM并不不要求A一定在B之前执行,因为它们之间没有数据依赖关系,因此如果1和2发生了重排序,也不会影响计算结果的正确性。JMM允许这种重排序。

在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。编译器和处理器遵从这一目标,从happens-before的定义我们可以看出,JMM同样遵从这一目标。


4、重排序对多线程的影响

重排序是否会改变多线程程序的执行结果呢?看下面这个案例:

class ReorderExample{
    int a = 0;
    boolean flag = false;

    public void writer(){
        a = 1;              // 1
        flag = true;        // 2
    }

    public void reader(){
        if(flag){           // 3
            int i = a * a;  // 4
            ......
        }
    }
}

flag是个变量,用来标识变量a是否已被写入。假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行rader()方法。那么B在执行操作4时,能否看见操作A在操作1对共享变量的写入呢?

答案是:不一定能看到。

由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序。

如上图所示:操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,在这里多线程程序的语义被重排序破坏了。

实际上,操作3和操作4也可能发送重排序,那我们就来看看操作3和操作4发生了重排序后会有什么情况发生?

操作3和操作4存在控制依赖关系。当代码中存在控制依赖时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。

从图中可以看出,猜测执行实际上对操作3和操作4进行了重排序,无需判断flag是否为true时,就可以读取a的值了,显然破坏了多线程程序的语义。


5、指令重排的案例讲解

下文的内容在Java内存模型中已经讲解过,这里再复述一遍。

5.1、指令重排

计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种:

1、编译器优化的重排

编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2、指令并行的重排

现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。

3、内存系统的重排

由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差

其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排。从Java源码到最终实际执行的指令序列,会分别经历下面三种重排序。

在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题。

5.1.1、编译器重排

下面我们简单看一个编译器重排的例子:

 线程 1                         线程 2

1:x2 = a ;                  3: x1 = b ;

2:b = 1;                    4: a = 2 ;

两个线程同时执行,分别有1、2、3、4四段执行代码,其中1、2属于线程1 , 3、4属于线程2 ,从程序的执行顺序上看,似乎不太可能出现x1 = 1 和x2 = 2 的情况,但实际上这种情况是有可能发现的,因为如果编译器对这段程序代码执行重排优化后,可能出现下列情况:

 线程 1                         线程 2

2:b = 1;                    4: a = 2 ;

1:x2 = a ;                 3: x1 = b ;            

这种执行顺序下就有可能出现x1 = 1 和x2 = 2 的情况,这也就说明在多线程环境下,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的

5.1.2、处理器指令重排

先了解一下指令重排的概念,处理器指令重排是对CPU的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下:

  1. 取指 IF;
  2. 译码和取寄存器操作数 ID;
  3. 执行或者有效地址计算 EX;
  4. 存储器访问 MEM;
  5. 写回 WB。

CPU在工作时,需要将上述指令分为多个步骤依次执行(注意硬件不同有可能不一样),由于每一步会使用到不同的硬件操作,比如取指时会用到PC寄存器和存储器、译码时会执行到指令寄存器组、执行时会执行ALU(算术逻辑单元)、写回时使用到寄存器组。为了提高硬件利用率,CPU指令是按流水线技术来执行的,如下:

从图中可以看出当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样做是有好处的。如果每个步骤花费1ms,那么如果第2条指令需要等待第1条指令执行完成后再执行的话,则需要等待5ms,但如果使用流水线技术的话,指令2只需等待1ms就可以开始执行了,这样就能大大提升CPU的执行性能。

虽然流水线技术可以大大提升CPU的性能,但不幸的是一旦出现流水中断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大,就好比工厂组装手机的流水线,一旦某个零件组装中断,那么该零件往后的工人都有可能进入一轮或者几轮等待组装零件的过程。因此我们需要尽量阻止指令中断的情况,指令重排就是其中一种优化中断的手段,我们通过一个例子来阐明指令重排是如何阻止流水线技术中断的。

a  =  b + c ;

d  =  e + f ;

下面通过汇编指令展示了上述代码在CPU执行的处理过程:

  • LW指令表示 load,其中LW R1,b表示把 b 的值加载到寄存器R1中;
  • LW R2,c 表示把 c 的值加载到寄存器R2中;
  • ADD 指令表示加法,把R1 、R2的值相加,并存入R3寄存器中。
  • SW 表示 store, 即:将 R3寄存器的值保存到变量a中;
  • LW R4,e 表示把 e 的值加载到寄存器R4中;
  • LW R5,f 表示把 f 的值加载到寄存器R5中;
  • SUB 指令表示减法,把R4 、R5的值相减,并存入R6寄存器中。
  • SW d,R6 表示将R6寄存器的值保存到变量d中。

上述便是汇编指令的执行过程,在某些指令上存在X的标志,X代表中断的含义,也就是只要有 X 的地方就会导致指令流水线技术停顿,同时也会影响后续指令的执行,可能需要经过1个或几个指令周期才可能恢复正常,那为什么停顿呢?这是因为部分数据还没准备好,如执行ADD指令时,需要使用到前面指令的数据R1,R2,而此时R2的MEM操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到MEM操作完成后才能执行,也就因此而停顿了,其他指令也是类似的情况。

前面阐述过,停顿会造成CPU性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了。如下图,既然ADD指令需要等待,那我们就利用等待的时间做些别的事情如把LW R4,e 和 LW R5,f 移动到前面执行,毕竟LW R4,e 和 LW R5,f 执行并没有数据依赖关系,对它们有数据依赖关系的 SUB R6,R5,R4 指令在 R4,R5 加载完成后才执行的,没有影响。过程如下:

正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样CPU的性能也能带来很好的提升,这就是处理器指令重排的作用。关于编译器重排以及指令重排(这两种重排我们后面统一称为指令重排)相关内容已阐述清晰了,我们必须意识到对于单线程而言指令重排几乎不会带来任何影响,比竟重排的前提是保证串行语义执行的一致性,但对于多线程环境而言,指令重排就可能导致严重的程序轮序执行问题,如下:

class MixedOrder{
    int a = 0;
    boolean flag = false;
    public void writer(){
        a = 1;
        flag = true;
    }

    public void read(){
        if(flag){
            int i = a + 1;
        }
    }
}

如上述代码,同时存在线程A和线程B对该实例对象进行操作,其中A线程调用写入方法,而B线程调用读取方法,由于指令重排等原因,可能导致程序执行顺序变为如下:

由于指令重排的原因,线程A的flag置为true被提前执行了,而a赋值为1的程序还未执行完。此时线程B,恰好读取flag的值为true,直接获取a的值(此时B线程并不知道a为0)并执行 i 赋值操作,结果 i 的值为1,而不是预期的2,这就是多线程环境下,指令重排导致的程序乱序执行的结果。因此,请记住,指令重排只会保证单线程中串行语义的执行的一致性,但并不会关心多线程间的语义一致性


上一篇:happens-before:https://blog.csdn.net/pcwl1206/article/details/84929752

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值