全網最硬核 Java 新內存模型解析與實驗 - 4. Java 新內存訪問方式與實驗

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

本篇文章參考了大量文章,文檔以及論文,但是這塊東西真的很繁雜,我的水平有限,可能理解的也不到位,如有異議歡迎留言提出。本系列會不斷更新,結合大家的問題以及這裏的錯誤和疏漏,歡迎大家留言

如果你喜歡單篇版,請訪問:全網最硬核 Java 新內存模型解析與實驗單篇版(不斷更新QA中) 如果你喜歡這個拆分的版本,這裏是目錄:

JMM 相關文檔:

內存屏障,CPU 與內存模型相關:

x86 CPU 相關資料:

ARM CPU 相關資料:

各種一致性的理解:

Aleskey 大神的 JMM 講解:

相信很多 Java 開發,都使用了 Java 的各種併發同步機制,例如 volatile,synchronized 以及 Lock 等等。也有很多人讀過 JSR 第十七章 Threads and Locks(地址:https://docs.oracle.com/javase/specs/jls/se17/html/jls-17.html),其中包括同步、Wait/Notify、Sleep & Yield 以及內存模型等等做了很多規範講解。但是也相信大多數人和我一樣,第一次讀的時候,感覺就是在看熱鬧,看完了只是知道他是這麼規定的,但是爲啥要這麼規定,不這麼規定會怎麼樣,並沒有很清晰的認識。同時,結合 Hotspot 的實現,以及針對 Hotspot 的源碼的解讀,我們甚至還會發現,由於 javac 的靜態代碼編譯優化以及 C1、C2 的 JIT 編譯優化,導致最後代碼的表現與我們的從規範上理解出代碼可能的表現是不太一致的。並且,這種不一致,導致我們在學習 Java 內存模型(JMM,Java Memory Model),理解 Java 內存模型設計的時候,如果想通過實際的代碼去試,結果是與自己本來可能正確的理解被帶偏了,導致誤解。 我本人也是不斷地嘗試理解 Java 內存模型,重讀 JLS 以及各路大神的分析。這個系列,會梳理我個人在閱讀這些規範以及分析還有通過 jcstress 做的一些實驗而得出的一些理解,希望對於大家對 Java 9 之後的 Java 內存模型以及 API 抽象的理解有所幫助。但是,還是強調一點,內存模型的設計,出發點是讓大家可以不用關心底層而抽象出來的一些設計,涉及的東西很多,我的水平有限,可能理解的也不到位,我會盡量把每一個論點的論據以及參考都擺出來,請大家不要完全相信這裏的所有觀點,如果有任何異議歡迎帶着具體的實例反駁並留言

6. 爲什麼最好不要自己寫代碼驗證 JMM 的一些結論

通過前面的一系列分析我們知道,程序亂序的問題錯綜複雜,假設一段代碼,沒有任何限制所有可能的輸出結果是如下圖所示這個全集: image

在 Java 內存模型的限制下,可能的結果被限制到了所有亂序結果中的一個子集: image

在 Java 內存模型的限制下,在不同的 CPU 架構上,CPU 亂序情況不同,有的場景有的 CPU 會亂序,有的則不會,但是都在 JMM 的範圍內所以是合理的,這樣所有可能的結果集又被限制到 JMM 的一個個不同子集: image

在 Java 內存模型的限制下,在不同的操作系統的編譯器編譯出來的 JVM 的代碼執行順序不同,底層系統調用定義不同,在不同操作系統執行的 Java 代碼又有可能會有些微小的差異,但是由於都在 JMM 的限制範圍內,所以也是合理的: image

最後呢,在不同的執行方式以及 JIT 編譯下,底層執行的代碼還是有差異的,進一步導致了結果集的分化:

image

所以,如果你自己編寫代碼在自己的唯一一臺電腦唯一一種操作系統上面去試,那麼你所能試出來的結果集只是 JMM 的一個子集,很可能有些亂序結果你是看不到的。並且,有些亂序執行次數少或者沒走到 JIT 優化,還看不到,所以,真的不建議你自己寫代碼去實驗。

那麼應該怎麼做呢?使用較爲官方的用來測試併發可見性的框架 - jcstress,這個框架雖然不能模擬不同的 CPU 架構和不同操作系統,但是能讓你排除不同執行(解釋執行,C1執行,C2執行)以及測試壓力不足次數少的原因,後面的所有講解都會附上對應的 jcstress 代碼實例供大家使用。

7. 層層遞進可見性與 Java 9+ 內存模型的對應 API

這裏主要參考了 Aleksey 大神的思路,去總結出不同層次,層層遞進的 Java 中的一些內存可見性限制性質以及對應的 API。Java 9+ 中,將原來的普通變量(非 volatile,final 變量)的普通訪問,定義爲了 Plain。普通訪問,沒有對這個訪問的地址做任何屏障(不同 GC 的那些屏障,比如分代 GC 需要的指針屏障,不是這裏要考慮的,那些屏障只是 GC 層面的,對於這裏的可見性沒啥影響),會有前面提到的各種亂序。那麼 Java 9+ 內存模型中究竟提出了那些限制以及對應這些限制的 API 是啥,我們接下層層遞進講述。

7.1. Coherence(相干性,連貫性)與 Opaque

image

這裏的標題我不太清楚究竟應該翻譯成什麼,因爲我看網上很多地方把 CPU Cache Coherence Protocol 翻譯成了 CPU 緩存一致性協議,即 Coherence 在那種語境下代表一致性,但是我們這裏的 Coherence 如果翻譯成一致性就不太合適。所以,之後的一些名詞我也直接沿用 Doug Lea 大神的以及 Aleksey 大神的定義。

那麼這裏什麼是 coherence 呢?舉一個簡單的例子:假設某個對象字段 int x 初始爲 0,一個線程執行:

image

另一個線程執行(r1, r2 爲本地變量):

image

那麼在 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。

coherence 的定義,我引用下原文:

The writes to the single memory location appear to be in a total order consistent with program order.

即對單個內存位置的寫看上去是按照與程序順序一致的總順序進行的。看上去有點難以理解,結合上面的例子,可以這樣理解:在全局,x 由 0 變成了 1,那麼每個線程中看到的 x 只能從 0 變成 1,而不會可能看到從 1 變成 0.

正如前面所說,Java 內存模型定義中的 Plain 讀寫,是不能保證 coherence 的。但是如果大家跑一下針對上面的測試代碼,會發現跑不出來第三種結果。這是因爲 Hotspot 虛擬機中的語義分析會認爲這兩個對於 x 的讀取(load)是互相依賴的,進而限制了這種亂序:

image

這就是我在前面一章中提到的,爲什麼最好不要自己寫代碼驗證 JMM 的一些結論。雖然在 Java 內存模型的限制中,是允許第三種結果 1, 0 的,但是這裏通過這個例子是試不出來的。

我們這裏通過一個別扭的例子來騙過 Java 編譯器造成這種亂序

image

我們不用太深究其原理,直接看結果:

image

發現出現了亂序的結果,並且,如果你自己跑一下這個例子,會發現這個亂序是發生在執行 JIT C2 編譯後的 actor2 方法纔會出現。

那麼如何避免這種亂序呢?使用 volatile 肯定是可以避免的,但是這裏我們並不用勞煩 volatile 這種重操作出馬,就用 Opaque 訪問即可Opaque 其實就是禁止 Java 編譯器優化,但是沒有涉及任何的內存屏障,和 C++ 中的 volatile 非常類似。測試下:

image

運行下,可以發現,這個就沒有亂序了(命令行如果沒有 ACCEPTABLE_INTERESTING,FORBIDDEN,UNKNOWN 的 結果就不會輸出了,只能最後看輸出的 html):

image

7.2. Causality(因果性)與 Acquire/Release

image

在 Coherence 的基礎上,我們一般在某些場景還會需要 Causality

一般到這裏,大家會接觸到兩個很常見的詞,即 happens-before 以及 synchronized-with order,我們這裏先不從這兩個比較晦澀的概念開始介紹(具體概念介紹不會在這一章節解釋),而是通過一個例子,即假設某個對象字段 int x 初始爲 0,int y 也初始爲 0,這兩個字段不在同一個緩存行中後面的 jcstress 框架會自動幫我們進行緩存行填充),一個線程執行:

