Java并发编程之指令重排序

1次阅读

共计 4276 个字符,预计需要花费 11 分钟才能阅读完成。

指令重排序
如果说内存可见性问题已经让你抓狂了,那么下边的这个指令重排序的事儿估计就要骂娘了~这事儿还得从一段代码说起:
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 修饰的字段被赋予了一些特殊的语义,它可以阻止某些重排序,具体的规则就这两条:

在构造方法内对一个 final 字段的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
初次读一个包含 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 变量依靠它的禁止重排序规则,保证在使用过程中的安全性。一旦被赋值成功,它的值在之后程序执行过程中都不会改变,也不存在所谓的内存可见性问题。

正文完
 0