JMM定義
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 對開發者的整個承諾。在這個承諾之后的部分,開發者就需要小心處理內存可見性問題。