指令重排
谈到指令重排,首先来了解一下Java内存模型(JMM)。
JMM的关键技术点都是围绕多线程的原子性、可见性、有序性来建立的。
原子性(Atomicity)
原子性是指一个操作是不可中断的,即使是在多个线程一起执行的时候,一个操作一旦开始,就不会被其它线程干扰。
可见性(Visibility)
可见性是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道这个修改。
对于串行程序来说可见性问题是不存在的,因为在任何一个操作步骤中修改了某个变量,那么后续的步骤中,读取这个变量的值,一定是修改之后的。
但是在并行程序中,如果一个线程修改了某一个全局变量,那么其他线程未必可以马上知道这个改动。(这里涉及到编译器优化重排和硬件优化,这里不重点讲述)
有序性(Ordering)
有序性是指在单线程环境中, 程序是按序依次执行的.
而在多线程环境中, 程序的执行可能因为指令重排而出现乱序。
` class OrderExample {
int a = 0;
boolean flag = false;
public void writer() {
// 以下两句执行顺序可能会在指令重排等场景下发生变化
a = 1;
flag = true;
}
public void reader() {
if (flag) {
int i = a + 1;
……
}
}
}`
假设线程A首先执行write()方法,接着线程B执行reader()方法,如果发生指令重排,那个线程B在执行 int i = a + 1;时不一定能看见a已经被赋值为1了。
指令1 IF ID EX MEN WB
指令2 IF ID EX MEN WB
指令的每一步都由不同的硬件完成,假设每一步耗时1ms,执行完一条指令需耗时5ms,每条指令都按顺序执行,那两条指令则需10ms。
但是通过流水线在指令1刚执行完IF,执行IF的硬件立马就开始执行指令2的IF,这样指令2只需要等1ms,两个指令执行完只需要6ms,效率会有提升巨大!
所以通过流水线技术,可以使得CPU高效执行,当流水线满载时,所有硬件都有序高效执行,但是一旦中断,所有硬件设备都会进入一个停顿期,再次满载
需要几个周期,因此性能损失会比较大,所以必须想办法尽量不让流水线中断!
此时,指令重排的重要性就此体现出来。当然,指令重排只是减少中断的一种技术,实际上,在CPU设计中还会使用更多的软硬件技术来防止中断。
现在来看一下代码 A=B+C 是怎么执行的
现有R1,R2,R3三个寄存器,
LW R1,B IF ID EX MEN WB(加载B到R1中)
LW R2,C IF ID EX MEN WB(加载C到R2中)
ADD R3,R2,R1 IF ID × EX MEN WB(R1,R2相加放到R3)
SW A,R3 IF ID x EX MEN WB(把R3 的值保存到变量A)
在ADD指令执行中有个x,表示中断、停顿,ADD为什么要在这里停顿一下呢?因为这时C还没加载到R2中,只能等待,而这个等待使得后边的所有指令都会停顿一下。
这个停顿可以避免吗?当然是可以的,通过指令重排就可以实现,再看一下下面的例子:
执行A=B+C;D=E-F;
通过将D=E-F执行的指令顺序提前,从而消除因等待加载完毕的时间。
1、LW Rb,B IF ID EX MEN WB
2、LW Rc,C IF ID EX MEN WB
3、LW Re,E IF ID EX MEN WB
4、ADD Ra,Rb,Rc IF ID EX MEN WB
5、LW Rf,F IF ID EX MEN WB
6、SW A,Ra IF ID EX MEN WB
7、SUB Rd,Re,Rf IF ID EX MEN WB
8、SW D,Rd IF ID EX MEN WB
在CPU硬件中断停顿等待的时候 可以加载别的数据,更加有效利用资源,节约时间。如果不指令重排则白白等待,效率较低。
编译器优化
主要指jvm层面的, 如下代码, 在jvm client模式很快就跳出了while循环, 而在server模式下运行, 永远不会停止
`/**
Created by Administrator on 2020/11/19
*/
public class VisibilityTest extends Thread {
private boolean stop;
public void run() {
int i = 0;
while (!stop) {
i++;
}
System.out.println("finish loop,i=" + i);
}
public void stopIt() {
stop = true;
}
public boolean getStop() {
return stop;
}
public static void main(String[] args) throws Exception {
VisibilityTest v = new VisibilityTest();
v.start();
Thread.sleep(1000);
v.stopIt();
Thread.sleep(2000);
System.out.println("finish main");
System.out.println(v.getStop());
}
}`
两者区别在于当jvm运行在-client模式的时候,使用的是一个代号为C1的轻量级编译器,而-server模式启动的虚拟机采用相对重量级,代号为C2的编译器. C2比C1编译器编译的相对彻底,会导致程序启动慢, 但服务起来之后, 性能更高, 同时有可能带来可见性问题.
再来看两个从Java语言规范中摘取的例子, 也是涉及到编译器优化重排, 这里不再做详细解释,可查询相关文档
例子1中有可能出现r2 = 2 并且 r1 = 1;
例子2中是r2, r5值因为都是=r1.x, 编译器会使用向前替换, 把r5指向到r2, 最终可能导致r2=r5=0, r4 = 3;
lfence指令读屏障(Load Barrier),作用是:
保证了lfence前后的Load指令的顺序,防止Load重排序
刷新Load Buffer
mfence指令全屏障(Full Barrier),作用是:
保证了mfence前后的Store和Load指令的顺序,防止Store和Load重排序
保证了mfence之后的Store指令全局可见之前,mfence之前的Store指令要先全局可见
JVM层级:8个hanppens-before原则 4个内存屏障 (LL LS SL SS)
Happen-Before先行发生规则
如果光靠sychronized和volatile来保证程序执行过程中的原子性, 有序性, 可见性, 那么代码将会变得异常繁琐.
JMM提供了8个Happen-Before规则来约束数据之间是否存在竞争, 线程环境是否安全, 具体如下:
4个内存屏障 (LL LS SL SS)
LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
as-if-serial
As-if-serial语义的意思是,所有的动作(Action)都可以为了优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。Java编译器、运行时和处理器都会保证单线程下的as-if-serial语义。
原文:https://www.cnblogs.com/csmblog/p/14008149.html