内存重排序
上一章描述了内存可见性问题、那么这章到了重点 内存重排序、volatile 与 synchronized 为什么能保证线程安全、原子操作
重排序与内存可见性的关系
Store Buffer 的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)。除此之外,还 有编译器和CPU的指令重排序。 重排序类型:
- 编译器重排序。 对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
- CPU指令重排序。 在指令级别,让没有依赖关系的多条指令并行。
- CPU内存重排序。
CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。
在三种重排序中,第三类就是造成“内存可见性”问题的主因,如下案例:
线程1:
X=1 a=Y 线程2:
Y=1 b=X
假设X、Y是两个全局变量,初始的时候,X=0,Y=0。请问,这两个线程执行完毕之后,a、b的正 确结果应该是什么? 很显然,线程1和线程2的执行先后顺序是不确定的,可能顺序执行,也可能交叉执行,最终正确的 结果可能是:
- a=0,b=1
- a=1,b=0
- a=1,b=1 也就是不管谁先谁后,执行结果应该是这三种场景中的一种。但实际可能是a=0,b=0。 两个线程的指令都没有重排序,执行顺序就是代码的顺序,但仍然可能出现a=0,b=0。原因是线程 1先执行 X=1,后执行 a=Y,但此时 X=1 还在自己的 Store Buffer 里面,没有及时写入主内存中。所以,线 程2看到的X还是0。线程2的道理与此相同。 虽然线程1觉得自己是按代码顺序正常执行的,但在线程2看来,a=Y和X=1顺序却是颠倒的。指令没 有重排序,是写入内存的操作被延迟了,也就是内存被重排序了,这就造成内存可见性问题。
内存屏障
为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障 (Memory Barrier)。这也正是JMM和happen-before规则的底层实现原理。 编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障 就消失了,CPU并不会感知到编译器中内存屏障的存在。 而CPU的内存屏障是CPU提供的指令,可以由开发者显示调用。 内存屏障是很底层的概念,对于 Java 开发者来说,一般用 volatile 关键字就足够了。但从JDK 8开 始,Java在Unsafe类中提供了三个内存屏障函数,如下所示。
public final class Unsafe { // ... public native void loadFence(); public native void storeFence(); public native void fullFence(); // ... }
在理论层面,可以把基本的CPU内存屏障分成四种:
- LoadLoad:禁止读和读的重排序。
- StoreStore:禁止写和写的重排序。
- LoadStore:禁止读和写的重排序。
- StoreLoad:禁止写和读的重排序。 Unsafe中的方法:
- loadFence=LoadLoad+LoadStore
- storeFence=StoreStore+LoadStore
- fullFence=loadFence+storeFence+StoreLoad
as-if-serial语义
重排序的原则是什么?什么场景下可以重排序,什么场景下不能重排序呢?
1.单线程程序的重排序规则
无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行结果不能改 变,这就是单线程程序的重排序规则。 即只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码 看起来就像是完全串行地一行行从头执行到尾,这也就是as-if-serial语义。 对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问 题。
2.多线程程序的重排序规则
编译器和CPU的这一行为对于单线程程序没有影响,但对多线程程序却有影响。 对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性 并据此做出最合理的优化。 编译器和CPU只能保证每个线程的as-if-serial语义。 线程之间的数据依赖和相互影响,需要编译器和CPU的上层来确定。 上层要告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。
happen-before
使用happen-before描述两个操作之间的内存可见性。 public final class Unsafe { // ... public native void loadFence(); public native void storeFence(); public native void fullFence(); // ... } 1234567 java内存模型(JMM)是一套规范,在多线程中,一方面,要让编译器和CPU可以灵活地重排序; 另一方面,要对开发者做一些承诺,明确告知开发者不需要感知什么样的重排序,需要感知什么样的重 排序。然后,根据需要决定这种重排序对程序是否有影响。如果有影响,就需要开发者显示地通过 volatile、synchronized等线程同步机制来禁止重排序。 关于happen-before: 如果A happen-before B,意味着A的执行结果必须对B可见,也就是保证跨线程的内存可见性。A happen before B不代表A一定在B之前执行。因为,对于多线程程序而言,两个操作的执行顺序是不确 定的。happen-before只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约 束,也就定义了一系列重排序的约束。 基于happen-before的这种描述方法,JMM对开发者做出了一系列承诺:
- 单线程中的每个操作,happen-before 对应该线程中任意后续操作(也就是 as-if-serial语义保 证)。
- 对volatile变量的写入,happen-before对应后续对这个变量的读取。
- 对synchronized的解锁,happen-before对应后续对这个锁的加锁。 …… JMM对编译器和CPU 来说,volatile 变量不能重排序;非 volatile 变量可以任意重排序。
happen-before的传递性
除了这些基本的happen-before规则,happen-before还具有传递性,即若A happen-before B,B happen-before C,则A happen-before C。 如果一个变量不是volatile变量,当一个线程读取、一个线程写入时可能有问题。那岂不是说,在多 线程程序中,我们要么加锁,要么必须把所有变量都声明为volatile变量?这显然不可能,而这就得归功 于happen-before的传递性。
class A {
private int a = 0;
private volatile int c = 0;
public void set() {
a = 5; // 操作1 c = 1; // 操作2
}
public int get() {
int d = c; // 操作3 return a; // 操作4
}
}
假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值一定是a=5。为什么呢? 操作1和操作2是在同一个线程内存中执行的,操作1 happen-before 操作2,同理,操作3 happenbefore操作4。又因为c是volatile变量,对c的写入happen-before对c的读取,所以操作2 happenbefore操作3。利用happen-before的传递性,就得到: 操作1 happen-before 操作2 happen-before 操作3 happen-before操作4。 所以,操作1的结果,一定对操作4可见。
class A {
private int a = 0;
private int c = 0;
public synchronized void set() {
a = 5; // 操作1
c = 1; // 操作2
}
public synchronized int get() {
return a;
}
}
假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值也一定是a=5。 因为与volatile一样,synchronized同样具有happen-before语义。展开上面的代码可得到类似于下 面的伪代码:
线程A:
加锁; // 操作1
a= 5; // 操作2
c = 1; // 操作3
解锁; // 操作4
线程B:
加锁; // 操作5
读取a; // 操作6
解锁; // 操作7
根据synchronized的happen-before语义,操作4 happen-before 操作5,再结合传递性,最终就 会得到: 操作1 happen-before 操作2……happen-before 操作7。所以,a、c都不是volatile变量,但仍然有 内存可见性。
本文由 chaoohuua 创作,采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
最后编辑时间为:
2021/04/13 22:17