JMM - Java 記憶體模型

語言: CN / TW / HK

JMM 即 Java Memory Model,也叫 Java 記憶體模型。JMM 就是一種規範,它定義了什麼情況開發者不需要去感知計算機的各種重排序,什麼情況需要開發者去幹涉重排序,以保證程式的執行結果可預測。

JMM的由來

計算機這麼多年來整體執行速度不斷地提升,除了像CPU時鐘頻率、記憶體讀寫速度等硬體效能不斷提升之外,還要歸功於電腦科學家對於計算機對於各種指令處理效率的不斷優化,包括超標量流水線技術,動態指令排程,猜測執行,多級快取技術等。在這其中,允許重排序對於計算機執行效率的提升產生了重要的作用,但同時也帶來了一些問題。計算機只能確保單執行緒情況下重排序對於執行結果沒有影響,對於多執行緒就無能為力了。這個時候就需要一個規範來保證開發者既能享受重排序帶來的效能的提升又能讓複雜情況下的執行結果可控,JMM 就是這樣一個規範。JMM 規定了 JVM 必須遵循的一組最小保證,這組保證規定了對變數的操作何時對其他執行緒可見。換句話說,JMM 對記憶體可見性作出了一些承諾,在承諾之外,開發者需要自己去處理記憶體可見性問題。

記憶體可見性問題

上面提到了記憶體可見性問題,那麼,什麼是記憶體可見性問題。

記憶體可見性問題的核心是 CPU 的快取與主記憶體不一致。

那麼,這裡就涉及到計算機原理的部分知識,下圖是 X86 架構下 CPU 快取的佈局:

從圖中可以看出 CPU 有多級快取,每個核心的一二級快取資料都是該 CPU 核心私有的,由於有快取一致性協議(例如 MESI )的存在,各個核心的快取之間不會存在不同步的問題。

這裡簡單講一下快取一致性協議 MESI,當各個 CPU 核心都快取了一個共享變數時,有任何一個核心對它作出了修改都會讓其他核心內對應變數的快取單元失敗(這裡失效的是整個 CacheLine,不僅僅是變數所佔用的區域)並且把修改值同步到主記憶體。其他核心如果後續要操作這個變數,必須從主記憶體讀,這樣就可以保證各個快取的一致性。

但引入快取一致性協議會有很大的效能損耗,為了解決這個問題,又進行了各種優化,這其中就有在計算單元和一級快取之間引入 StoreBuffer 和 LoadBuffer ,如下圖所示:

StoreBuffer 和 LoadBuffer 的引入,大大提升了計算機效能,但同時也帶來了一些問題:各級快取之間資料是一致的,但 StoreBuffer 和 LoadBuffer 一級快取之間的資料卻是非同步的,這裡就會存在一致性問題。

當一個快取中的資料被修改後,會存到 StoreBuffer 中,而 StoreBuffer 不會立即把修改後的資料同步到主記憶體,這時其他核心在主記憶體中讀取到就是舊資料,也就是說一個數據在一個核心的寫操作會出現對其他核心不可見的情況,這就是記憶體可見性問題。

重排序

上面講的記憶體可見性問題其本質就是 CPU 記憶體重排序,它是重排序的一種。這裡講一下什麼是重排序。

重排序分為三種:編譯重排序、CPU 指令重排序和 CPU 記憶體重排序。

  • 編譯器重排序: 對於沒有先後依賴的語句,編譯器可以重新調整語句的順序;
  • CPU 指令重排序: 對於沒有先後依賴的指令並行執行;
  • CPU 記憶體重排序: CPU 有自己的快取,指令的執行順序與寫入主記憶體的順序不一定一致。

編譯器重排序對開發者來說是無感知的,我們主要關注的是 CPU 指令重排序和 CPU 記憶體重排序,這兩者都會對執行結果產生影響。

舉個例子:假如有 X,Y,a,b 四個共享變數,我們在兩個不同的執行緒分別執行下面的程式碼:

執行緒一:

X = 1;
a = Y;

執行緒二:

Y = 1;
b = X;

這兩個執行緒的執行順序是不一定的,有可能是順序執行,也可能是交叉執行,最終結果可能是:

  • a = 0, b = 1 (執行緒一執行 -> 執行緒二執行)
  • b = 0, a = 1 (執行緒二執行 -> 執行緒一執行)
  • a = 1, b = 1 (兩個執行緒交叉執行)

上面就是 CPU 指令重排序產生的影響。但實際情況會有第四種結果:

  • a = 0, b = 0 (記憶體重排序)

導致這個結果的原因是兩個執行緒全部或其中一個的寫入操作沒有同步到主記憶體中,因此給 a 或 b 賦值時讀取到的還是舊值 0,這就是記憶體可見性問題。

CPU 指令重排序問題我們可以通過鎖、CAS 等同步機制來解決,編譯器重排序和 CPU 記憶體重排序都可以通過引入記憶體屏障來解決,這裡主要關注記憶體屏障在 CPU 重排序的應用。

記憶體屏障

記憶體屏障是一個比較底層的概念,它能對重排序作一定的限制,不同的記憶體屏障對重排序限制不同,一般都是組合使用的。作為 Java 開發者我們知道使用 volatile 關鍵字修飾的變數不會存在記憶體可見性問題,它的原理其實就是在對變數的操作前後都加入了兩個不同的記憶體屏障,以保證所有的讀寫組合都不會發生記憶體可見性問題。

可以把記憶體屏障分為四類:

  • LoadLoad:禁止讀和讀的重排序
  • StoreStore:禁止寫和寫的重排序
  • LoadStore:禁止讀和寫的重排序
  • StoreLoad:禁止寫和讀的重排序

JDK 8 開始,Unsafe 類提供了三個記憶體屏障方法:

public final class Unsafe { 
	// ...
	public native void loadFence(); 
	public native void storeFence(); 
	public native void fullFence(); 
	// ...
}

這三個方法對應的記憶體屏障如下:

  • loadFence = LoadLoad + LoadStore
  • storeFence = StoreStore + LoadStore
  • fullFence = loadFence + storeFence + StoreLoad

我們平常在開發中一般不會去主動使用記憶體屏障,而記憶體屏障所實現的效果可以用 happen-before 來描述。

happen-before

首先來說說什麼是 happen-before:它用來描述來個操作之間的記憶體可見性,如果 A 操作 happen-before 於 B 操作,那麼 A 操作的執行結果必須是對 B 操作可見的,這裡隱含了一個條件,只有在 A 操作的執行實際發生在 B 操作之前,這個可見性保證才會有效,happen-before 並不會去改變 A 和 B 的執行順序。

JMM 規範藉助 happen-before 可以更好的描述出來。

happen-before 有以下四個基本規則:

  • 單執行緒中的每個操作,happen-before於該執行緒中任意後續操作。
  • 對volatile變數的寫,happen-before於後續對這個變數的讀。
  • 對synchronized的解鎖,happen-before於後續對這個鎖的加鎖。
  • 對final變數的寫,happen-before於final域物件的讀,happen-before於後續對final變數的讀。

除了以上四個基礎規則之外,happen-before 還具有傳遞性。傳遞性是指當 A happen-before 於 B,B happen-before 於 C ,那麼操作 A 的結果一定對操作 C 可見。

這四個基本規則再加上 happen-before 的傳遞性,就構成了 JMM 對開發者的整個承諾。在這個承諾之後的部分,開發者就需要小心處理記憶體可見性問題。