image

另一個線程執行(r1, r2 爲本地變量):

image

這個例子與我們前面的 CPU 緩存那裏的亂序分析舉得例子很像,在 Java 內存模型中,可能的結果有:

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

同樣的,第三個結果也是很有趣的,第二個線程先看到 y 更新,但是沒有看到 x 的更新。這個在前面的 CPU 緩存亂序那裏我們詳細分析,在前面的分析中,我們需要像這樣加內存屏障才能避免第三種情況的出現,即:

image

以及

image

簡單回顧下,線程 1 執行 x = 1 之後,在 y = 1 之前執行了寫屏障,保證 store buffer 的更新都更新到了緩存,y = 1 之前的更新都保證了不會因爲存在 store buffer 中導致不可見。線程 2 執行 int r1 = y 之後執行了讀屏障,保證 invalidate queue 中的需要失效的數據全部被失效,保證當前緩存中不會有髒數據。這樣,如果線程 2 看到了 y 的更新,就一定能看到 x 的更新。

我們進一步更形象的描述一下:我們把寫屏障以及後面的一個 Store(即 y = 1)理解爲將前面的更新打包,然後將這個包在這點發射出去,讀屏障與前面一個 Load(即 int r1 = y)理解成一個接收點,如果接收到發出的包,就在這裏將包打開並讀取進來。所以,如下圖所示:

