指令重排
概述
简单的来说就是,出于硬件执行效率方面的考虑,为了进一步提升软件的执行效率,使其更快的将我们的程序执行完毕,除了我们自身代码的编写以外,在执行之前还会将我们程序之中的执行重新打散,将不需要注意数据依赖之间的代码的执行顺序重新打乱(以结果最快的方式来执行),而也因此会导致我们的程序执行和我们所想不一致。
例子
为了方便理解,这里引入更加具体的例子来帮助理解。(仅提供程序方面的示例作为例子介绍) 例如:1.新建对象的过程、2.数值计算的过程
一个对象的创建可以拆分为三个步骤:
- memory = allocate() 分配给新对象内存
- instance(memory) 创建对象
- obj = instance 将对象交给到指定的变量
数值计算的过程
- Integer a = 0; Integer b = 0; Integer c = 0;
- b = 11 + c;
- a = b;
从整个过程来看,这两个工作的实际步骤之中,第二步和第三步都是没有实际上的数据依赖,但是又会导致数据的实际执行过程之中数据的不一致问题。多线程下,就会出现,明明已经发生obj = instance的赋值工作早已完成,但是obj所指向的对象仍然没有创建,也就是指向的还是null,a指向的已经是b,但是b的值还是原来的0这种奇怪的情况出现。
而且指令重排的问题,不仅仅会出现在我们所开发的程序的应用层面,在物理层之中也会出现该问题。我们的CPU也会针对要执行的指令进行重排处理,这样重排就会导致我们的程序可能会出现意想不到的结果。相对应的,为了解决一些不合理的情况下出现的指令重排,CPU、Java Jvm都提供了一些措施来进行处理。
如果还想进一步到指令执行来看到底为什么会导致这个问题,可以参考以下解释:(还是只有代码层面的处理)
同样的总所周知,每当我们的java程序启动一个线程,jvm就会在给他分配一个线程专用的栈体,来处理线程的执行任务。那么线程专用的栈就会保存了线程运行时候的对象信息。而当线程针对某一个对象进行使用的时候,线程栈就会先到堆之中找到这个对象的值,并且将其通过以 load(加载、复制) 的方式线程所专用的空间之中,相当于做了一层隔离处理。而实际线程内部对该变量的操作其实是先对线程内的临时变量先进行了修改,然后最后再赋值到堆之中的变量之中。
上述的处理很有意思的一点就是,他就会导致我们在编写多线程的时候会出现奇奇怪怪的问题,比如说我明明已经在其他线程之中修改了某个条件,下一个线程应该是进不去进行执行的,但实际情况是,线程依然进去了,因为下一个线程看的并不是内存之中的真正现在的值,而是他自己线程池里面的值。那么解决以上的问题就得看下文了。
解决
总的来说,虽然指令重排本质上是为了进一步提升性能的做法,但是实际上还是导致了我们的程序会出现不合理的问题。那么面对这些问题,自然就需要想办法进行解决。
从CPU和硬件方面来说
CPU提供了原语指令或者锁总线方式。
- SFENCE - 写屏障,在写指令之后插入写屏障,使得CPU核心的高速缓存数据失效,重新从内存之中读取数据,更新数据
- LFENCE - 读屏障,在读指令之前插入读屏障,使得CPU和信的高速缓存数据失效,重新从内存之中读取数据,更新数据
- MFENCE - 全屏障,同时具备有 LFENCE和SFENCE的功能
- LOCK + 指令 - 是一种前缀,不是内存屏障,但是做到的效果是跟屏障一致的。
更详细的介绍可以看该文:Memory barrier & Lfence Sfence - 知乎 (zhihu.com)
从代码的层面来说
首先从代码的层面来说,有最基本的四重屏障 SS LL SL LS
-
LoadLoad屏障(读读屏障):对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
(只有UnSafe类可以用到)
-
StoreStore屏障(写写屏障):对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
(Synchronized 和 ReenTranLock就是使用了该屏障)
-
LoadStore屏障(读写屏障):对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
(只有UnSafe类可以用到)
-
StoreLoad屏障(写读屏障):对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
(Volatile使用的就是写读屏障,所以Volatile避免出现指令重排的能力就是来源于内存屏障,其结果就是每次需要的时候都是直接去内存读取对象)
普遍的来说,常见的屏障处理手段是以锁为主的Synchronized和ReentranLock的锁方式实现,但是由于加锁往往对于程序来说消耗资源的处理,所以,除去了这两种方式以外,还有仅仅阻止了指令重排,但是不保证同步的Volatile。
除此以外,JVM还具有八大happens-before原则来尽量的避免指令重排带来的问题,其中包括就有:
- 顺序原则:一个线程内保证语义的串行性; a = 1; b = a + 1;
- volatile规则:volatile变量的写,先发生于读,这保证了volatile变量的可见性,
- 锁规则:解锁(unlock)必然发生在随后的加锁(lock)前.
- 传递性:A先于B,B先于C,那么A必然先于C.
- 线程的start()方法先于它的每一个动作.
- 线程的所有操作先于线程的终结(Thread.join()).
- 线程的中断(interrupt())先于被中断线程的代码.
- 对象的构造函数执行结束先于finalize()方法.
附详文
如果需要了解更多,可以参考以下文章:(包含一部分例子)