指令重排序

如果说内存可见性问题已经让你抓狂了,那么下边的这个指令重排序的事儿估计就要骂娘了~这事儿还得从一段代码说起:

public class Reordering {

    private static boolean flag;
    private static int num;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {

            @Override
            public void run() {
                while (!flag) {
                    Thread.yield();
                }

                System.out.println(num);
            }
        }, "t1");
        t1.start();
        num = 5;
        flag = true;
    }
}

需要注意到flag并不是一个volatile变量,也就是说它存在内存可见性问题,但是即便如此,num = 5也是写在flag = true的前边的,等到t1线程检测到了flag值的变化,num值的变化应该是早于flag值刷新到主内存的,所以线程t1最后的输出结果肯定是5!!!

no!no!no! 输出的结果也可能是0,也就是说flag = true可能先于num = 5执行,有没有亮瞎你的狗眼~ 这些代码最后都会变成机器能识别的二进制指令,我们把这种指令不按书写顺序执行的情况称为指令重排序。大多数现代处理器都会采用将指令乱序执行的方法,在条件允许的情况下,直接运行当前有能力立即执行的后续指令,避开获取下一条指令所需数据时造成的等待。通过乱序执行的技术,处理器可以大大提高执行效率。

Within-Thread As-If-Serial Semantics

既然存在指令重排序这种现象,为什么我们之前写代码从来没感觉到呢?到了多线程这才发现问题?

指令重排序不是随便排,一个一万行的程序直接把最后一行当成第一行就给执行那不就逆天了了么,指令重排序是需要遵循代码依赖情况的。比如下边几行代码:

int i = 0, b = 0;
i = i + 5;  //指令1
i = i*2;  //指令2
b = b + 3;  //指令3

对于上边标注的3个指令来说,指令2是对指令1有依赖的,所以指令2不能被排到指令1之前执行。但是指令3指令1指令2都没有关系,所以指令3可以被排在指令1之前,或者指令1指令2中间或者指令2后边执行都可以~ 这样在单线程中执行这段代码的时候,最终结果和没有重排序的执行结果是一样的,所以这种重排序有着Within-Thread As-If-Serial Semantics的含义,翻译过来就是线程内表现为串行的语义

但是这种指令重排序单线程中没有任何问题的,但是在多线程中,就引发了我们上边在执行flag = true后,num的值仍然不能确定是0还是5

抑制重排序

在多线程并发编程的过程中,执行重排序有时候会造成错误的后果,比如一个线程在main线程中调用setFlag(true)的前边修改了某些程序配置项,而在t1线程里需要用到这些配置项,所以会造成配置缺失的错误。但是java给我们提供了一些抑制指令重排序的方式。

同步代码抑制指令重排序

将需要抑制指令重排序的代码放入同步代码块中:

public class Reordering {

    private static boolean flag;
    private static int num;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {

            @Override
            public void run() {
                while (!getFlag()) {
                    Thread.yield();
                }

                System.out.println(num);
            }
        }, "t1");
        t1.start();
        num = 5;
        setFlag(true);
    }

    public synchronized static void setFlag(boolean flag) {
        Reordering.flag = flag;
    }

    public synchronized static boolean getFlag() {
        return flag;
    }
}

在获取锁的时候,它前边的操作必须已经执行完成,不能和同步代码块重排序;在释放锁的时候,同步代码块中的代码必须全部执行完成,不能和同步代码块后边的代码重排序。

图片描述

加了锁之后,num=5就不能和flag=true的代码进行重排序了,所以在线程2中看到的num值肯定是5,而不会是0喽~

虽然抑制重排序可以保证多线程程序按照我们期望的执行顺序进行执行,但是它抑制了处理器对指令执行的优化,原来能并行执行的指令现在只能串行执行,会导致一定程度的性能下降,所以加锁只能保证在执行同步代码块时,它之前的代码已经执行完成,在同步代码块执行完成之前,代码块后边的代码是不能执行的,也就是只保证加锁前、加锁中、加锁后这三部分的执行时序,但是同步代码块之前的代码可以重排序,同步代码块中的代码可以重排序,同步代码块之后的代码也可以进行重排序,在保证执行顺序的基础上,尽最大可能让性能得到提升,比方说下边这段代码:

int i = 1;
int j = 2;
synchronized (Reordering.class) {
    int m = 3;
    int n = 4;
}
int x = 5;
int y = 6;

它的一个执行时序可能是:

