指令重排序
如果说 内存可见性
问题已经让你抓狂了,那么下边的这个指令重排序的事儿估计就要骂娘了~这事儿还得从一段代码说起:
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读或者有执行依赖的代码才会阻止重排序的过程。
在java语言中,用 fina
l修饰的字段被赋予了一些特殊的语义,它可以阻止某些重排序,具体的规则就这两条:
在构造方法内对一个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
变量依靠它的禁止重排序规则,保证在使用过程中的安全性。一旦被赋值成功,它的值在之后程序执行过程中都不会改变,也不存在所谓的 内存可见性
问题。