通過例項程式驗證與優化談談網上很多對於Java DCL的一些誤解以及為何要理解Java記憶體模型

乾貨滿滿張雜湊發表於2022-04-16

個人創作公約:本人宣告創作的所有文章皆為自己原創,如果有參考任何文章的地方,會標註出來,如果有疏漏,歡迎大家批判。如果大家發現網上有抄襲本文章的,歡迎舉報,並且積極向這個 github 倉庫 提交 issue,謝謝支援~

本文基於 OpenJDK 11 以上的版本

最近爆肝了這系列文章 全網最硬核 Java 新記憶體模型解析與實驗,從底層硬體,往上全面解析了 Java 記憶體模型設計,並給每個結論都配有了相關的參考的論文以及驗證程式,我發現多年來對於 Java 記憶體模型有很多誤解,並且我發現很多很多人都存在這樣的誤解,所以這次通過不斷優化一個經典的 DCL (Double Check Locking)程式例項來幫助大家消除這個誤解。

首先有這樣一個程式, 我們想實現一個單例值,只有第一次呼叫的時候初始化,並且有多執行緒會訪問這個單例值,那麼我們會有:

image

getValue 的實現就是經典的 DCL 寫法。

在 Java 記憶體模型的限制下,這個 ValueHolder 有兩個潛在的問題:

  1. 如果根據 Java 記憶體模型的定義,不考慮實際 JVM 的實現,那麼 getValue 是有可能返回 null 的。
  2. 可能讀取到沒有初始化完成的 Value 的欄位值。

下面我們就這兩個問題進行進一步分析並優化。

根據 Java 記憶體模型的定義,不考慮實際 JVM 的實現,getValue 有可能返回 null 的原因

全網最硬核 Java 新記憶體模型解析與實驗 文章的7.1. Coherence(相干性,連貫性)與 Opaque中我們提到過:假設某個物件欄位 int x 初始為 0,一個執行緒執行:
image
另一個執行緒執行(r1, r2 為本地變數):
image

那麼這個實際上是兩次對於欄位的讀取(對應位元組碼 getfield),在 Java 記憶體模型下,可能的結果是包括:

  1. r1 = 1, r2 = 1
  2. r1 = 0, r2 = 1
  3. r1 = 1, r2 = 0
  4. r1 = 0, r2 = 0

其中第三個結果很有意思,從程式上理解即我們先看到了 x = 1,之後又看到了 x 變成了 0.實際上這是因為編譯器亂序。如果我們不想看到這個第三種結果,我們所需要的特性即 coherence。這裡由於private Value value是普通的欄位,所以根據 Java 記憶體模型來看並不保證 coherence

回到我們的程式,我們有三次對欄位讀取(對應位元組碼 getfield),分別位於:
image

由於 1,2 之間有明顯的分支關係(2 根據 1 的結果而執行或者不執行),所以無論在什麼編譯器看來,都要先執行 1 然後執行 2。但是對於 1 和 3,他們之間並沒有這種依賴關係,在一些簡單的編譯器看來,他們是可以亂序執行的。在 Java 記憶體模型下,也沒有限制 1 與 3 之間是否必須不能亂序。所以,可能你的程式先執行 3 的讀取,然後執行 1 的讀取以及其他邏輯,最後方法返回 3 讀取的結果

但是,在 OpenJDK Hotspot 的相關編譯器環境下,這個是被避免了的。OpenJDK Hotspot 編譯器是比較嚴謹的編譯器,它產生的 1 和 3 的兩次讀取(針對同一個欄位的兩次讀取)也是兩次互相依賴的讀取,在編譯器維度是不會有亂序的(注意這裡說的是編譯器維度哈,不是說這裡會有記憶體屏障連可能的 CPU 亂序也避免了,不過這裡針對同一個欄位讀取,前面已經說了僅和編譯器亂序有關,和 CPU 亂序無關)

不過,這個僅僅是針對一般程式的寫法,我們可以通過一些奇怪的寫法騙過編譯器,讓他任務兩次讀取沒有關係,例如在全網最硬核 Java 新記憶體模型解析與實驗 文章的7.1. Coherence(相干性,連貫性)與 Opaque中的實驗環節,OpenJDK Hotspot 對於下面的程式是沒有編譯器亂序的

image
但是如果你換成下面這種寫法,就騙過了編譯器:
image
我們不用太深究其原理,直接看其中一個結果:
image
對於 DCL 這種寫法,我們也是可以騙過編譯器的,但是一般我們不會這麼寫,這裡就不贅述了

可能讀取到沒有初始化完成的 Value 的欄位值

這個就不只是編譯器亂序了,還涉及了 CPU 指令亂序以及 CPU 快取亂序,需要記憶體屏障解決可見性問題。

我們從 Value 類的構造器入手:

image
對於 value = new Value(10); 這一步,將程式碼分解為更詳細易於理解的虛擬碼則是:
image
這中間沒有任何記憶體屏障,根據語義分析,1 與 5 之間有依賴關係,因為 5 依賴於 1 的結果,必須先執行 1 再執行 5。 2 與 3 之間也是有依賴關係的,因為 3 依賴 2 的結果。但是,2和3,與 4,以及 5 這三個之間沒有依賴關係,是可以亂序的。我們使用使用程式碼測試下這個亂序:
image

雖然在註釋中寫出了這麼編寫程式碼的原因,但是這裡還是想強調下這麼寫的原因:

  1. jcstress 的 @Actor 是使用一個執行緒執行這個方法中的程式碼,在測試中,每次會用不同的 JVM 啟動引數讓這段程式碼解釋執行,C1編譯執行,C2編譯執行,同時對於 JIT 編譯還會修改編譯引數讓它的編譯程式碼效果不一樣。這樣我們就可以看到在不同的執行方式下是否會有不同的編譯器亂序效果
  2. jcstress 的 @Actor 是使用一個執行緒執行這個方法中的程式碼,在每次使用不同的 JVM 測試啟動時,會將這個 @Actor 繫結到一個 CPU 執行,這樣保證在測試的過程中,這個方法只會在這個 CPU 上執行, CPU 快取由這個方法的程式碼獨佔,這樣才能更容易的測試出 CPU 快取不一致導致的亂序所以,我們的 @Actor 註解方法的數量需要小於 CPU 個數
  3. 我們測試機這裡只有兩個 CPU,那麼只能有兩個執行緒,如果都執行原始程式碼的話,那麼很可能都執行到 synchronized 同步塊等待,synchronized 本身有記憶體屏障的作用(後面會提到)。為了更容易測試出沒有走 synchronized 同步塊的情況,我們第二個 @Actor 註解的方法直接去掉同步塊邏輯,並且如果 value 為 null,我們就設定結果都是 -1 用來區分

我分別在 x86arm CPU 上測試了這個程式,結果分別是:

x86 - AMD64
image

arm - aarch64:

image

我們可以看到,在比較強一致性的 CPU 如 x86 中,是沒有看到未初始化的欄位值的,但是在 arm 這種弱一致性的 CPU 上面,我們就看到了未初始化的值。在我的另一個系列 - 全網最硬核 Java 新記憶體模型解析與實驗中,我們也多次提到了這個 CPU 亂序表格:
image

在這裡,我們需要的記憶體屏障是 StoreStore(同時我們也從上面的表格看出,x86 天生不需要 StoreStore,只要沒有編譯器亂序的話,CPU 層面是不會亂序的,而 arm 需要記憶體屏障保證 Store 與 Store 不會亂序),只要這個記憶體屏障保證我們前面虛擬碼中第 2,3 步在第 5 步前,第 4 步在第 5 步之前即可,那麼我們可以怎麼做呢?參考我的那篇全網最硬核 Java 新記憶體模型解析與實驗中各種記憶體屏障對應關係,我們可以有如下做法,每種做法我們都會對比其記憶體屏障消耗:

1.使用 final

final 是在賦值語句末尾新增 StoreStore 記憶體屏障,所以我們只需要在第 2,3 步以及第 4 步末尾新增 StoreStore 記憶體屏障即把 a2 和 b 設定成 final 即可,如下所示:

image

對應虛擬碼:

image

我們測試下:

image

這次在 arm 上的結果是:
image

如你所見,這次 arm CPU 上也沒有看到未初始化的值了。

這裡 a1 不需要設定成 final,因為前面我們說過,2 與 3 之間是有依賴的,可以把他們看成一個整體,只需要整體後面新增好記憶體屏障即可。但是這個並不可靠!!!!因為在某些 JDK 中可能會把這個程式碼:
image

優化成這樣:
image

這樣 a1, a2 之間就沒有依賴了!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!所以最好還是所有的變數都設定為 final

但是,這在我們不能將欄位設定為 final 的時候,就不好使了。

2. 使用 volatile,這是大家常用以及官方推薦的做法

將 value 設定為 volatile 的,在我的另一系列文章 全網最硬核 Java 新記憶體模型解析與實驗中,我們知道對於 volatile 寫入,我們通過在寫入之前加入 LoadStore + StoreStore 記憶體屏障,在寫入之後加入 StoreLoad 記憶體屏障實現的,如果把 value 設定為 volatile 的,那麼前面的虛擬碼就變成了:
image

我們通過下面的程式碼測試下:

image

依舊在 arm 機器上面測試,結果是:
image

沒有看到未初始化值了

3. 對於 Java 9+ 可以使用 Varhandle 的 acquire/release

前面分析,我們其實只需要保證在虛擬碼第五步之前保證有 StoreStore 記憶體屏障即可,所以 volatile 其實有點重,我們可以通過使用 Varhandle 的 acquire/release 這一級別的可見性 api 實現,這樣虛擬碼就變成了:
image

我們的測試程式碼變成了:

image

測試結果是:
image

也是沒有看到未初始化值了。這種方式是用記憶體屏障最少,同時不用限制目標型別裡面不必使用 final 欄位的方式。

4. 一種有趣但是沒啥用的思路 - 如果是靜態方法,可以通過類載入器機制實現很簡便的寫法

如果我們,ValueHolder 裡面的方法以及欄位可以是 static 的,例如:

image
將 ValueHolder 作為一個單獨的類,或者一個內部類,這樣也是能保證 Value 裡面欄位的可見性的,這是通過類載入器機制實現的,在載入同一個類的時候(類載入的過程中會初始化 static 欄位並且執行 static 塊程式碼),是通過 synchronized 關鍵字同步塊保護的,參考其中類載入器(ClassLoader.java)的原始碼:

ClassLoader.java
image

對於 syncrhonized 底層對應的 monitorenter 和 monitorexit,monitorenter 與 volatile 讀有一樣的記憶體屏障,即在操作之後加入 LoadLoad 和 LoadStore,monitorexit 與 volatile 寫有一樣的記憶體屏障,在操作之前加入 LoadStore + StoreStore 記憶體屏障,在操作之後加入 StoreLoad 記憶體屏障。所以,也是能保證可見性的。但是這樣雖然寫起來貌似很簡便,效率上更加低(低了很多,類載入需要更多事情)並且不夠靈活,只是作為一種擴充套件知識知道就好。

總結

  1. DCL 是一種常見的程式設計模式,對於鎖保護的欄位 value 會有兩種欄位可見性問題:
  2. 如果根據 Java 記憶體模型的定義,不考慮實際 JVM 的實現,那麼 getValue 是有可能返回 null 的。但是這個一般都被現在 JVM 設計避免了,這一點我們在實際程式設計的時候可以不考慮。
  3. 可能讀取到沒有初始化完成的 Value 的欄位值,這個可以通過在構造器完成與賦值給變數之間新增 StoreStore 記憶體屏障解決。可以通過將 Value 的欄位設定為 final 解決,但是不夠靈活。
  4. 最簡單的方式是將 value 欄位設定為 volatile 的,這也是 JDK 中使用的方式,官方也推薦這種
  5. 效率最高的方式是使用 VarHandle 的 release 模式,這個模式只會引入 StoreStore 與 LoadStore 記憶體屏障,相對於 volatile 寫的記憶體屏障要少很多(少了 StoreLoad,對於 x86 相當於沒有記憶體屏障,因為 x86 天然有 LoadLoad,LoadStore,StoreStore,x86 僅僅不能天然保證 StoreLoad)

微信搜尋“我的程式設計喵”關注公眾號,加作者微信,每日一刷,輕鬆提升技術,斬獲各種offer
image
我會經常發一些很好的各種框架的官方社群的新聞視訊資料並加上個人翻譯字幕到如下地址(也包括上面的公眾號),歡迎關注:

相關文章