在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
1属于编译器重排序,2和3属于处理器重排序。从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序:
那么为啥要用指令重排序呢?
编译期重排序有啥好处?
CPU计算的时候要访问值,如果常常利用到寄存器中已有的值就不用去内存读取了,先看JAVA中String.intern的理解一个例子
String a = new String("ab");
String b = new String("ab");
String c = "ab";
String d = "a" + "b";
String e = "b";
String f = "a" + e;
System.out.println(b.intern() == a);
System.out.println(b.intern() == c);
System.out.println(b.intern() == d);
System.out.println(b.intern() == f);
System.out.println(b.intern() == a.intern());
运行结果:
false
true
true
false
true
由运行结果可以看出来,b.intern() == a和b.intern() == c可知,
采用new 创建的字符串对象不进入字符串池,并且通过b.intern() == d和b.intern() == f可知,字符串相加的时候,都是静态字符串的结果会添加到字符串池,如果其中含有变量(如f中的e)则不会进入字符串池中。但是字符串一旦进入字符串池中,就会先查找池中有无此对象。如果有此对象,则让对象引用指向此对象。如果无此对象,则先创建此对象,再让对象引用指向此对象
回到刚才的问题,String c = "ab"执行后String d = "a" + "b",就无需再创建对象或者字符串了,直接从常量池里面获得引用,性能自然会有所优化了。
处理器为啥要重排序?
因为一个汇编指令也会涉及到很多步骤,每个步骤可能会用到不同的寄存器,现在的CPU一般采用流水线来执行指令,也就是说,CPU有多个功能单元(如获取、解码、运算和结果),一个指令的执行被分成:取指、译码、访存、执行、写回、等若干个阶段,**流水线是并行的, **第一条指令执行还没完毕,就可以执行第二条指令,前提是这两条指令功能单元相同或类似,所以一般可以通过指令重排使得具有相似功能单元的指令接连执行来减少流水线中断的情况。
具体的编译优化请看javac 编译与 JIT 编译
我们写一段代码来试试:
package *****;
/**
* reorder
* @author Mageek Chiu
* @date 2018/5/25 0025:12:49
*/
public class ReOrder {
public int value ;
private ReOrder(int value) {
this.value = value;
}
public static void main(String... args){
ReOrder reOrder = new ReOrder(111);
ReOrder reOrder1 = new ReOrder(222);
ReOrder reOrder2 = new ReOrder(333);
System.out.println(add1(reOrder,reOrder1,reOrder2));
}
static int add1(ReOrder reOrder,ReOrder reOrder1,ReOrder reOrder2){
int result = 0;
result += reOrder.value;
result += reOrder1.value;
result += reOrder2.value;//***
result += reOrder.value;
result += reOrder1.value;
result += reOrder2.value;
result += reOrder.value;
result += reOrder1.value;
result += reOrder2.value;
return result;
}
}
运行结果中:
# {method} {0x000000001c402c80} 'add1' '(*****/ReOrder;*****/ReOrder;*****/ReOrder;)I' in '*****/ReOrder'
# parm0: rdx:rdx = '*****/ReOrder'
# parm1: r8:r8 = '*****/ReOrder'
# parm2: r9:r9 = '*****/ReOrder'
# [sp+0x20] (sp of caller)
0x00000000032a86c0: mov dword ptr [rsp+0ffffffffffffa000h],eax
0x00000000032a86c7: push rbp
0x00000000032a86c8: sub rsp,10h ;*synchronization entry
; - *****.ReOrder::add1@-1 (line 24)
0x00000000032a86cc: mov r11d,dword ptr [rdx+0ch]
;*getfield value
; - *****.ReOrder::add1@4 (line 26)
; implicit exception: dispatches to 0x00000000032a86ff
0x00000000032a86d0: mov r10d,dword ptr [r8+0ch] ;*getfield value
; - *****.ReOrder::add1@11 (line 27)
; implicit exception: dispatches to 0x00000000032a870d
0x00000000032a86d4: mov r9d,dword ptr [r9+0ch] ;*getfield value
; - *****.ReOrder::add1@18 (line 28)
; implicit exception: dispatches to 0x00000000032a8719
0x00000000032a86d8: mov eax,r11d
0x00000000032a86db: add eax,r10d
0x00000000032a86de: add eax,r9d
0x00000000032a86e1: add eax,r11d
0x00000000032a86e4: add eax,r10d
0x00000000032a86e7: add eax,r9d
0x00000000032a86ea: add eax,r11d
0x00000000032a86ed: add eax,r10d
0x00000000032a86f0: add eax,r9d ;*iadd
也就是先用mov
把方法里面所需要的三个value
加载了,再统一用add进行加法运算。
现在我们把//***
哪一行注释掉,运行结果如下:
[Constants]
# {method} {0x000000001c052c78} 'add1' '(*****/ReOrder;*****/ReOrder;*****/ReOrder;)I' in '*****/ReOrder'
# parm0: rdx:rdx = '*****/ReOrder'
# parm1: r8:r8 = '*****/ReOrder'
# parm2: r9:r9 = '*****/ReOrder'
# [sp+0x20] (sp of caller)
0x0000000002f47d40: mov dword ptr [rsp+0ffffffffffffa000h],eax
0x0000000002f47d47: push rbp
0x0000000002f47d48: sub rsp,10h ;*synchronization entry
; - *****.ReOrder::add1@-1 (line 24)
0x0000000002f47d4c: mov r11d,dword ptr [rdx+0ch]
;*getfield value
; - *****r.ReOrder::add1@4 (line 26)
; implicit exception: dispatches to 0x0000000002f47d7c
0x0000000002f47d50: mov r10d,dword ptr [r8+0ch] ;*getfield value
; - *****.ReOrder::add1@11 (line 27)
; implicit exception: dispatches to 0x0000000002f47d89
0x0000000002f47d54: mov r9d,dword ptr [r9+0ch] ;*getfield value
; - *****::add1@32 (line 32)
; implicit exception: dispatches to 0x0000000002f47d95
0x0000000002f47d58: mov eax,r11d
0x0000000002f47d5b: add eax,r10d
0x0000000002f47d5e: add eax,r11d
0x0000000002f47d61: add eax,r10d
0x0000000002f47d64: add eax,r9d
0x0000000002f47d67: add eax,r11d
0x0000000002f47d6a: add eax,r10d
0x0000000002f47d6d: add eax,r9d ;*iadd
依然是先把所有value都用mov指令加载后再进行加法运算。
总结起来就是不管代码里这个值使用顺序多靠后,都先用mov加载后再使用add对这个值进行运算。
注意,上面的运行参数为:
-Xcomp -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*ReOrder.add1 -XX:+PrintCompilation
。
Xcomp
含义是使用编译模式而不是解释模式,
-XX:CompileCommand=print,*ReOrder.add1
表示只打印这个方法,
-XX:+PrintCompilation
表示打印方法名称。
需要插件hsdis,编译好后放在jdk的jre的bin的server
中就好,具体环境搭建可以参阅这里
这里提前对下面的一个例子的重排序解释一下:
int a = 0;
boolean flag = false;
public synchronized void init() {
a = 1; // 1
flag = true; // 2
//.......
}
init()方法将会执行重排序,因为true已经存在,而常量1不存在,所以先执行flag=true 再执行a=1
这个例子如果多线程情况下,指令重排序将会更加复杂,可能产生不一样的结果,那么我们又应该如何保证正确的内存加载呢?(线程的同步和锁这里不展开讨论)
先看几个概念:
as-if-serial 语义
as-if-serial的意思是:不管指令怎么重排序,在单线程下执行结果不能被改变。不管是编译器级别还是处理器级别的重排序都必须遵循as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。但是as-if-serial规则允许对有控制依赖关系的指令做重排序,因为在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果,但是多线程下确有可能会改变结果。
数据依赖
int a = 1; // 1
int b = 2; // 2
int c = a + b; // 3
上述代码,a
和b
不存在依赖关系,所以1、2可以进行重排序;c
依赖 a
和b
,所以3必须在1、2的后面执行。
控制依赖
public void use(boolean flag, int a, int b) {
if (flag) { // 1
int i = a * b; // 2
}
}
flag
和i
存在控制依赖关系。当指令重排序后,2这一步会将结果值写入重排序缓冲(Reorder Buffer,ROB)的硬件缓存中,当判断为true时,再把结果值写入变量i中。
happens-before 语义
JSR-133使用happens-before的概念来阐述操作之间的内存可见性。在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个 操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一 个操作按顺序排在第二个操作之前(the first is visible to and ordered before the second)。
happens-before 部分规则
程序顺序规则: 一个线程中的每个操作,happens-before于该线程中的任意后续操作。
主要含义是:在一个线程内不管指令怎么重排序,程序运行的结果都不会发生改变。和as-if-serial 比较像。监视器锁规则: 对一个锁的解锁,happens-before于随后对这个锁的加锁。
主要含义是:同一个锁的解锁一定发生在加锁之后管程锁定规则: 一个线程获取到锁后,它能看到前一个获取到锁的线程所有的操作结果。
主要含义是:无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步原语,synchronized就是管程的实现)volatile变量规则: 对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
主要含义是:如果一个线程先去写一个volatile变量,然后另一个线程又去读这个变量,那么这个写操作的结果一定对读的这个线程可见。传递性: 如果A happens-before B,且B happens-before C,那么A happens-before C。
start()规则: 如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。
主要含义是:线程A在启动子线程B之前对共享变量的修改结果对线程B可见。join()规则: 如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
主要含义是:如果在线程A执行过程中调用了线程B的join方法,那么当B执行完成后,在线程B中所有操作结果对线程A可见。线程中断规则: 对线程interrupt方法的调用happens-before于被中断线程的代码检测到中断事件的发生。
主要含义是:响应中断一定发生在发起中断之后。对象终结规则: 就是一个对象的初始化的完成,也就是构造函数执行的结束一定 happens-before它的finalize()方法。
一个happens-before规则对应于一个或多个编译器和处理器重排序规则。
as-if-serial和happens-before的主要作用都是:在保证不改变程序运行结果的前提下,允许部分指令的重排序,最大限度的提升程序执行的效率。
内存屏障
我们先来看一个并发环境下指令重排序带来的问题:
这里有两个线程A和线程B,当A执行init
方法时发生了指令重排,2先执行,这时线程B执行use
方法,这时我们拿到的变量a却还是0,所以最后得到的结果 i=0,而不是i=1。
如何解决上述问题呢?
一种是使用内存屏障(volatile)
另一种使用临界区(synchronized )。
如果我们使用内存屏障,那么JMM的处理器,会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
内存屏障的类型
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂 贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。
常见处理器允许的重排序类型的列表,“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序:
那么上面的问题,我们可以在flag
处插入一个内存屏障,其作用是:保证在init()
方法中,第1步操作一定在第2步之前,禁止第1步和第2步操作出现指令重排序,代码如下:
public class ControlDep {
int a = 0;
volatile boolean flag = false;
public void init() {
a = 1; // 1
flag = true; // 2
//.......
}
public void use() {
if (flag) { // 3
int i = a * a; // 4
}
//.......
}
}
A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。也就是说程序执行执行完第2步的时候,处理器会将第2步和其之前的所有结果强制刷新到主内存。也就是说
a=1
也会被强制刷新到主内存中。那么当另一个线程执行到步骤3的时候,如果判断到flag=true
时,那么第4步处a一定是等于1的,这样就保证了程序的正确运行。
顺序一致性
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行。
- (不管程序是否同步)所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
JMM对正确同步的多线程程序的内存一致性做了如下保证:如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
我们看到JMM仅仅是保证了程序运行的结果是和顺序执行是一致,并没有实现真正的顺一致性。它又是怎么实现的呢?
JMM使用了临界区(加锁)来保证程序的顺序执行,但是在临界区内是允许出现指令重排的(JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。
我们在回过来看下上面遇到的并发问题,在上面我们说了使用内存屏障来解决,这里我们使用临界区。
临界区synchronized
public class ControlDep {
int a = 0;
boolean flag = false;
public synchronized void init() {
a = 1; // 1
flag = true; // 2
//.......
}
public synchronized void use() {
if (flag) { // 3
int i = a * a; // 4
}
//.......
}
}
虽然线程A执行init()
方法时,在临界区内做了重排序,但由于监视器互斥执行的特性,线程B执行use()
方法时,根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
从这里我们可以看到,JMM在具体实现上的基本方针为:在不改变(正确同步的)程序执 行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门。
volatile的内存语义
volatile的特性
- 可见性: 对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
- 原子性: 对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性(++不具备原子性,volatile直接赋值具备原子性)
理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这 些单个读/写操作做了同步。下面通过具体的示例来说明,示例代码如下:
class VolatileFeaturesExample {
// 使用volatile声明64位的long型变量
volatile long vl = 0L;
// 普通long型变量
volatile long v2 = 0L;
public void set(long l) {
// 单个volatile变量的写
vl = l;
}
public synchronized void syncSet(long l) {
// 单个volatile变量的写执行效果等价于对普通变量的加同步锁来写
v2 = l;
}
public long get() {
// 单个volatile变量的读
return vl;
}
public synchronized long syncGet() {
// 单个volatile变量的读执行效果等价于对普通变量的加同步锁来读
return vl;
}
public void getAndIncrement() {
// 复合(多个)volatile变量的读/写 不具备原子性
vl++;
// v1++ 等价于 如下代码(不具备原子性)
long temp = syncGet();
temp = temp + 1;
syncSet(temp);
}
}
线程A在线程B先后执行结果会不一致,因为volatile是在读取的时候会同步,而同步的时候temp = temp + 1; syncSet(temp);不一定已经执行完毕,只有将三个语句用一个锁同步,才能保证原子性(++不具备原子性,volatile直接赋值具备原子性)。
volatile写和读的内存语义
-
volatile写的内存语义: 当写一个volatile变量时,JMM会把该线程对应的本地内存中的所有共享变量值刷新到主内存
-
volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取所有共享变量。
volatile内存语义的实现
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
具体限制规则如下
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
具体插入的内存屏障
-
在每个volatile写操作的前面插入一个StoreStore屏障。在每个volatile写操作的后面插入一个StoreLoad屏障。
-
在每个volatile读操作的后面插入一个LoadLoad屏障。在每个volatile读操作的后面插入一个LoadStore屏障。
锁的内存语义
- 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A 对共享变量所做修改的)消息。
- 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的临界区代码必须从主内存中读取共享变量。线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共 享变量所做修改的)消息。
线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发送消息。
final的内存语义
- 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。也就是说只有将对象实例化完成后,才能将对象引用赋值给变量。
- 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。也就是下面示例的4和5不能重排序。
- 当final域为引用类型时,在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
下面通过代码在说明一下:
public class FinalExample {
int i; // 普通变量
final int j; // final变量
static FinalExample obj;
public FinalExample() { // 构造函数
i = 1;// 写普通域
j = 2;// 写final域
}
public static void writer() { // 写线程A执行
// 这一步实际上有三个指令,如下:
// memory = allocate(); // 1:分配对象的内存空间
// ctorInstance(memory); // 2:初始化对象
// instance = memory; // 3:设置instance指向刚分配的内存地址
obj = new FinalExample();
}
public static void reader() { // 读线程B执行
FinalExample object = obj; // 4\. 读对象引用
int a = object.i; // 5\. 读普通域
int b = object.j; // 读final域
}
}
- 如果没有final语义的保证,在
writer()
方法中,那三个指令可能发生重排序,导致步骤3先于2执行,然后线程B在执行reader()
方法时拿到一个没有初始化的对象。 - 在读一个对象的final域之前,一定会先读包含这个final 域的对象的引用。在这个示例程序中,如果该引用不为null,那么引用对象的final域一定已经 被A线程初始化过了。
final语义在处理器中的实现
- 会要求编译器在final域的写之后,构造函数return之前插入一个StoreStore障屏。
- 读final域的重排序规则要求编译器在读final域的操作前面插入一个LoadLoad屏障。