在写代码时,你是否有过这样的认识,代码执行的执行过程就是代码编写的过程,毕竟debug的时候,F6(idea是F7)一直都是这样工作的。但是实际的运行的过程可能会打破你的认知。运行环境会根据自己的意愿来优化代码的执行顺序,但是不会影响程序员的开发意图,也就是说不会影响代码执行结果。总结来说重排序其实是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。举个例子说:
a=1;
b=3;
c=a+b;
从惯性角度来看,代码的书写顺序就是执行顺序,但是真正的执行顺序可能会
b=3;
a=1;
c=a+b;
这样的执行结果并不影响程序的计算结果。
上面已经提到重排序是编译器和处理器优化代码的手段,所以我们也可以将重排序分为三类
1、编译器优化的重排序。
编译器在不改变单线程程序的语义的前提下,可以重新安排语句的执行顺序。这可能是我们唯一可见的重排序,在比对class文件的时候相信你会看见,当然只是场景之一。
2、指令级并行的重排序。
代码最终被执行的并不是我们所编写的代码,而是被计算机才能熟知的计算机指令。Java作为高级编程语言,是更容易被开发人员理解和熟悉的一种语言规范,同时也是一种高封装的一种语言。一行java语句不一定就会转换成一条计算机指令。
比如:
j+=j+1
这行代码最终会被转换成三条计算机指令在计算机上执行。现代处理器采用了指令级并行技术(Instruction-Level-Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依懒性,处理器可以改变语句对应机器指令的执行顺序。
3、内存系统的重排序。
由于处理器使用缓存和读/ 写缓冲区,这使得加载和存储操作看上去可能是乱执行。这一点也是比较隐蔽的,也是真正的看不见和摸不着的。
我们归纳出来从java源码到最终实际执行的指令序列,会分别经历下面三种重排序。后面两种属于处理器重排序优化。
重排序后的先甜后苦
上面说重排序是为了为了优化程序性能。其实再你还没有细细品味性能优化的甜头的时候,重排序的苦果就端到了你的面前。无论是编译器重排序还是处理器重排序,都会导致多线程程序出现内存可见性的问题。 来看个例子:
Class Reordering {
int x = 0, y = 0;
public void writer() {
x = 1;
y = 2;
}
public void reader() {
int r1 = y;
int r2 = x;
}
}
假设线程A和线程B同时执行,线程A调用writer方法,线程B调用read方法,如果b读到y=2,那么x读到一定是1吗?当然不一定!!!在writer方法中,x和y的赋值可能发生重排序,y在x之前就已经写入了,x可能仍然是初始值0。
再看下流程图:
实线表示线程的顺序运行情况
虚线表示发生重排序的线程运行情况
在虚线的执行中,al线程xy发生了重排序,在执行了y的写入后,发生了线程切换,A线程放弃了CPU的占有权,B线程获取了CPU的占有权,当执读到y时,显示值为2,而x并未发生写入操作,所以此时的值仍为1。(并发场景比较多,案例并未考虑缓存一致性等问题,只是为了说明重排序的场景)。
再来看一个更经典的例子:
public class Singleton {
static Singleton instance;
static Singleton getInstance(){
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null)
instance = new Singleton();
}
}
return instance;
}
}
从代码层看这段代码确实看不出什么问题,但是到了多线程的场景下就不一样了。而问题就发生在 instance = new Singleton();假设有两个线程 A、B 同时调用 getInstance() 方法,他们会同时发现 instance == null ,于是同时对 Singleton.class 加锁,此时 JVM 保证只有一个线程能够加锁成功(假设是线程 A),另外一个线程则会处于等待状态(假设是线程 B);线程 A 会创建一个 Singleton 实例,之后释放锁,锁释放后,线程 B 被唤醒,线程 B 再次尝试加锁,此时是可以加锁成功的,加锁成功后,线程 B 检查 instance == null 时会发现,已经创建过 Singleton 实例了,所以线程 B 不会再创建一个 Singleton 实例。
这看上去一切都很完美,无懈可击,但实际上这个 getInstance() 方法并不完美。问题出在哪里呢?出在 new 操作上,我们以为的 new 操作应该是:
分配一块内存 M;
在内存 M 上初始化 Singleton 对象;
然后 M 的地址赋值给 instance 变量。
但是实际上优化后的执行路径却是这样的:
分配一块内存 M;
将 M 的地址赋值给 instance 变量;
最后在内存 M 上初始化 Singleton 对象。
优化后会导致什么问题呢?我们假设线程 A 先执行 getInstance() 方法,当执行完指令 2 时恰好发生了线程切换,切换到了线程 B 上;如果此时线程 B 也执行 getInstance() 方法,那么线程 B 在执行第一个判断时会发现 instance != null ,所以直接返回 instance,而此时的 instance 是没有初始化过的,如果我们这个时候访问 instance 的成员变量就可能触发空指针异常。
看下流程图:
重排序真的可以这么肆意妄为?当然不会编译器和处理器为了保证结果的正确性,在重排序之前就会有一定的约束性:
- 数据依赖性
当然并非所有情况都会产生重排序,编译优化也需要遵循一定的规则那就是数据依赖性。如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。
编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。(不过这只是在单处理器的指定操作和单线程的执行操作下, 不同处理器的不同线程不在编译器和处理器的考虑范围内。)
在遵守数据依赖性的同时也会遵守两大原则:
- as-if-serial规则
as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。
- 程序顺序原则
根据happens- before的程序顺序规则,上面计算圆的面积的示例代码存在3个happens-before关系。
1)A happens-before B。
2)B happens-before C。
3)A happens-before C。
这里的第3个happens-before关系,是根据happens-before的传递性推导出来的。
这里A happens- before B,但实际执行时B却可以排在A之前执行(看上面的重排序后的执行顺序)。如果A happens-before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B按happens- before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法(not illegal),JMM允许这种重排序。
JMM针对重拍还会有其他对限制:
对于编译器重排序,JMM(Java内存模型)编译器重排序规则会禁止特定类型的编译器重排序。
对于处理器重排序,JMM处理器重排序规则,会要java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。