图片描述

volatile变量抑制指令重排序

还是那句老话,加锁会导致竞争同一个锁的线程阻塞,造成线程切换,代价比较大,volatile变量也提供了一些抑制指令重排序的语义,上边的程序可以改成这样:

public class Reordering {

    private static volatile boolean flag;
    private static int num;

    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {

            @Override
            public void run() {
                while (!flag) {
                    Thread.yield();
                }

                System.out.println(num);
            }
        });
        t1.start();
        num = 5;
        flag = true;
    }
}
``
也就是把``flag``声明为``volatile变量``,这样也能起到抑制重排序的效果,``volatile变量``具体抑制重排序的规则如下:

1. volatile写之前的操作不会被重排序到volatile写之后。
2. volatile读之后的操作不会被重排序到volatile读之前。
3. 前边是volatile写,后边是volatile读,这两个操作不能重排序。
![图片描述][3]
除了这三条规定以外,其他的操作可以由处理器按照自己的特性进行重排序,换句话说,就是怎么执行着快,就怎么来。比如说:

flag = true;
num = 5;
``
volatile变量之后进行普通变量的写操作,那就可以重排序喽,直到遇到一条volatile读或者有执行依赖的代码才会阻止重排序的过程。

final变量抑制指令重排序

在java语言中,用final修饰的字段被赋予了一些特殊的语义,它可以阻止某些重排序,具体的规则就这两条:

  1. 在构造方法内对一个final字段的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含final字段对象的引用,与随后初次读这个final字段,这两个操作不能重排序。

可能大家看的有些懵逼,赶紧写代码理解一下:

public class FinalReordering {

    int i;
    final int j;

    static FinalReordering obj;

    public FinalReordering() {
        i = 1;
        j = 2;
    }

    public static void write() {
        obj = new FinalReordering();
    }

    public static void read() {
        FinalReordering finalReordering = FinalReordering.obj;
        int a = finalReordering.i;
        int b = finalReordering.j;
    }
}

我们假设有一个线程执行write方法,另一个线程执行read方法。

先看一下对final字段进行写操作时,不同线程执行write方法和read方法的一种可能情况是:

图片描述

从上图中可以看出,普通的字段可能在构造方法完成之后才被真正的写入值,所以另一个线程在访问这个普通变量的时候可能读到了0,这显然是不符合我们的预期的。但是final字段的赋值不允许被重排序到构造方法完成之后,所以在把该字段所在对象的引用赋值出去之前,final字段肯定是被赋值过了,也就是说这两个操作不能被重排序

再来看一下初次读取final字段的情况,下边是不同线程执行write方法和read方法的一种可能情况:
图片描述

从上图可以看出,普通字段的读取操作可能被重排序到读取该字段所在对象引用前边,自然会得到NullPointerException异常喽,但是对于final字段,在读final字段之前,必须保证它前边的读操作都执行完成,也就是说必须先进行该字段所在对象的引用的读取,再读取该字段,也就是说这两个操作不能进行重排序

值得注意的是,读取对象引用与读取该对象的字段是存在间接依赖的关系的,对象引用都没有被赋值,还读个锤子对象的字段喽,一般的处理器默认是不会重排序这两个操作的,可是有一些为了性能不顾一切的处理器,比如alpha处理器,这种处理器是可能把这两个操作进行重排序的,所以这个规则就是给这种处理器贴身设计的~ 也就是说对于final字段,不管在什么处理器上,都得先进行对象引用的读取,再进行final字段的读取。但是并不保证在所有处理器上,对于对象引用读取和普通字段读取的顺序是有序的。

安全性小结

我们上边介绍了原子性操作内存可见性以及指令重排序三个在多线程执行过程中会影响到安全性的问题。

  • synchronized可以把三个问题都解决掉,但是伴随着这种万能特性,是多线程在竞争同一个锁的时候会造成线程切换,导致线程阻塞,这个对性能的影响是非常大的。
  • volatile不能保证一系列操作的原子性,但是可以保证对于一个变量的读取和写入是原子性的,一个线程对某个volatile变量的写入是可以立即对其他线程可见的,另外,它还可以禁止处理器对一些指令执行的重排序。
  • final变量依靠它的禁止重排序规则,保证在使用过程中的安全性。一旦被赋值成功,它的值在之后程序执行过程中都不会改变,也不存在所谓的内存可见性问题。

wayen
203 声望19 粉丝

后端工程师