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的指令重排序。
重排序型別:
編譯器重排序。
對於沒有先後依賴關係的語句,編譯器可以重新調整語句的執行順序。
CPU指令重排序。
在指令級別,讓沒有依賴關係的多條指令並行。
CPU記憶體重排序。
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
語意: