新聞中心
首先有這樣一個(gè)程序, 我們想實(shí)現(xiàn)一個(gè)單例值,只有第一次調(diào)用的時(shí)候初始化,并且有多線程會(huì)訪問(wèn)這個(gè)單例值,那么我們會(huì)有:

getValue 的實(shí)現(xiàn)就是經(jīng)典的 DCL 寫(xiě)法。
在 Java 內(nèi)存模型的限制下,這個(gè) ValueHolder 有兩個(gè)潛在的問(wèn)題:
- 如果根據(jù) Java 內(nèi)存模型的定義,不考慮實(shí)際 JVM 的實(shí)現(xiàn),那么 getValue 是有可能返回 null 的。
- 可能讀取到?jīng)]有初始化完成的 Value 的字段值。
下面我們就這兩個(gè)問(wèn)題進(jìn)行進(jìn)一步分析并優(yōu)化。
根據(jù) Java 內(nèi)存模型的定義,不考慮實(shí)際 JVM 的實(shí)現(xiàn),getValue 有可能返回 null 的原因
在 全網(wǎng)最硬核 Java 新內(nèi)存模型解析與實(shí)驗(yàn) 文章的7.1. Coherence(相干性,連貫性)與 Opaque中我們提到過(guò):假設(shè)某個(gè)對(duì)象字段 int x 初始為 0,一個(gè)線程執(zhí)行:
另一個(gè)線程執(zhí)行(r1, r2 為本地變量):
那么這個(gè)實(shí)際上是兩次對(duì)于字段的讀取(對(duì)應(yīng)字節(jié)碼 getfield),在 Java 內(nèi)存模型下,可能的結(jié)果是包括:
- r1 = 1, r2 = 1
- r1 = 0, r2 = 1
- r1 = 1, r2 = 0
- r1 = 0, r2 = 0
其中第三個(gè)結(jié)果很有意思,從程序上理解即我們先看到了 x = 1,之后又看到了 x 變成了 0.實(shí)際上這是因?yàn)榫幾g器亂序。如果我們不想看到這個(gè)第三種結(jié)果,我們所需要的特性即 coherence。這里由于private Value value是普通的字段,所以根據(jù) Java 內(nèi)存模型來(lái)看并不保證 coherence。
回到我們的程序,我們有三次對(duì)字段讀取(對(duì)應(yīng)字節(jié)碼 getfield),分別位于:
由于 1,2 之間有明顯的分支關(guān)系(2 根據(jù) 1 的結(jié)果而執(zhí)行或者不執(zhí)行),所以無(wú)論在什么編譯器看來(lái),都要先執(zhí)行 1 然后執(zhí)行 2。但是對(duì)于 1 和 3,他們之間并沒(méi)有這種依賴(lài)關(guān)系,在一些簡(jiǎn)單的編譯器看來(lái),他們是可以亂序執(zhí)行的。在 Java 內(nèi)存模型下,也沒(méi)有限制 1 與 3 之間是否必須不能亂序。所以,可能你的程序先執(zhí)行 3 的讀取,然后執(zhí)行 1 的讀取以及其他邏輯,最后方法返回 3 讀取的結(jié)果。
但是,在 OpenJDK Hotspot 的相關(guān)編譯器環(huán)境下,這個(gè)是被避免了的。OpenJDK Hotspot 編譯器是比較嚴(yán)謹(jǐn)?shù)木幾g器,它產(chǎn)生的 1 和 3 的兩次讀取(針對(duì)同一個(gè)字段的兩次讀取)也是兩次互相依賴(lài)的讀取,在編譯器維度是不會(huì)有亂序的(注意這里說(shuō)的是編譯器維度哈,不是說(shuō)這里會(huì)有內(nèi)存屏障連可能的 CPU 亂序也避免了,不過(guò)這里針對(duì)同一個(gè)字段讀取,前面已經(jīng)說(shuō)了僅和編譯器亂序有關(guān),和 CPU 亂序無(wú)關(guān))
不過(guò),這個(gè)僅僅是針對(duì)一般程序的寫(xiě)法,我們可以通過(guò)一些奇怪的寫(xiě)法騙過(guò)編譯器,讓他任務(wù)兩次讀取沒(méi)有關(guān)系,例如在全網(wǎng)最硬核 Java 新內(nèi)存模型解析與實(shí)驗(yàn) 文章的7.1. Coherence(相干性,連貫性)與 Opaque中的實(shí)驗(yàn)環(huán)節(jié),OpenJDK Hotspot 對(duì)于下面的程序是沒(méi)有編譯器亂序的:
但是如果你換成下面這種寫(xiě)法,就騙過(guò)了編譯器:
我們不用太深究其原理,直接看其中一個(gè)結(jié)果:
對(duì)于 DCL 這種寫(xiě)法,我們也是可以騙過(guò)編譯器的,但是一般我們不會(huì)這么寫(xiě),這里就不贅述了。
可能讀取到?jīng)]有初始化完成的 Value 的字段值。
這個(gè)就不只是編譯器亂序了,還涉及了 CPU 指令亂序以及 CPU 緩存亂序,需要內(nèi)存屏障解決可見(jiàn)性問(wèn)題。
我們從 Value 類(lèi)的構(gòu)造器入手:
對(duì)于 value = new Value(10); 這一步,將代碼分解為更詳細(xì)易于理解的偽代碼則是:
這中間沒(méi)有任何內(nèi)存屏障,根據(jù)語(yǔ)義分析,1 與 5 之間有依賴(lài)關(guān)系,因?yàn)?5 依賴(lài)于 1 的結(jié)果,必須先執(zhí)行 1 再執(zhí)行 5。 2 與 3 之間也是有依賴(lài)關(guān)系的,因?yàn)?3 依賴(lài) 2 的結(jié)果。但是,2和3,與 4,以及 5 這三個(gè)之間沒(méi)有依賴(lài)關(guān)系,是可以亂序的。我們使用使用代碼測(cè)試下這個(gè)亂序:
- 雖然在注釋中寫(xiě)出了這么編寫(xiě)代碼的原因,但是這里還是想強(qiáng)調(diào)下這么寫(xiě)的原因:
- jcstress 的 @Actor 是使用一個(gè)線程執(zhí)行這個(gè)方法中的代碼,在測(cè)試中,每次會(huì)用不同的 JVM 啟動(dòng)參數(shù)讓這段代碼解釋執(zhí)行,C1編譯執(zhí)行,C2編譯執(zhí)行,同時(shí)對(duì)于 JIT 編譯還會(huì)修改編譯參數(shù)讓它的編譯代碼效果不一樣。這樣我們就可以看到在不同的執(zhí)行方式下是否會(huì)有不同的編譯器亂序效果。
- jcstress 的 @Actor 是使用一個(gè)線程執(zhí)行這個(gè)方法中的代碼,在每次使用不同的 JVM 測(cè)試啟動(dòng)時(shí),會(huì)將這個(gè) @Actor 綁定到一個(gè) CPU 執(zhí)行,這樣保證在測(cè)試的過(guò)程中,這個(gè)方法只會(huì)在這個(gè) CPU 上執(zhí)行, CPU 緩存由這個(gè)方法的代碼獨(dú)占,這樣才能更容易的測(cè)試出 CPU 緩存不一致導(dǎo)致的亂序。所以,我們的 @Actor 注解方法的數(shù)量需要小于 CPU 個(gè)數(shù)。
- 我們測(cè)試機(jī)這里只有兩個(gè) CPU,那么只能有兩個(gè)線程,如果都執(zhí)行原始代碼的話,那么很可能都執(zhí)行到 synchronized 同步塊等待,synchronized 本身有內(nèi)存屏障的作用(后面會(huì)提到)。為了更容易測(cè)試出沒(méi)有走 synchronized 同步塊的情況,我們第二個(gè)@Actor 注解的方法直接去掉同步塊邏輯,并且如果 value 為 null,我們就設(shè)置結(jié)果都是 -1 用來(lái)區(qū)分。
我分別在 x86 和 arm CPU 上測(cè)試了這個(gè)程序,結(jié)果分別是:
x86 - AMD64:
arm - aarch64:
我們可以看到,在比較強(qiáng)一致性的 CPU 如 x86 中,是沒(méi)有看到未初始化的字段值的,但是在 arm 這種弱一致性的 CPU 上面,我們就看到了未初始化的值。在我的另一個(gè)系列 - 全網(wǎng)最硬核 Java 新內(nèi)存模型解析與實(shí)驗(yàn)中,我們也多次提到了這個(gè) CPU 亂序表格:
在這里,我們需要的內(nèi)存屏障是 StoreStore(同時(shí)我們也從上面的表格看出,x86 天生不需要 StoreStore,只要沒(méi)有編譯器亂序的話,CPU 層面是不會(huì)亂序的,而 arm 需要內(nèi)存屏障保證 Store 與 Store 不會(huì)亂序),只要這個(gè)內(nèi)存屏障保證我們前面?zhèn)未a中第 2,3 步在第 5 步前,第 4 步在第 5 步之前即可,那么我們可以怎么做呢?參考我的那篇全網(wǎng)最硬核 Java 新內(nèi)存模型解析與實(shí)驗(yàn)中各種內(nèi)存屏障對(duì)應(yīng)關(guān)系,我們可以有如下做法,每種做法我們都會(huì)對(duì)比其內(nèi)存屏障消耗:
1.使用 final
final 是在賦值語(yǔ)句末尾添加 StoreStore 內(nèi)存屏障,所以我們只需要在第 2,3 步以及第 4 步末尾添加 StoreStore 內(nèi)存屏障即把 a2 和 b 設(shè)置成 final 即可,如下所示:
對(duì)應(yīng)偽代碼:
我們測(cè)試下:
這次在 arm 上的結(jié)果是:
如你所見(jiàn),這次 arm CPU 上也沒(méi)有看到未初始化的值了。
這里 a1 不需要設(shè)置成 final,因?yàn)榍懊嫖覀冋f(shuō)過(guò),2 與 3 之間是有依賴(lài)的,可以把他們看成一個(gè)整體,只需要整體后面添加好內(nèi)存屏障即可。但是這個(gè)并不可靠!!!!因?yàn)樵谀承?JDK 中可能會(huì)把這個(gè)代碼:
優(yōu)化成這樣:
這樣 a1, a2 之間就沒(méi)有依賴(lài)了!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!所以最好還是所有的變量都設(shè)置為 final
但是,這在我們不能將字段設(shè)置為 final 的時(shí)候,就不好使了。
2. 使用 volatile,這是大家常用以及官方推薦的做法
將 value 設(shè)置為 volatile 的,在我的另一系列文章 全網(wǎng)最硬核 Java 新內(nèi)存模型解析與實(shí)驗(yàn)中,我們知道對(duì)于 volatile 寫(xiě)入,我們通過(guò)在寫(xiě)入之前加入 LoadStore + StoreStore 內(nèi)存屏障,在寫(xiě)入之后加入 StoreLoad 內(nèi)存屏障實(shí)現(xiàn)的,如果把 value 設(shè)置為 volatile 的,那么前面的偽代碼就變成了:
我們通過(guò)下面的代碼測(cè)試下:
依舊在 arm 機(jī)器上面測(cè)試,結(jié)果是:
沒(méi)有看到未初始化值了。
3. 對(duì)于 Java 9+ 可以使用 Varhandle 的 acquire/release
前面分析,我們其實(shí)只需要保證在偽代碼第五步之前保證有 StoreStore 內(nèi)存屏障即可,所以 volatile 其實(shí)有點(diǎn)重,我們可以通過(guò)使用 Varhandle 的 acquire/release 這一級(jí)別的可見(jiàn)性 api 實(shí)現(xiàn),這樣偽代碼就變成了:
我們的測(cè)試代碼變成了:
測(cè)試結(jié)果是:
也是沒(méi)有看到未初始化值了。這種方式是用內(nèi)存屏障最少,同時(shí)不用限制目標(biāo)類(lèi)型里面不必使用 final 字段的方式。
4. 一種有趣但是沒(méi)啥用的思路 - 如果是靜態(tài)方法,可以通過(guò)類(lèi)加載器機(jī)制實(shí)現(xiàn)很簡(jiǎn)便的寫(xiě)法
如果我們,ValueHolder 里面的方法以及字段可以是 static 的,例如:
將 ValueHolder 作為一個(gè)單獨(dú)的類(lèi),或者一個(gè)內(nèi)部類(lèi),這樣也是能保證 Value 里面字段的可見(jiàn)性的,這是通過(guò)類(lèi)加載器機(jī)制實(shí)現(xiàn)的,在加載同一個(gè)類(lèi)的時(shí)候(類(lèi)加載的過(guò)程中會(huì)初始化 static 字段并且運(yùn)行 static 塊代碼),是通過(guò) synchronized 關(guān)鍵字同步塊保護(hù)的,參考其中類(lèi)加載器(ClassLoader.java)的源碼:
ClassLoader.java
對(duì)于 syncrhonized 底層對(duì)應(yīng)的 monitorenter 和 monitorexit,monitorenter 與 volatile 讀有一樣的內(nèi)存屏障,即在操作之后加入 LoadLoad 和 LoadStore,monitorexit 與 volatile 寫(xiě)有一樣的內(nèi)存屏障,在操作之前加入 LoadStore + StoreStore 內(nèi)存屏障,在操作之后加入 StoreLoad 內(nèi)存屏障。所以,也是能保證可見(jiàn)性的。但是這樣雖然寫(xiě)起來(lái)貌似很簡(jiǎn)便,效率上更加低(低了很多,類(lèi)加載需要更多事情)并且不夠靈活,只是作為一種擴(kuò)展知識(shí)知道就好。
總結(jié)
DCL 是一種常見(jiàn)的編程模式,對(duì)于鎖保護(hù)的字段 value 會(huì)有兩種字段可見(jiàn)性問(wèn)題:
如果根據(jù) Java 內(nèi)存模型的定義,不考慮實(shí)際 JVM 的實(shí)現(xiàn),那么 getValue 是有可能返回 null 的。但是這個(gè)一般都被現(xiàn)在 JVM 設(shè)計(jì)避免了,這一點(diǎn)我們?cè)趯?shí)際編程的時(shí)候可以不考慮。
可能讀取到?jīng)]有初始化完成的 Value 的字段值,這個(gè)可以通過(guò)在構(gòu)造器完成與賦值給變量之間添加 StoreStore 內(nèi)存屏障解決??梢酝ㄟ^(guò)將 Value 的字段設(shè)置為 final 解決,但是不夠靈活。
最簡(jiǎn)單的方式是將 value 字段設(shè)置為 volatile 的,這也是 JDK 中使用的方式,官方也推薦這種。
效率最高的方式是使用 VarHandle 的 release 模式,這個(gè)模式只會(huì)引入 StoreStore 與 LoadStore 內(nèi)存屏障,相對(duì)于 volatile 寫(xiě)的內(nèi)存屏障要少很多(少了 StoreLoad,對(duì)于 x86 相當(dāng)于沒(méi)有內(nèi)存屏障,因?yàn)?x86 天然有 LoadLoad,LoadStore,StoreStore,x86 僅僅不能天然保證 StoreLoad)。
文章名稱(chēng):通過(guò)實(shí)例程序驗(yàn)證與優(yōu)化談?wù)劸W(wǎng)上很多對(duì)于Java DCL的一些誤解
標(biāo)題網(wǎng)址:http://www.dlmjj.cn/article/dpgedge.html


咨詢(xún)
建站咨詢(xún)
