CPU 4核
下,L1、L2、L3三级缓存与主内存的布局。 每个核上面有L1、L2缓存,L3缓存
为所有核共用。CPU缓存一致性
协议,例如MESI,多个CPU核心之间缓存不会出现不同步的问题,不会有 “内存可见性”问题。性能有很大损耗
,为了解决这个问题,又进行了各种优化。例如,在计算单元和 L1之间加了Store Buffer、Load Buffer(还有其他各种Buffer),如下图:L1、L2、L3
和主内存之间是同步
的,有缓存一致性协议的保证,但是Store Buffer、Load Buffer和 L1之间却是异步
的。向内存中写入一个变量,这个变量会保存在Store Buffe
r里面,稍后才异步地写入 L1中,同时同步写入主内存中。CPU
。每个逻辑CPU都有自己的缓存
,这些缓存和主内存之间不是完全同步
的。Store Buffer(存储缓冲区)
的延迟写入是重排序的一种,称为内存重排序(Memory Ordering)
。除此之外,还 有编译器和CPU的指令重排序。
第三类
就是造成内存可见性
问题的主因,如下案例:// 线程1中
x=1;
a=y;
// 线程2中
y=1;
b=x;
1. a=0,b=1
2. a=1,b=0
3. a=1,b=1
线程1
先执行x=1,后执行a=Y,但此时x=1还在自己的Store Buffer(存储缓冲区)
里面,没有及时写入主内存中。所以,线程2看到的x还是0。线程2的道理与此相同。编译器重排序
和 CPU 重排序
,在编译器和 CPU 层面都有对应的指令,也就是内存屏障 (Memory Barrier)
。这也正是JMM
和happen-before规则
的底层实现原理。CPU提供
的指令,可以由开发者显示调用。volatile
关键字就足够了。但从JDK 8开 始,Java在Unsafe类中提供了三个内存屏障函数,如下所示。public final class Unsafe {
// ...
public native void loadFence();
public native void storeFence();
public native void fullFence();
// ...
}
单线程程序
来说,编译器和CPU可能做了重排序
,但开发者感知不到,也不存在内存可见性问题。依赖性太复杂
,编译器和CPU没有办法完全理解这种依赖性、并据此做出最合理的优化。每个线程
的as-if-serial语义。使用happen-before描述两个操作之间的内存可见性。
volatile
、synchronized
等线程同步机制来禁止重排序。happen-before(在.. 之前)
B,意味着A的执行结果必须对B可见,也就是保证线程间的内存可见性。A happen before B不代表A一定在B之前执行
。因为,对于多线程程序而言,两个操作的执行顺序是不确定的。happen-before只确保如果A在B之前执行,则A的执行结果必须对B可见。定义了内存可见性的约束,也就定义了一系列重排序的约束。 volatile
变量的写入,happen-before对应 后续对这个变量的读取。重排序
;非 volatile 变量可以任意重排序。除了这些基本的happen-before规则,happen-before还具有传递性,即若A happen-before B,B happen-before C,则A happen-before C。
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
}
}
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;
}
}
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
变量,但仍然有内存可见性。线程A
调用set(100),线程B调 用get(),在某些场景下,返回值可能不是100。public class MyClass {
private long a = 0;
// 线程A调用set(100)
public void set(long a) {
this.a = a;
}
// 线程B调用get(),返回值一定是100吗?
public long get() {
return this.a;
}
}
DCL(Double Checking Locking)
,如下所示:public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized(Singleton.class) {
if (instance == null) {
// 此处代码有问题
instance = new Singleton();
}
}
}
return instance;
}
}
三个操作
: 可能重排序
,即先把instance指向内存,再初始化成员变量,因为二者并没有先后的依赖关系。此时,另外一个线程可能拿到一个未完全初始化的对象。这时,直接访问里面的成员变量,就可能出错。这就是典型的“构造方法溢出
”问题。volatile
修饰。64位写入的原子性、内存可见性和禁止重排序
。写操作不会和之前的写操作重排序
。写操作不会和之后的读操作重排序
。读操作不会和之后的读操作、写操作重排序
。happen-before
规则的严格遵守。public class MyClass {
private int num1;
private int num2;
private static MyClass myClass;
public MyClass() {
num1 = 1;
num2 = 2;
}
/**
* 线程A先执行write()
*/
public static void write() {
myClass = new MyClass();
}
/**
* 线程B接着执行write()
*/
public static void read() {
if (myClass != null) {
int num3 = myClass.num1;
int num4 = myClass.num2;
}
}
}
“原子的”
,当一个线程正在构造对象时,另外一个线程却可以读到未构造好的“一半对象”
。volatile
一样,final关键字也有相应的happen-before
语义: