Java内存模型小析之重排序(三)

 2019-12-10 15:54  阅读(781)
文章分类:Java Core

我们在上一篇文章中说了JAVA内存模型中原子性可见性的相关概念(点这里查看),我们在这一篇文章里说一下java内存模型中的重排序的内容。

重排序

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

重排序的分类

编译器优化的重排序

编译器在不改变单线程程序执行结果的前提下,可以重新安排语句的执行顺序。这里需要注意的是:
不改变单线程程序的语义(as-if-serial)。

指令级并行的重排序

现代处理器采用了指令级并行技术(ILP)来将多条指令重叠执行。在单线程和单处理器中,如果两个操作之间不存在数据依赖,处理器可以改变语句对应机器指令的执行顺序。

内存系统的重排序

由于处理器使用缓存和读/写缓冲区,处理器会重排对内存的读/写操作的执行顺序。
所以从java源代码到最终实际执行的指令序列,会分别经历下面三种重排序 :

源代码—->编译器优化重排序—->指令级并行重排序—->内存系统重排序—->最终执行的指令序列

数据依赖

我们在指令级并行的重排序中说如果两个操作之间没有数据依赖,处理器会进行指令的重排序。那么什么是数据依赖呢?
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。这里的数据依赖仅针对单个处理器中执行的指令序列和单个线程中执行的操作。数据依赖分为下列三种类型:

类型 代码示例 说明
类型 代码示例 说明
写后读 a=1;b=a; 写一个变量之后,再读这个变量
写后写 a=1; a=2; 写一个变量之后,再写这个变量
写后读 a=b;b=1; 度一个变量之后,再写这个变量

指令依赖

在按序执行中,一旦遇到指令依赖的情况,流水线就会停滞(因为CPU从主存加载读取数据是一个很慢(相对于CPU的处理速度来说)很复杂的IO操作,但是CPU层面实现了异步IO,通过异步IO的方式读取内存数据。),为了让CPU一直处于工作状态,把时间浪费减到最小,CPU就会进行重排序,跳到下一个非依赖指令。如a=b;c=1;d=1;
如果b不在缓存行里,需要从主存加载,这就是一个指令依赖。CPU可以对此进行重排序,先读c=1;或者d=1;。

重排序导致的问题

重排序会引起多线程的可见性问题。

例子及详细说明

下面我们详细的说明一下重排序会引起的多线程的可见性问题。 大家先看这样的一段代码:

class Visibility extends Thread {
            private boolean flag = false;

            @Override
            public void run() {
                int i = 0;
                while (!flag) {
                    i++;
                }
                System.out.println("finish loop i:" + i);
            }

            public void stopFlag() {
                flag = true;
            }

            public boolean getFlag() {
                return flag;
            }
        }

