Java内存模型JMM之二指令重排序及内存屏障

重排序背景

         现代计算机的处理器架构几乎都采用流水线机制在一颗处理器内核中实现指令级并行运算(甚至一个CPU拥有多条独立的流水线这称为超标量),简单地理解即是说,将一条指令的执行过程拆分成不同的执行周期(例如取指、译码、转址、执行、写回等)分散到若干级流水线上执行,从而达到多条指令能同时处于这条流水线上的不同级别,称为指令级并行。

         在这样的流水线模型中,当某一条指令在某级流水线处理时间太长就会阻塞上一级已经执行完并正在等待该级流水线执行的指令的执行,从而严重降低了处理性能,这中现象称之为流水线阻塞或者流水线气泡。为了避免这种现象,处理器在将指令推入流水线之前可以根据它们之间的数据依耐性进行重排序,从而消除这种阻塞,当然这已经是很早期的CPU流水线设计了,Intel在奔腾系列开始就已经在流水线模型中加入了乱序执行部件,只要当前微指令所需的数据就绪,而且存在空闲的执行单元,微指令就可以立即执行,甚至跳过前面还未就绪的微指令,然而就算有了这些激动人心的改进,CPU对指令的重排序依然是不可或缺的。

 

重排序的分类

一条程序的执行,为了提高性能,编译器和处理器事先一般都会对其指令进行重排序, 重排序分为以下三种类型:

  1. 编译器优化重排序。编译器在不改变单线程程序语义的前提下,可以依据上下文的分析重新安排语句的执行顺序。在Java中即是as-if-serial语义表达的含义:单线程环境中的操作均可以为了优化而被重排序,但是必须要保证重排序后执行的结果不能被改变,编译器、runtime、处理器都必须遵守as-if-serial语义。 
  2. 指令级并行重排序。在指令运行期,处理器在将指令提交至并行运算的流水线时,如果经过动态分析不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统重排序。要理解内存系统重排序,需要先了解缓存分片机制:我们知道为了提升CPU性能,CPU从直接存取主内存转移到各自的CPU缓存,但是CPU处理性能依然还是远远超过访问cache的速度,这种速度不匹配会造成cache wait,过多的cache wait会造成性能瓶颈。针对这种情况,多数架构采用了一种将cache分片的解决方案,即将一块cache划分成互不关联地多个slots(逻辑存储单元,又名Memory Bank或Cache Bank),CPU可以自行选择在多个idle bank中进行存取,从而能够显著提升指令并行处理能力。回到重排序问题上,在指令运行期间,如果指令1要操作的cache bank处于busy状态,而指令2要操作的cache bank处于idle状态,那么CPU为了防止cache 等待,可能会对着两个指令的内存访问操作进行重排序,即先执行后面的指令2。

重排序可能会导致这样的结果:

//代码顺序                        //执行顺序
int number= 1;                    int result = 0;
int result = 0;                   int number = 1;

 以上重排序满足as-if-serial语义,即程序执行的结果应该与代码执行的结果一致,所以重排序是被允许的。但是存在数据依赖的语句的执行不能被重排序,如下三种情况重排序将改变程序执行结果,所以不会被编译器以及处理器重排序:

 

写后读 a = 1;b = a; 写一个变量之后,再读这个位置
写后写 a = 1;a = 2; 写一个变量之后,再写这个变量
读后写 a = b;b = 1; 读一个变量之后,再写这个变量

 

重排序对多线程的影响

public class RecordExample {
    int a = 0;
    boolean flag = false;

    /**
     * A线程执行
     */
    public void writer(){
        a = 1;                  // 1
        flag = true;            // 2
    }

    /**
     * B线程执行
     */
    public void read(){
        if(flag){                  // 3
           int i = a + a;          // 4
        }
    }

}

 A线程执行writer(),线程B执行read(),线程B在执行时能否读到 a = 1 呢?答案是不一定(当然和特定的CPU架构相关,如X86CPU不支持写重排序,那么结果就是确定的1)。

分析:由于操作1 和操作2 之间没有数据依赖性,所以可以进行重排序处理。

           由于操作3 和操作4 之间也没有数据依赖性,他们亦可以进行重排序。但是操作3和操作4之间存在控制依赖关系,当代码中存在控制依赖时,会影响指令序列的并行度,为此,编译器和处理器会采用 猜测执行来克服这种影响,即线程B可以提前读取并计算a + a,然后把结果临时保存到一个名为重排序缓冲的硬件缓存中,当操作3满足时,就把该结果写入变量i中。

通过上面的分析,重排序不会影响单线程环境的执行结果,但是会破坏多线程的执行语义。

 

内存屏障 

通过前面的介绍,我们知道不但现代计算器都是多核CPU,而且每个CPU核心还有单独的缓存, 并且这些缓存并不是实时都与主存发生信息交换,因为与主存之间的交互操作需要很大的性能开销,CPU为了保证指令流水线持续运行,一般会以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一块内存地址的多次写,减少对内存总线的占用,这样就可能造成多个CPU上的缓存数据不一致从而使多线程的运行出现问题,另一方面,指令重排序的现象进一步打乱了指令的执行顺序,给其它CPU提供不可测的运行顺序。

内存屏障通过牺牲CPU的优化技术在一定程度上消除了这种想象,内存屏障(Memory Barrier),又称内存栅栏,是一个CPU指令。内存屏障有两个功能:

a)确保一些特定操作执行的顺序。通过内存屏障可以禁止特定类型处理器的重排序,确保从另一个CPU来看屏障的两边的所有指令都是正确的程序顺序,而保持程序顺序的外部可见性。

b)影响一些数据的可见性。通过内存屏障可以强制把写缓冲区/高速缓存中的数据立即写回主内存,让其它CPU的缓存中相应的数据失效。

 

大多数的内存屏障都是复杂的话题。在不同的CPU架构上内存屏障的实现非常不一样,Java内存模型屏蔽了这种底层硬件平台的差异。为了简化并方便理解,仅以X86架构来阐述。x86主要有以下几种内存屏障:

1. Store屏障,是x86上的”sfence“指令,强制所有在store屏障指令之前的store指令,都在该store屏障指令执行之前被执行,并把store缓冲区的数据都写会主内存。这会使得程序状态对其它CPU可见。通俗地讲就是:在写指令之后插入写屏障,能让写入缓存的最新数据立即写回到主内存。

2. Load屏障,是x86上的”ifence“指令,强制所有在load屏障指令之后的load指令,都在该load屏障指令执行之后被执行,并且一直等到load缓冲区被该CPU读完才能执行之后的load指令。通俗地讲就是:在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据。

3. Full屏障,是x86上的”mfence“指令,是一种全能型的屏障,复合了Load和Store屏障的功能,所以开销也是比较大的。

 

Lock前缀,Lock不是一种内存屏障,但是它能完成类似Full内存屏障的功能。Lock会对CPU总线或者高速缓存加锁,可以理解为CPU指令级的一种锁。

1. 它先对总线/缓存加锁,然后执行后面的指令,最后释放锁后会把高速缓存中的数据全部刷新回主内存。

2. Lock锁住总线的时候,其他CPU的读写请求都会被阻塞,直到锁释放。Lock后的写操作会让其他CPU相关的cache line失效,从而使其重新从内存加载最新的数据。这个是通过缓存一致性协议做的。

猜你喜欢

转载自pzh9527.iteye.com/blog/2389529