image

在發射點,會將發射點之前(包括髮射點本身的信息)的所有結果打包,如果在執行接收點的代碼的時候接收到了這個包,那麼在這個接收點之後的所有指令就能看到包裏面的所有內容,即發射點之前以及發射點的內容。Causality(因果性),有的地方也叫做 Casual Consistency(因果一致性),它在不同的語境下有不同的含義,我們這裏僅特指:可以定義一系列寫入操作,如果讀取看到了最後一個寫入,那麼這個讀取之後的所有讀取操作,都能看到這個寫入以及之前的所有寫入操作。這是一種 Partial Order(半順序),而不是 Total Order(全順序),關於這個定義將在後面的章節詳細說明。

在 Java 中,Plain 訪問與 Opaque 訪問都不能保證 Causality,因爲 Plain 沒有任何的內存屏障,Opaque 只是有編譯器屏障,我們可以通過如下代碼測試出來:

首先是 Plain:

image

結果是:

image

然後是 Opaque:

image

這裏我們需要注意:由於前面我們看到, x86 CPU 是天然保證一些指令不亂序的,稍後我們就能看到是哪些不亂序保證了這裏的 Causality,所以 x86 的 CPU 都看不到亂序,Opaque 訪問就能看到因果一致性的結果,如下圖所示(AMD64 是一種 x86 的實現): image 但是,如果我們換成其他稍微弱一致一些的 CPU,就能看到 Opaque 訪問保證不了因果一致性,下面的結果是我在 aarch64 (是一種 arm 的實現): image

並且,還有一個比較有意思的點,即亂序都是 C2 編譯執行的時候發生的

那麼,我們如何保證 Causality 呢?同樣的,我們同樣不必勞煩 volatile 這麼重的操作,採用 release/acquire 模式即可。release/acquire 可以保證 Coherence + Causality。release/acquire 必須成對出現(一個 acquire 對應一個 release),可以將 release 視爲前面提到的發射點,acquire 視爲前面提到的接收點,那麼我們就可以像下圖這樣實現代碼:

image

image

然後,繼續在剛剛的 aarch64 的機器上面執行,結果是: image

可以看出,Causuality 由於使用了 Release/Acquire 保證了 Causality。注意,對於發射點和接收點的選取一定要選好,例如這裏我們如果換個位置,那麼就不對了:

示例一:發射點只會打包之前的所有更新,對於 x = 1 的更新在發射點之後,相當於沒有打包進去,所以還是會出現 1,0 的結果。

image

示例二:在接收點會解包,從而讓後面的讀取看到包裏面的結果,對於 x 的讀取在接收點之前,相當於沒有看到包裏面的更新,所以還是會出現 1,0 的結果。

image

由此,我們類比下 Doug Lea 的 Java 內存屏障設計,來看看這裏究竟用了哪些 Java 中設計的內存屏障。在 Doug Lea 的很早也是很經典的一篇文章中,介紹了 Java 內存模型以及其中的內存屏障設計,提出了四種屏障:

