指令重排序

指令重排序的概念

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

  • 编译器优化的重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令并行的重排:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的指令无需依赖前面执行的指令的结果),处理器可以改变语句对应的机器指令的执行顺序
  • 内存系统的重排:由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差

其中编译器优化的重排属于编译器重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题。从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
image.png

指令重排排序带来的问题

编译器重排序

有两个共享变量a和b:

  1. 线程 1 线程 2
  2. 1 x2 = a ; 3: x1 = b ;
  3. 2: b = 1; 4: a = 2 ;

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

  1. 线程 1 线程 2
  2. 2: b = 1; 4: a = 2 ;
  3. 1x2 = a ; 3: x1 = b ;

如果说只对线程1而言,重排序完全不会对它的执行结果产生任何影响,但是,a、b是共享变量,如果在多线程环境下,这种编译器重排序就会结果产生影响。即由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,存在线程安全问题。

处理器重排序

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

  • IF:取指
  • ID:译码和取寄存器操作数
  • EX:执行或者有效地址计算
  • MEM:存储器访问
  • WB:写回

现代计算机CPU对指令的执行往往不是按照指令流对指令串行执行,而为了充分利用空闲的CPU而采用指令流水,如图:
image.png
从图中可以看出当指令1还未执行完成时,第2条指令便利用空闲的硬件开始执行,这样做是有好处的,如果每个步骤花费1ms,那么如果第2条指令需要等待第1条指令执行完成后再执行的话,则需要等待5ms,但如果使用流水线技术的话,指令2只需等待1ms就可以开始执行了,这样就能大大提升CPU的执行性能。虽然指令流水可以提高CPU的执行性能,但是也会存在一些问题,如下面的例子所示:

  1. a = b + c ;
  2. d = e - f ;

上面的两行代码,CPU需要执行下面的指令:
image.png

  • 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加载完成后才执行的,没有影响,过程如下:
image.png

image.png
正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样CPU的性能也能带来很好的提升,这就是处理器指令重排的作用。了解为什么要进行指令重排后,可以看一下它带来的问题:

  1. class MixedOrder{
  2. int a = 0;
  3. boolean flag = false;
  4. public void writer(){
  5. a = 1;
  6. flag = true;
  7. }
  8. public void read(){
  9. if(flag){
  10. int i = a + 1
  11. }
  12. }
  13. }

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

  1. 线程A 线程B
  2. writer read
  3. 1:flag = true; 1:flag = true;
  4. 2:a = 1; 2: a = 0 ; //误读
  5. 3: i = 1 ;

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

小结

通过上面的分析,其实指令重排序就是带来了可见性和有序性的问题:

  • 可见性:工作内存与主内存的同步延迟现象会带来可见性问题,此外,重排序也带来了可见性问题:无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题
  • 有序性:指令重排序就是导致有序性的直接原因,在一个线程内,所有操作都视为有序行为;如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,从而也导致了线程安全问题

    指令重排序的解决方案

    指令重排序固然可以提高程序的执行效率,但是却带来了可见性和有序性的并发安全问题,因此,Java就需要一些机制去解决这些问题。

    as-if-serial语义

    重排序可以优化执行效率,但是前提是一定要确保执行结果不被改变。编译器和处理器都必须遵守as-if-serial语义。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。当前,这个仅仅只是保证了单线程的线程安全,并没有保证多线程环境下的线程安全。

    happens-before原则

    Java语言设计者在设计语言的时候,本身就设计了一些“天然原则”来确保JMM的三大特点的正常,这些原则就是happens-before原则了:

  • 程序顺序原则: 即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。

  • 锁规则: 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
  • volatile规则: volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
  • 传递性规则: 传递性 A先于B ,B先于C 那么A必然先于C
  • 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
  • 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
  • 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
  • 对象终结规则 对象的构造函数执行,结束先于finalize()方法

    使用Synchronized或Lock锁

    Java提供的Synchonized和Lock锁就可以确保原子性、可见性、有序性,因此,同样也就解决了指令重排序带来的问题。

    使用volatile关键字

    如果线程仅仅只是存在指令重排序导致的可见性和有序性的并发问题,那么就可以使用volatile这个关键字来解决,因为volatile关键字有两个作用:

  • 保证可见性:当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存;到读一个volatile变量时,JMM会把该线程对应的工作内存置为无效,然后从主内存中读取共享变量

  • 禁止指令重排序优化:volatile关键字会使用内存屏障在可能会导致多线程并发问题的情况下禁止指令重排

    volatile的内存语义

    内存屏障

    内存屏障的概念

    内存屏障(Memory barrier),也称内存栅栏,是一类同步屏障指令,它使得 CPU 或编译器在对内存进行操作的时候, 严格按照一定的顺序来执行,也就是说在内存屏障之前的指令和之后的指令不会由于系统优化等原因而导致乱序。内存屏障具有两个作用:

  • 如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。

  • 强制把写CPU缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效