@Test
        public void testSame() {
            Visibility visibility = new Visibility();
            visibility.start();
            try {
                //让线程执行一段时间
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            //停止线程
            visibility.stopFlag();
            System.out.println(visibility.getFlag());
            try {
                //陷入死循环
                visibility.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

如果以server模式运行上面的代码的话,程序的执行结果可能和大家以为的不太一样。有人可能会认为程序在调用stopFlag()方法之后,就会停止循环,然后输出finish loop i:。但是程序的执行结果确实可能会陷入到死循环中。这里会出现死循环的
原因是:程序在进入Visibility的run方法之前,先读取到了flag的值,然后在while循环中不再读取flag的值了,即使后续对flag的值进行了修改,run方法中也不会再读取flag的值。相当于
run{ int i ; while(!false){ 循环 } }。编译器层面进行了重排序。这里想让程序停下来也很简单,只需要用volatile修改变量即可。 这里需要说明一下的是:我们在安装64位JDK时,一般都是server模式运行程序的(默认)。32位不支持server模式。server模式与client模式的区别是:server模式启动时,速度较慢,但是一旦运行起来后,性能将会有很大的提升.原因是。当虚拟机运行在-client模式的时候,使用的是一个代号为C1的轻量级编译器, 而-server模式启动的虚拟机采用相对重量级,代号为C2的编译器,这个编译器对代码做了很多的优化。可以通过java -version这个命令来查看对应的模式。上面是client模式,下面是server模式。
20191210001407\_1.png
20191210001407\_2.png

我们再看一个CPU重排序的例子

public class CPUDisordeTest {

        int a = 0, b = 0, x = 0, y = 0;

        public static void test(int i){

            CPUDisordeTest test = new CPUDisordeTest();
            Thread threadA = new Thread(()->{
                test.a = 1;
                test.x = test.b;
            });
            Thread threadB = new Thread(()->{
                test.b = 1;
                test.y = test.a;
            });
            threadA.start();
            threadB.start();
            //确保线程执行完
            try {
                threadA.join();
                threadB.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println(String.format("case%s x:%s y:%s",i,test.x,test.y));
        }

        public static void main(String[] args) {
            try {
                PrintStream pw = new PrintStream(new FileOutputStream("G:\\LearnVideo\\testout.txt"));
                System.setOut(pw);
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            }
            //循环
            IntStream.range(0,1000000).forEach(CPUDisordeTest::test);
        }
    }

上面的程序可能会出现如下所示的结果(说明:x:1 y:1这个结果我没有跑出来,但是有可能会存在这一的结果。以下程序的结果,都是本人亲自执行程序测试出来的结果):
20191210001407\_3.png
20191210001407\_4.png
20191210001407\_5.png 对于x:0 y:0这个结果大家可能会非常奇怪。程序执行时CPU和内存的交互如下图所示:

20191210001407\_6.png

当处理器A和处理器B把各自的结果写入缓冲区(A1 B1),然后从主存中读取另外的共享变量的值,注意这时处理器A和处理器B写入的值还在写入缓冲区,还没刷新到主存中去,所以这时从主存中读取到的共享变量a和b的值还是0,即text.x和test.y的值是0。最后处理器A和处理器B把自己写入缓冲区中的数据刷新到主存中去。注意这里发生了内存系统的重排序。按照程序发生的顺序应该是A3把A1写缓存区中的值刷新到主存中a的变量写入才算数成功了。即应该是A1-A3-A2。但是实际发生的顺序可能是A1-A2-A3或者是A2-A1-A3(因为这里没有数据依赖关系,可能会发生重排序)。 下面我们再看最后一个例子关于指令级重排序的: 一条指令的执行是可以分为很多步骤的,下面列了一下主要的步骤:

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

即,指令的执行顺序为:IF ID EX MEM WB。下面我们看一下 A=B+C的操作的指令序列:
20191210001407\_7.png

上面我们说过一条指令的顺序为 IF ID EX MEM WB那当我们有两条指令的时候是不是先等第一条指令顺序执行完的时候,才开始执行第二条?很明显不是的。指令的执行就如同流水线,当我们第一个指令执行完IF的时候,我们就可以跟着执行第二条指令的IF了如上图所示。但是当执行到ADD这一行的时候我们发现ID和EX中间多了一个X,这个X代表的时候一个停顿。为什么这里会有一个停顿呢?因为这时要进行一个计算的操作,而第二条指令还没有从内存中读出来值。为什么第二条指令还没有写回到寄存器中,就可以进行EX呢?在硬件电路中处理数据冲突的时候会使用一种旁路的技术,直接把数据从硬件中读取出来,所以不用等第二条指令完全执行完,就可以进行计算了。我们再看一个复杂的例子:
20191210001407\_8.png

在上图中我们看到很多X存在,那么有没有办法尽可能的减少这些X呢?答案是肯定的,指令重排。我们看一下重排之后的结果:
20191210001407\_9.png
20191210001407\_10.png 从上图中我们发现没有了X的存在,程序的性能得到了一定的提升,并且在单线程和单处理器中程序的执行结果不会发生变化。
既然重排序会导致的不可见的问题,那么能不能禁止特定类型的重排序呢?答案是:能。通过什么方式呢?就是我们下面要说的栅栏或者内存屏障。

内存屏障

简单理解:内存屏障(Memory Barrier / Memory Fence)是一种CPU指令,用于控制特定条件下的重排序和内存可见性问题
java编译器在生成指令序列时插入特定类型的内存屏障(Memory Barriers/Memory Fence),可以禁止特定类型的重排序。 内存屏障可以分为下面四类:
20191210001407\_11.png

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

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序,在单线程中程序的执行结果不能被改变。

编译器、runtime和处理器都必须遵守as-if-serial语义。

控制依赖性

什么是控制依赖性? 像:if(条件){ 执行代码。。。 }这样的代码之间存在控制依赖性。

控制依赖性会导致什么问题?

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

在单线程程序中,对存在控制依赖的操作重排序不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

参考:
java并发编程的艺术。 葛一鸣的相关资料。

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> Java内存模型小析之重排序(三)

相关推荐