天天看點

并發程式設計:JMM與記憶體屏障

作者:日拱一卒程式猿

一、為什麼會存在“記憶體可見性”問題

下圖為x86架構下CPU緩存的布局,即在一個CPU 4核下,L1、L2、L3三級緩存與主記憶體的布局。 每個核上面有L1、L2緩存,L3緩存為所有核共用。

并發程式設計:JMM與記憶體屏障

因為存在CPU緩存一緻性協定,例如MESI,多個CPU核心之間緩存不會出現不同步的問題,不會有 “記憶體可見性”問題。 緩存一緻性協定對性能有很大損耗,為了解決這個問題,又進行了各種優化。

例如,在計算單元和 L1之間加了Store Buffer、Load Buffer(還有其他各種Buffer),如下圖:

并發程式設計:JMM與記憶體屏障

L1、L2、L3和主記憶體之間是同步的,有緩存一緻性協定的保證,但是Store Buffer、Load Buffer和 L1之間卻是異步的。向記憶體中寫入一個變量,這個變量會儲存在Store Buffer裡面,稍後才異步地寫入 L1中,同時同步寫入主記憶體中。

作業系統核心視角下的CPU緩存模型

并發程式設計:JMM與記憶體屏障

多CPU,每個CPU多核,每個核上面可能還有多個硬體線程,對于作業系統來講,就相當于一個個 的邏輯CPU。每個邏輯CPU都有自己的緩存,這些緩存和主記憶體之間不是完全同步的。

對應到Java裡,就是JVM抽象記憶體模型,如下圖所示:

并發程式設計:JMM與記憶體屏障

二、重排序與記憶體可見性的關系

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類中提供了三個記憶體屏障函數,如下所示。

并發程式設計:JMM與記憶體屏障

在理論層面,可以把基本的CPU記憶體屏障分成四種:

1. LoadLoad:禁止讀和讀的重排序。

2. StoreStore:禁止寫和寫的重排序。

3. LoadStore:禁止讀和寫的重排序。

4. StoreLoad:禁止寫和讀的重排序。

Unsafe中的方法:

1. loadFence=LoadLoad+LoadStore

2. storeFence=StoreStore+LoadStore

3. fullFence=loadFence+storeFence+StoreLoad

繼續閱讀