1.LoadLoad

如果有兩個完全不相干的互不依賴(即可以亂序執行的)的讀取(Load),可以通過 LoadLoad 屏障避免它們的亂序執行(即在 Load(x) 執行之前不會執行 Load(y)):

image

2.LoadStore

如果有一個讀取(Load)以及一個完全不相干的(即可以亂序執行的)的寫入(Store),可以通過 LoadStore 屏障避免它們的亂序執行(即在 Load(x) 執行之前不會執行 Store(y)):

image

3.StoreStore

如果有兩個完全不相干的互不依賴(即可以亂序執行的)的寫入(Store),可以通過 StoreStore 屏障避免它們的亂序執行(即在 Store(x) 執行之前不會執行 Store(y)):

image

4.StoreLoad

如果有一個寫入(Store)以及一個完全不相干的(即可以亂序執行的)的讀取(Load),可以通過 LoadStore 屏障避免它們的亂序執行(即在 Store(x) 執行之前不會執行 Load(y)):

image

那麼如何通過這些內存屏障實現的 Release/Acquire 呢?我們可以通過前面我們的抽象推出來,首先是發射點。發射點首先是一個 Store,並且保證打包前面的所有,那麼不論是 Load 還是 Store 都要打包,都不能跑到後面去,所以需要在 Release 的前面加上 LoadStore,StoreStore 兩種內存屏障來實現。同理,接收點是一個 Load,並且保證後面的都能看到包裏面的值,那麼無論 Load 還是 Store 都不能跑到前面去,所以需要在 Acquire 的後面加上 LoadLoad,LoadStore 兩種內存屏障來實現

但是呢我們可以在下一章中看到,其實目前來看這四個內存屏障的設計有些過時了(由於 CPU 的發展以及 C++ 語言的發展) ,JVM 內部用的更多的是 acquire,release,fence 這三個。這裏的 acquire 以及 release 其實就是我們這裏提到的 Release/Acquire。這三個與傳統的四屏障的設計的關係是:

image

我們這裏知道了 Release/Acquire 的內存屏障,x86 爲何沒有設置這個內存屏障就沒有這種亂序呢?參考前面的 CPU 亂序圖: image

通過這裏我們知道,x86 對於 Store 與 Store,Load 與 Load,Load 與 Store 都不會亂序,所以天然就能保證 Casuality

7.3. Consensus(共識性)與 Volatile

image

最後終於來到我們所熟悉的 Volatile 了,Volatile 其實就是在 Release/Acquire 的基礎上,進一步保證了 Consensus;Consensus 即所有線程看到的內存更新順序是一致的,即所有線程看到的內存順序全局一致,舉個例子:假設某個對象字段 int x 初始爲 0,int y 也初始爲 0,這兩個字段不在同一個緩存行中後面的 jcstress 框架會自動幫我們進行緩存行填充),一個線程執行:

image

另一個執行:

image

在 Java 內存模型下,同樣可能有4種結果:

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

第四個結果比較有意思,他是不符合 Consensus 的,因爲兩個線程看到的更新順序不一樣(第一個線程看到 0 代表他認爲 x 的更新是在 y 的更新之前執行的,第二個線程看到 0 代表他認爲 y 的更新是在 x 的更新之前執行的)。如果沒有亂序,那麼肯定不會看到 x, y 都是 0,因爲線程 1 和線程 2 都是先更新後讀取的。但是也正如前面所有的講述一樣,各種亂序造成了我們可以看大第三個這樣的結果。那麼 Release/Acquire 能否保證不會出現這樣的結果呢?我們來簡單分析下,如果對於 x,y 的訪問都是 Release/Acquire 模式的,那麼線程 1 實際執行的就是:

image

這裏我們就可以看出來,x = 1 與 int r1 = y 之間沒有任何內存屏障,所以實際可能執行的是:

image

同理,線程 2 可能執行的是:

image

或者:

image

這樣,就會造成我們可能看到第四種結果。我們通過代碼測試下:

image

測試結果是: image

如果要保證 Consensus,我們只要保證線程 1 的代碼與線程 2 的代碼不亂序即可,即在原本的內存屏障的基礎上,添加 StoreLoad 內存屏障,即線程 1 執行:

