一、為什麼會存在“記憶體可見性”問題
下圖為x86架構下CPU緩存的布局,即在一個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 Buffer裡面,稍後才異步地寫入 L1中,同時同步寫入主記憶體中。
作業系統核心視角下的CPU緩存模型
多CPU,每個CPU多核,每個核上面可能還有多個硬體線程,對于作業系統來講,就相當于一個個 的邏輯CPU。每個邏輯CPU都有自己的緩存,這些緩存和主記憶體之間不是完全同步的。
對應到Java裡,就是JVM抽象記憶體模型,如下圖所示:
二、重排序與記憶體可見性的關系
Store Buffer的延遲寫入是重排序的一種,稱為記憶體重排序(Memory Ordering)。除此之外,還編譯器和CPU的指令重排序。
重排序類型:
- 1. 編譯器重排序。 對于沒有先後依賴關系的語句,編譯器可以重新調整語句的執行順序。
- 2. CPU指令重排序。 在指令級别,讓沒有依賴關系的多條指令并行。
- 3. CPU記憶體重排序。 CPU有自己的緩存,指令的執行順序和寫入主記憶體的順序不完全一緻。
在三種重排序中,第三類就是造成“記憶體可見性”問題的主因,如下案例:
線程1:
X=1
a=Y
線程2:
Y=1
b=X
假設X、Y是兩個全局變量,初始的時候,X=0,Y=0。請問,這兩個線程執行完畢之後,a、b的正 确結果應該是什麼? 很顯然,線程1和線程2的執行先後順序是不确定的,可能順序執行,也可能交叉執行,最終正确的 結果可能是:
1. a=0,b=1
2. a=1,b=0
3. 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類中提供了三個記憶體屏障函數,如下所示。
在理論層面,可以把基本的CPU記憶體屏障分成四種:
1. LoadLoad:禁止讀和讀的重排序。
2. StoreStore:禁止寫和寫的重排序。
3. LoadStore:禁止讀和寫的重排序。
4. StoreLoad:禁止寫和讀的重排序。
Unsafe中的方法:
1. loadFence=LoadLoad+LoadStore
2. storeFence=StoreStore+LoadStore
3. fullFence=loadFence+storeFence+StoreLoad