Java并发编程JVM指令重排

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java并发编程JVM指令重排相关的知识,希望对你有一定的参考价值。

引言:在Java中看似顺序的代码在JVM中,可能会出现编译器或者CPU对这些操作指令进行了重新排序;在特定情况下,指令重排将会给我们的程序带来不确定的结果.....

 

如下代码可能的结果有哪些?

public class PossibleReordering {
    static int x = 0, y = 0;
    static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread one = new Thread(new Runnable() {
            public void run() {
                a = 1;
                x = b;
        }
    });

    Thread other = new Thread(new Runnable() {
        public void run() {
            b = 1;
            y = a;
        }
    });
    one.start();other.start();
    one.join();other.join();
    System.out.println("(" + x + "," + y + ")");
}

 

看完本文你就明白了。

 

什么是指令重排?

在计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化。

 

问题来了,为啥重排可以提高代码的执行效率?

 

在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。

数据依赖性

主要指不同的程序指令之间的顺序是不允许进行交互的,即可称这些程序指令之间存在数据依赖性。

 

哪些指令不允许重排?


主要的例子如下:

名称       代码示例         说明  
写后读     a = 1;b = a;    写一个变量之后,再读这个位置。  
写后写     a = 1;a = 2;    写一个变量之后,再写这个变量。  
读后写     a = b;b = 1;    读一个变量之后,再写这个变量。

进过分析,发现这里每组指令中都有写操作,这个写操作的位置是不允许变化的,否则将带来不一样的执行结果。
编译器将不会对存在数据依赖性的程序指令进行重排,这里的依赖性仅仅指单线程情况下的数据依赖性;多线程并发情况下,此规则将失效。

as-if-serial语义

不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。
分析:  关键词是单线程情况下,必须遵守;其余的不遵守。

 

as-if-serial语义是啥?

as-if-serial语义的意思是,所有的动作(Action)5都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。


代码示例:

double pi = 3.14;         //A
double r = 1.0;           //B
double area = pi * r * r; //C

 

分析代码:

A->C  B->C;  A,B之间不存在依赖关系; 故在单线程情况下, A与B的指令顺序是可以重排的,C不允许重排,必须在A和B之后。
结论性的总结:
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

核心点还是单线程,多线程情况下不遵守此原则。

在多线程下的指令重排

首先我们基于一段代码的示例来分析,在多线程情况下,重排是否有不同结果信息:

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=true将被reader的方法体中看到,并正确的设置结果。 但是在多线程情况下,是否还是只有一个确定的结果呢?
假设有A和B两个线程同时来执行这个代码片段, 两个可能的执行流程如下:


可能的流程1, 由于1和2语句之间没有数据依赖关系,故两者可以重排,在两个线程之间的可能顺序如下: 

 

技术分享图片

 

可能的流程2:, 在两个线程之间的语句执行顺序如下:

 

技术分享图片

 

根据happens-before的程序顺序规则,上面计算圆的面积的示例代码存在三个happens-before关系:

 

啥是happens-before关系?


A happens-before B;
B happens-before C;
A happens-before C;
这里的第3个happens- before关系,是根据happens-before的传递性推导出来的 

 

啥是控制依赖关系?

 

啥是猜测(Speculation)?

 

在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。从图中我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义。

 

与上面的例子类似的有:

在线程A中:

context = loadContext();
inited = true;
在线程B中:
while(!inited ){     //根据线程A中对inited变量的修改决定是否使用context变量
    sleep(100);
} doSomethingwithconfig(context);
假设线程A中发生了指令重排序:
inited = true;
context = loadContext();
那么B中很可能就会拿到一个尚未初始化或尚未初始化完成的context,从而引发程序错误。

 

重排导致双重锁定的单例模式失效的例子
 
例子2:指令重排导致单例模式失效
我们都知道一个经典的懒加载方式的双重判断单例模式:

public class Singleton {
    private static Singleton instance = null;
    private Singleton() { }
    public static Singleton getInstance() {
        if(instance == null) {
            synchronzied(Singleton.class) {
                if(instance == null) {
                    instance = new Singleton();  //非原子操作
                }
            }
        }
        return instance;
    }
}
看似简单的一段赋值语句:instance= new Singleton(),但是它并不是一个原子操作,其实际上可以抽象为下面几条JVM指令:
memory =allocate();       //1:分配对象的内存空间 
ctorInstance(memory);     //2:初始化对象 
instance =memory;         //3:设置instance指向刚分配的内存地址
上面操作2依赖于操作1,但是操作3并不依赖于操作2,所以JVM是可以针对它们进行指令的优化重排序的,经过重排序后如下:
memory =allocate();     //1:分配对象的内存空间 
instance =memory;       //3:instance指向刚分配的内存地址,此时对象还未初始化
ctorInstance(memory);   //2:初始化对象
可以看到指令重排之后,instance指向分配好的内存放在了前面,而这段内存的初始化被排在了后面。
在线程A执行这段赋值语句,在初始化分配对象之前就已经将其赋值给instance引用,恰好另一个线程进入方法判断instance引用不为null,然后就将其返回使用,导致出错。

 

解决方案:例子1中的inited和例子2中的instance以关键字volatile修饰之后,就会阻止JVM对其相关代码进行指令重排,这样就能够按照既定的顺序指执行

 

核心点是:两个线程之间在执行同一段代码之间的critical area,在不同的线程之间共享变量;由于执行顺序、CPU编译器对于程序指令的优化等造成了不确定的执行结果。

 

我感觉这种情况大多发生多线程环境下:在你先去判断,然后决定做出操作时容易出错,对你要判断的对象加个volatile是个不错的选择。

如何防止指令重排

volatile关键字可以保证变量的可见性,因为对volatile的操作都在Main Memory中,而Main Memory是被所有线程所共享的,这里的代价就是牺牲了性能,无法利用寄存器或Cache,因为它们都不是全局的,无法保证可见性,可能产生脏读。

volatile还有一个作用就是局部阻止重排序的发生(在JDK1.5之后,可以使用volatile变量禁止指令重排序),对volatile变量的操作指令都不会被重排序,因为如果重排序,又可能产生可见性问题。

在保证可见性方面,锁(包括显式锁、对象锁)以及对原子变量的读写都可以确保变量的可见性。

 

但是实现方式略有不同,例如同步锁保证得到锁时从内存里重新读入数据刷新缓存,释放锁时将数据写回内存以保数据可见,而volatile变量干脆都是读写内存。

volatile关键字通过提供"内存屏障"的方式来防止指令被重排序,为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
大多数的处理器都支持内存屏障的指令。
对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,Java内存模型采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。



























以上是关于Java并发编程JVM指令重排的主要内容,如果未能解决你的问题,请参考以下文章

Java 并发编程线程指令重排序问题 ( 指令重排序规范 | volatile 关键字禁止指令重排序 )

Java进阶 - 并发(关键字)

并发编程-Java内存模型和volatile

JUC并发编程 -- 有序性(指令重排序优化 & 支持流水线的处理器 & 指令重排序的问题/验证/禁用)

JVM学习--内存模型可见性指令重排序

指令重排序