image

線程 2 執行:

image

這樣就能保證不會亂序,這其實就是 volatile 訪問了。Volatile 訪問即在 Release/Acquire 的基礎上增加 StoreLoad 屏障,我們來測試下:

image

結果是:

image

那麼引出另一個問題,這個 StoreLoad 屏障是 Volatile Store 之後添加,還是 Volatile Load 之前添加呢?我們來做下這個實驗:

首先保留 Volatile Store,將 Volatile Load 改成 Plain Load,即:

image

測試結果:

image 從結果中可以看出,仍然保持了 Consensus。再來看保留 Volatile Load,將 Volatile Store 改成 Plain Store:

image

測試結果: image

發現又亂序了。

所以,可以得出結論,這個 StoreLoad 是加在 Volatile 寫之後的,在後面的 JVM 底層源碼分析我們也能看出來。

7.4 Final 的作用

Java 中,創建對象通過調用類的構造函數實現,我們還可能在構造函數中放一些初始化一些字段的值,例如:

image

我們可以這樣調用構造器創建一個對象:

image

我們合併這些步驟,用僞代碼表示底層實際執行的是:

image

他們之間,沒有任何內存屏障,同時根據語義分析,1 和 5 之間有依賴關係,所以 1 和 5 的前後順序不能變。1,2,3,4 之間有依賴,所以 1,2,3,4 的前後順序也不能變。2,3,4 與 5 之間,沒有任何關係,他們之間的執行順序是可能亂序的。如果 5 在 2,3,4 中的任一一步之前執行,那麼就會造成我們可能看到構造器還未執行完,x,y,z 還是初始值的情況。測試下:

image

在 x86 平臺的測試結果,你只會看到兩個結果,即 -1, -1, -1(代表沒看到對象初始化)和 1, 2, 3(看到對象初始化,並且沒有亂序),結果如下圖所示(AMD64 是一種 x86 的實現):

image

這是因爲,前文我們也提到過類似的, x86 CPU 是比較強一致性的 CPU,這裏不會亂序。至於由於 x86 哪種不亂序性質這裏纔不亂序,我們後面會看到。

還是和前文一樣,我們換到不那麼強一致性的 CPU (ARM)上執行,這裏看到的結果就比較熱鬧了,如下圖所示(aarch64 是一種 ARM 實現):

image

那我們如何保證看到構造器執行完的結果呢? 用前面的內存屏障設計,我們可以把僞代碼的第五步改成 setRelease,即:

image

前面我們提到過 setRelease 會在前面加上 LoadStore 和 StoreStore 屏障,StoreStore 屏障會防止 2,3,4 與 5 亂序,所以可以避免這個問題,我們來試試看:

image

再到前面的 aarch64 機器上試一下,結果是: image

從結果可以看出,只能看到要麼沒初始化,要麼完整的構造器執行後的結果了。

我們再進一步,其實我們這裏只需要 StoreStore 屏障就夠了,由此引出了 Java 的 final 關鍵字:final 其實就是在更新後面緊接着加入 StoreStore 屏障,這樣也相當於在構造器結束之前加入 StoreStore 屏障,保證了只要我們能看到對象,對象的構造器一定是執行完了的。測試代碼:

image

我們再進一步,由於僞代碼中 2,3,4 是互相依賴的,所以這裏我們只要保證 4 先於 5 執行,那麼2,3,一定先於 5 執行,也就是我們只需要對 z 設置爲 final,從而加 StoreStore 內存屏障,而不是每個都聲明爲 final,從而多加內存屏障

image

然後,我們繼續用 aarch64 測試,測試結果依然是對的: image

最後我們需要注意,final 僅僅是在更新後面加上 StoreStore 屏障,如果你在構造器過程中,將 this 暴露了出去,那麼還是會看到 final 的值沒有初始化,我們測試下:

image

這次我們在 x86 的機器上就能看到 final 沒有初始化:

image

最後,爲何這裏的示例中 x86 不需要內存屏障就能實現,參考前面的 CPU 圖:

image

x86 本身 Store 與 Store 之間就不會亂序,天然就有保證。

最後給上表格:

image

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章