从硬件层面而言,一般分为读屏障(Load Barrier)和写屏障(Store Barrier),其作用如下:

  • 对于Load Barrier来说,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据
  • 对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见

    四种基本内存屏障

    image.png

    volatile的可见性

    被volatile关键字修饰的变量对所有线程都是可见的,对volatile变量的所有写操作总是能立刻反应到其他线程中。其原因如下:

    volatile写的实现

    volatile写:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
    image.png
    不难看出来,对 volatile 变量的写指令后会加入写屏障 ,因为其功能是“在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见”

    volatile读的实现

    volatile读:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量
    image.png
    不难看出来,对 volatile 变量的读指令前会加入读屏障,因为其功能是“在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据”
    • 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
    • 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
    • 线程A写一个 volatile变量,随后线程B读取这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息

volatile禁止指令重排

volatile关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,而其实现原理是借助内存屏障实现的。

volatile重排序规则

为了实现volatile内存语义,JMM会分别限制编译器重排序和处理器重排序的重排序类型,下面是JMM针对编译器制定的volatile重排序规则表:
image.png

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序

    实现原理

    volatile想要实现上面的重排序规则,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
  1. volatile写:
  • 前面插入一个StoreStore屏障
  • 后面插入一个StoreLoad屏障
  1. volatile读:
  • 后面插入一个LoadLoad屏障
  • 后面插入一个LoadStore屏障

虽然上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。

volatile写的屏障布局

image.png

  1. 图中StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
  2. 而为什么要在volatile写后面插入StoreLoad屏障:此屏障的作用是避免volatile写与 后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面 是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确 实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile 读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM最终选择了在每个 volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个 写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。

    volatile读的屏障布局

    image.png

  3. 图中LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。

  4. LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。

    注意: 上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。

volatile关键字的使用

volatile的适用场景

  • volatile是轻量级同步机制,在访问volatile变量时不会执行加锁操作,因此也就不会使执行线程阻塞,是一种比synchronized关键字更轻量级的同步机制。
  • volatile无法同时保证内存可见性和原子性。加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。
  • volatile不能修饰写入操作依赖当前值的变量。声明为volatile的简单变量如果当前值与该变量以前的值相关,那么volatile关键字不起作用,也就是说如下的表达式都不是原子操作:“count++”、“count = count+1”。
  • 当要访问的变量已在synchronized代码块中,或者为常量时,没必要使用volatile;
  • volatile屏蔽掉了JVM中必要的代码优化,所以在效率上比较低,因此一定在必要时才使用此关键字。

这里要特别注意volatile关键字不能保证原子性,如下所示:

  1. public class Test {
  2. private volatile int num = 0;
  3. public void increase() {
  4. //num++并不是原子操作,需要多条执行才能完成这一行代码
  5. num++;
  6. }
  7. public static void main(String[] args) throws InterruptedException {
  8. final Test test = new Test();
  9. for (int i = 0; i < 10; i++) {
  10. new Thread(() -> {
  11. for (int j = 0; j < 1000; j++) {
  12. test.increase();
  13. }
  14. }).start();
  15. }
  16. // 睡眠 3s 保证 10个线程的自增操作全部执行完毕
  17. Thread.sleep(3000);
  18. System.out.println("num 的值为: " + test.num);
  19. }
  20. }

image.png

volatile使用案例

用于状态标记量

  1. volatile boolean inited = false;
  2. //线程1:
  3. context = loadContext();
  4. inited = true;
  5. //线程2:
  6. while(!inited ){
  7. sleep()
  8. }
  9. doSomethingwithconfig(context);

双重检查

  1. class Singleton{
  2. private volatile static Singleton instance = null;
  3. private Singleton() {
  4. }
  5. public static Singleton getInstance() {
  6. if(instance==null) {
  7. synchronized (Singleton.class) {
  8. if(instance==null)
  9. instance = new Singleton();
  10. }
  11. }
  12. return instance;
  13. }
  14. }

讲到这个经典的单例模式,可以分析一下如果不加volatile关键字会怎样:

  1. public class Singleton {
  2. private static Singleton instance = null;
  3. private Singleton() {}
  4. public static Singleton getInstance() {
  5. if (instance == null) {
  6. synchronized(Singleton.class) {
  7. if (instance == null)
  8. instance = new Singleton();// 非原子操作
  9. }
  10. }
  11. return instance;
  12. }
  13. }

因为“new Singleton()”不是原子操作,大概可以分为:

  1. //1:分配对象的内存空间
  2. memory =allocate();
  3. //2:初始化对象
  4. ctorInstance(memory);
  5. //3:设置instance指向刚分配的内存地址
  6. instance =memory;

如果没有volatile关键字,那么可能导致指令重排:

  1. //1:分配对象的内存空间
  2. memory =allocate();
  3. //3:instance指向刚分配的内存地址,此时对象还未初始化,但是instance!=null
  4. instance =memory;
  5. //2:初始化对象
  6. ctorInstance(memory);

指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错(还未执行初始化对象操作)。