一个cpu下有多个核心,每个核心有自己的L1,L2级缓存,所有核公用一个L3级缓存
因为存在CPU缓存一致性协议(例如MESI),多个CPU核心之间缓存不会出现不同步的问题(L1L2L3是同步的),即不存在内存可见性问题。
缓存一致性协议对性能有很大损耗,为了解决这个问题,又进行了各种优化。例如,在计算单元和
L1之间加了Store Buffer、Load Buffer(还有其他各种Buffer)
L1、L2、L3和主内存之间是同步的,有缓存一致性协议的保证,但是Store Buffer、Load Buffer和
L1之间却是异步的。向内存中写入一个变量,这个变量会保存在Store Buffer里面,稍后才异步地写入
L1中,同时同步写入主内存中。
重排序和内存可见性的关系
Store Buffer的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)。比如上图中有写入和读取两个指令,分别在core0和core1中运行,写入指令向store buffer里面写入,然后store buffer向L3缓存中写入,读取指令读数据的时候,通过load buffer来读取L3的数据。本来是写入先运行完,读取后运行完,因为有buffer的原因导致读取指令通过load buffer先读取了L3的数据。、
重排序类型:
- 编译器重排序。
对于没有先后依赖关系的语句,编译器可以重新调整语句的执行顺序。
编译的时候优化,编译器将没有先后依赖关系的语句顺序调整,提高代码效率。 - CPU指令重排序。
在指令级别,让没有依赖关系的多条指令并行。 - CPU内存重排序。(造成内存可见性的主因)
CPU有自己的缓存,指令的执行顺序和写入主内存的顺序不完全一致。
内存屏障
为了禁止编译器重排序和 CPU 重排序,在编译器和 CPU 层面都有对应的指令,也就是内存屏障
(Memory Barrier)。
编译器的内存屏障,只是为了告诉编译器不要对指令进行重排序。当编译完成之后,这种内存屏障
就消失了,CPU并不会感知到编译器中内存屏障的存在。
而CPU的内存屏障是CPU提供的指令,可以由开发者显式调用。
基本的CPU内存屏障大致可以分成四种:
- LoadLoad:禁止读和读的重排序。
- StoreStore:禁止写和写的重排序。
- LoadStore:禁止读和写的重排序。
- StoreLoad:禁止写和读的重排序。
Java在Unsafe类中提供了三个内存屏障函数
public final class Unsafe {
// ...
//这里的内存屏障函数都是本地方法
// loadFence=LoadLoad+LoadStore 读读和读写不重排
public native void loadFence();
// storeFence=StoreStore+LoadStore 写写和读写不重排
public native void storeFence();
// fullFence=LoadLoad+LoadStore+StoreStore+StoreLoad 全部不重排
public native void fullFence();
// ...
}
as-if-serial
as-if-serial 语义(就像是序列化执行了一样)
1.单线程程序的重排序规则
无论什么语言,站在编译器和CPU的角度来说,不管怎么重排序,单线程程序的执行结果不能改
变,这就是单线程程序的重排序规则。
即只要操作之间没有数据依赖性,编译器和CPU都可以任意重排序,因为执行结果不会改变,代码
看起来就像是完全串行地一行行从头执行到尾,这也就是as-if-serial语义。
对于单线程程序来说,编译器和CPU可能做了重排序,但开发者感知不到,也不存在内存可见性问
题。
2.多线程程序的重排序规则
编译器和CPU的这一行为对于单线程程序没有影响,但对多线程程序却有影响。
对于多线程程序来说,线程之间的数据依赖性太复杂,编译器和CPU没有办法完全理解这种依赖性
并据此做出最合理的优化。
编译器和CPU只能保证每个线程的as-if-serial语义。
线程之间的数据依赖和相互影响,需要编译器和CPU的上层来确定。
上层要告知编译器和CPU在多线程场景下什么时候可以重排序,什么时候不能重排序。
使用happen-before描述两个操作之间的内存可见性。
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还具有传递性,即若A happen-before B,B happen-before C,则A happen-before C。
/**
*假设线程A先调用了set,设置了a=5;之后线程B调用了get,返回值一定是a=5。为什么呢?
*操作1和操作2是在同一个线程内存中执行的
*操作1 happen-before 操作2
*同理,操作3 happen- before操作4。
*又因为c是volatile变量,对c的写入happen-before对c的读取,所以操作2 happen- before操作3。
*利用happen-before的传递性,就得到:
*操作1 happen-before 操作2 happen-before 操作3 happen-before操作4。
*所以,操作1的结果,一定对操作4可见。
*/
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
}
}
synchronized同样具有happen-before语义。
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。
线程A:
加锁; // 操作1
a=5; // 操作2
c=1; // 操作3
解锁; // 操作4
线程B:
加锁; // 操作5
读取a; // 操作6
解锁; // 操作7
操作1happen-before操作2…happen-before操作7。所以,a、c都不是volatile变量,但仍然有内存可见性。