個人創作公約:本人聲明創作的所有文章皆爲自己原創,如果有參考任何文章的地方,會標註出來,如果有疏漏,歡迎大家批判。如果大家發現網上有抄襲本文章的,歡迎舉報,並且積極向這個 github 倉庫 提交 issue,謝謝支持~
本篇文章參考了大量文章,文檔以及論文,但是這塊東西真的很繁雜,我的水平有限,可能理解的也不到位,如有異議歡迎留言提出。本系列會不斷更新,結合大家的問題以及這裏的錯誤和疏漏,歡迎大家留言
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 抽象的理解有所幫助。但是,還是強調一點,內存模型的設計,出發點是讓大家可以不用關心底層而抽象出來的一些設計,涉及的東西很多,我的水平有限,可能理解的也不到位,我會盡量把每一個論點的論據以及參考都擺出來,請大家不要完全相信這裏的所有觀點,如果有任何異議歡迎帶着具體的實例反駁並留言。
1. 理解“規範”與“實現”
首先,我想先參考 Aleksey Shipilëv 大神的理解思路,即首先分清楚規範(Specification)與實現(Implementation)的區別。前面提到的 JLS(Java Language Specification)其實就是一種規範,它規範了 Java 語言,並且所有能編譯運行 Java 語言的 JDK 實現都要實現它裏面規定的功能。但是對於實際的實現,例如 Hotspot JVM 的 JDK,就是具體的實現了,從規範到實際的實現,其實是有一定的差異的。首先是下面這個代碼:
實際 HotSpot 最後編譯並且經過 JIT 優化與 CPU 指令優化運行的代碼其實是:
即將結果 3 放入寄存器並返回,這樣與原始代碼其實效果是一致的,省略了無用的本地變量操作,也是合理的。那麼你可能會有疑問:不會呀,我打斷點運行到這裏的時候,能看到本地變量 x,y,result 呀。這個其實是 JVM 運行時做的工作,如果你是以 DEBUG 模式運行 JVM,那麼其實 JIT 默認就不會啓用,只會簡單的解釋執行,所以你能看到本地變量。但是實際執行中,如果這個方法是熱點方法,經過 JIT 的優化,這些本地變量其實就不存在了。
還有一個例子是,Hotspot 會有鎖膨脹機制(這個我們後面還會測試),即:
如果按照 JLS 的描述,那麼 x = 1 與 y = 1 這兩個操作是不能重排序的。但是 Hotspot 實際的實現會將上面的代碼優化成: 那麼這樣,其實 x = 1 與 y = 1 這兩個操作就可以重排序了,這個我們後面也會驗證。
不同的 JVM 實現,實際的表現都會有些差異。並且就算是同一個 JVM 實現,在不同的操作系統,硬件環境等等,表現也有可能不一樣。例如下面這個例子:
正常情況下,r1 的值應該只有 {-1, 0}
這兩個結果之一。但是在某些 32 位的 JVM 上執行會有些問題,例如在 x86_32 的環境下,可能會有 {-1, 0, -4294967296, 4294967295}
這些結果。
所以,如果我們要全面的覆蓋底層到 JMM 設計以及 Hotspot 實現和 JIT 優化等等等等,涉及的東西太多太多,一層邏輯套邏輯,面面俱到我真的做不到。並且我也沒法保證我理解的百分百準確。如果我們要涉及太多的 HotSpot 實現,那麼我們可能就偏離了我們這個系列的主題,我們其實主要關心的是 Java 本身內存模型的設計規範,然後從中總結出我們在實際使用中,需要知道並且注意的點的最小集合,這個也是本系列要梳理的,同時,爲了保證本系列梳理出的這個最小集合準確,會加上很多實際測試的代碼,大家也可以跑一下看看這裏給出的結論以及對於 JMM 的理解是否正確。
2. 什麼是內存模型
任何需要訪問內存的語言,都需要有內存模型,描述如何訪問內存:即我可以用哪些方式去寫內存,可以用哪些方式去讀取內存,不同的寫入方式以及讀取方式,會有什麼不同的表現。當然,如果你的程序是一個簡單的串行程序,你讀取到的一定是最新寫入的值,這樣的情況下,其實你並不需要內存模型這種東西。一般是併發的環境下,纔會需要內存模型這個東西。
Java 內存模型其實就是規定了在 Java 多線程環境下,以不同的特定方式讀取或者寫入內存的時候,能觀察到內存的合理的值。
也有是這麼定義 Java 內存的,即 Java 指令是會重排序的,Java 內存模型規定了哪些指令是禁止重排序的,實際上這也是 JLS 第 17 章中 Java 內存模型中的主要內容。這其實也是實現觀察到內存的合理的值的方式,即對於給定的源代碼,可能的結果集是什麼。
我們接下來看兩個簡單的入門例子,作爲熱身。分別是原子性訪問,以及字分裂。
3. 原子性訪問
原子性訪問,對於一個字段的寫入與讀取,這個操作本身是原子的不可分割的。可能大家不經常關注的一點是根據 JLS 第 17 章中的說明,下面這兩個操作,並不是原子性訪問的: 因爲大家當前的系統通常都是 64 位的,得益於此,這兩個操作大多是原子性的了。但是其實根據 Java 的規範,這兩個並不是原子性的,在 32 位的系統上就保證不了原子性。我這裏直接引用 JLS 第 17 章的一段原話:
For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write. Writes and reads of volatile long and double values are always atomic.
翻譯過來,簡單來說非 volatile 的 long 或者 double 可能會按照兩次單獨的 32 位寫更新,所以是非原子性的。volatile 的 long 或者 double 讀取和寫入都是原子性的。
爲了說明我們這裏的原子性,我引用一個 jcstress 中的一個例子:
我們使用 Java 8 32bit (Java 9 之後就不再支持 32 位的機器了)的 JVM 運行這裏的代碼,結果是:
可以看到,結果不止 -1 和 0 這種我們代碼中的指定的值,還有一些中間結果。
4. 字分裂(word tearing)
字分裂(word tearing)即你更新一個字段,數組中的一個元素,會影響到另一個字段,數組中的另一個元素的值。例如處理器沒有提供寫單個 byte 的功能,假設最小維度是 int,在這樣的處理器上更新 byte 數組,若只是簡單地讀取 byte 所在的整個 int,更新對應的 byte,然後將整個 int 再寫回,這種做法是有問題的。Java 中沒有字分裂現象,字段之間以及數組元素之間是獨立的,更新一個字段或元素不能影響任何其它字段或元素的讀取與更新。
爲了說明什麼是字分裂,舉一個不太恰當的例子,即線程不安全的 BitSet。BitSet 的抽象是比特位集合(一個一個 0,1 這樣,可以理解爲一個 boolean 集合),底層實現是一個 long 數組,一個 long 保存 64 個比特位,每次更新都是讀取這個 long 然後通過位運算更新對應的比特位,再更新回去。接口層面是一位一位更新,但是底層卻是按照 long 的維度更新的(因爲是底層 long 數組),很明顯,如果沒有同步鎖,併發訪問就會併發安全問題從而造成字分裂的問題:
結果是:
這裏用了一個不太恰當的例子來說明什麼是字分裂,Java 中是可以保證沒有字分裂的,對應上面的 BitSet 的例子就是我們嘗試更新一個 boolean 數組,這樣結果就只會是 true true:
這個結果只會是 true true
接下來,我們將進入一個比較痛苦的章節了,內存屏障,不過大家也不用太擔心,從我個人的經驗來看,內存屏障很難理解的原因是因爲網上基本上不會從 Java 已經爲你屏蔽的底層細節去給你講,直接理解會很難說服自己,於是就會猜想一些東西然後造成誤解,所以本文不會上來丟給你 Doug Lea 抽象的並一直沿用至今的 Java 四種內存屏障(就是 LoadLoad,StoreStore,LoadStore 和 StoreLoad 這四個,其實通過後面的分析也能看出來,這四個內存屏障的設計對於現在的 CPU 來說已經有些過時了,現在用的更多的是 acquire, release 以及 fence)希望能通過筆者看的一些關於底層細節的文章論文中提取出便於大家理解的東西供大家參考,更好地更容易的理解內存屏障。
5. 內存屏障
5.1. 爲何需要內存屏障
內存屏障(Memory Barrier),也有叫內存柵欄(Memory Fence),還有的資料直接爲了簡便,就叫 membar,這些其實意思是一樣的。內存屏障主要爲了解決指令亂序帶來了結果與預期不一致的問題,通過加入內存屏障防止指令亂序(或者稱爲重排序,reordering)。
那麼爲什麼會有指令亂序呢?主要是因爲 CPU 亂序(CPU亂序還包括 CPU 內存亂序以及 CPU 指令亂序)以及編譯器亂序。內存屏障可以用於防止這些亂序。如果內存屏障對於編譯器和 CPU 都生效,那麼一般稱爲硬件內存屏障,如果只對編譯器生效,那麼一般被稱爲軟件內存屏障。我們這裏主要關注 CPU 帶來的亂序,對於編譯器的重排序我們會在最後簡要介紹下。
5.2. CPU 內存亂序相關
我們從 CPU 高速緩存以及緩存一致性協議出發,開始分析爲何 CPU 中會有亂序。我們這裏假設一種簡易的 CPU 模型,請大家一定記住,實際的 CPU 要比這裏列舉的簡易 CPU 模型複雜的多
5.2.1. 簡易 CPU 模型 - CPU 高速緩存的出發點 - 減少 CPU Stall
我們在這裏會看到,現代的 CPU 的很多設計,一切以減少 CPU Stall 出發。什麼是 CPU Stall 呢?舉一個簡單的例子,假設 CPU 需要直接讀取內存中的數據(忽略其他的結構,例如 CPU 緩存,總線與總線事件等等):
CPU 發出讀取請求,在內存響應之前,CPU 需要一直等待,無法處理其他的事情。這一段 CPU 就是處於 Stall 狀態。如果 CPU 一直直接從內存中讀取,CPU 直接訪問內存消耗時間很長,可能需要幾百個指令週期,也就是每次訪問都會有幾百個指令週期內 CPU 處於 Stall 狀態什麼也幹不了,這樣效率會很低。一般需要引入若干個高速緩存(Cache)來減少 Stall:高速緩存即與處理器緊挨着的小型存儲器,位於處理器和內存之間。
我們這裏不關心多級高速緩存,以及是否存在多個 CPU 共用某一緩存的情況,我們就簡單認爲是下面這個架構: 當需要讀取一個地址的值時,訪問高速緩存看是否存在:存在代表命中(hit),直接讀取。不存在被稱爲缺失(miss)。同樣的,如果需要寫一個值到一個地址,這個地址在緩存中存在也就不需要訪問內存了。大部分程序都表現出較高的局部性(locality):
- 如果處理器讀或寫一個內存地址,那麼它很可能很快還會讀或寫同一個地址。
- 如果處理器讀或寫一個內存地址,那麼它很可能很快還會讀或寫附近的地址。
針對局部性,高速緩存一般會一次操作不止一個字,而是一組臨近的字,稱爲緩存行。
但是呢,由於告訴緩存的存在,就給更新內存帶來了麻煩:當一個 CPU 需要更新一塊緩存行對應內存的時候,它需要將其他 CPU 緩存中這塊內存的緩存行也置爲失效。爲了維持每個 CPU 的緩存數據一致性,引入了緩存一致性協議(Cache Coherence Protocols)
5.2.2. 簡易 CPU 模型 - 一種簡單的緩存一致性協議(實際的 CPU 用的要比這個複雜) - MESI
現代的緩存一致性的協議以及算法非常複雜,緩存行可能會有數十種不同的狀態。這裏我們並不需要研究這種複雜的算法,我們這裏引入一個最經典最簡單的緩存一致性協議即 4 狀態 MESI 協議(再次強調,實際的 CPU 用的協議要比這個複雜,MESI 其實本身有些問題解決不了),MESI 其實指的就是緩存行的四個狀態:
- Modified:緩存行被修改,最終一定會被寫回入主存,在此之前其他處理器不能再緩存這個緩存行。
- Exclusive:緩存行還未被修改,但是其他的處理器不能將這個緩存行載入緩存
- Shared:緩存行未被修改,其他處理器可以加載這個緩存行到緩存
- Invalid:緩存行中沒有有意義的數據
根據我們前面的 CPU 緩存結構圖中所示,假設所有 CPU 都共用在同一個總線上,則會有如下這些信息在總線上發送:
- Read:這個事件包含要讀取的緩存行的物理地址。
- Read Response:包含前面的讀取事件請求的數據,數據來源可能是內存或者是其他高速緩存,例如,如果請求的數據在其他緩存處於 modified 狀態的話,那麼必須從這個緩存讀取緩存行數據作爲 Read Response
- Invalidate:這個事件包含要過期掉的緩存行的物理地址。其他的高速緩存必須移除這個緩存行並且響應 Invalidate Acknowledge 消息。
- Invalidate Acknowledge:收到 Invalidate 消息移除掉對應的緩存行之後,回覆 Invalidate Acknowledge 消息。
- Read Invalidate:是 Read 消息還有 Invalidate 消息的組合,包含要讀取的緩存行的物理地址。既讀取這個緩存行並且需要 Read Response 消息響應,同時發給其他的高速緩存,移除這個緩存行並且響應 Invalidate Acknowledge 消息。
- Writeback:這個消息包含要更新的內存地址以及數據。同時,這個消息也允許狀態爲 modified 的緩存行被剔除,以給其他數據騰出空間。
緩存行狀態轉移與事件的關係:
這裏只是列出這個圖,我們不會深入去講的,因爲 MESI 是一個非常精簡的協議,具體實現的時候會有很多額外的問題 MESI 無法解決,如果詳細的去講,會把讀者繞進去,讀者會思考在某個極限情況下這個協議要怎麼做才能保證正確,但是 MESI 實際上解決不了這些。在實際的實現中,CPU 一致性協議要比 MESI 複雜的多得多,但是一般都是基於 MESI 擴展的。
舉一個簡單的 MESI 的例子: 1.CPU A 發送 Read 從地址 a 讀取數據,收到 Read Response 將數據存入他的高速緩存並將對應的緩存行置爲 Exclusive
2.CPU B 發送 Read 從地址 a 讀取數據,CPU A 檢測到地址衝突,CPU A 響應 Read Response 返回緩存中包含 a 地址的緩存行數據,之後,地址 a 的數據對應的緩存行被 A 和 B 以 Shared 狀態裝入緩存
3.CPU B 對於 a 馬上要進行寫操作,發送 Invalidate,等待 CPU A 的 Invalidate Acknowledge 響應之後,狀態修改爲 Exclusive。CPU A 收到 Invalidate 之後,將 a 所在的緩存行狀態置爲 Invalid 失效
4.CPU B 修改數據存儲到包含地址 a 的緩存行上,緩存行狀態置爲 modified
5.這時候 CPU A 又需要 a 數據,發送 Read 從地址 a 讀取數據,CPU B 檢測到地址衝突,CPU B 響應 Read Response 返回緩存中包含 a 地址的緩存行數據,之後,地址 a 的數據對應的緩存行被 A 和 B 以 Shared 狀態裝入緩存
我們這裏可以看到,MESI 協議中,發送 Invalidate 消息需要當前 CPU 等待其他 CPU 的 Invalidate Acknowledge,也就是這裏有 CPU Stall。爲了避免這個 Stall,引入了 Store Buffer
5.2.3. 簡易 CPU 模型 - 避免等待 Invalidate Response 的 Stall - Store Buffer
爲了避免這種 Stall,在 CPU 與 CPU 緩存之間添加 Store Buffer,如下圖所示:
有了 Store Buffer,CPU 在發送 Invalidate 消息的時候,不用等待 Invalidate Acknowledge 的返回,將修改的數據直接放入 Store Buffer。如果收到了所有的 Invalidate Acknowledge 再從 Store Buffer 放入 CPU 的高速緩存的對應緩存行中。但是加入的這個 Store Buffer 又帶來了新的問題:
假設有兩個變量 a 和 b,不會處於同一個緩存行,初始都是 0,a 現在位於 CPU A 的緩存行中,b 現在位於 CPU B 的緩存行中:
假設 CPU B 要執行下面的代碼:
我們肯定是期望最後 b 會等於 2 的。但是真的會如我們所願麼?我們來詳細看下下面這個運行步驟:
1.CPU B 執行 a = 1:
(1)由於 CPU B 緩存中沒有 a,並且要修改,所以發佈 Read Invalidate 消息(因爲是要先把包含 a 的整個緩存行讀取後才能更新,所以發的是 Read Invalidate,而不只是 Invalidate)。
(2)CPU B 將 a 的修改(a=1)放入 Storage Buffer
(3)CPU A 收到 Read Invalidate 消息,將 a 所在的緩存行標記爲 Invalid 並清除出緩存,並響應 Read Response(a=0) 和 Invalidate Acknowlegde。
2.CPU B 執行 b = a + 1:
(1)CPU B 收到來自於 CPU A 的 Read Response,這時候這裏面 a 還是等於 0。
(2)CPU B 將 a + 1 的結果(0+1=1)存入緩存中已經包含的 b。
3.CPU B 執行 assert(b == 2) 失敗
這個錯誤的原因主要是我們在加載到緩存的時候沒考慮從 store buffer 最新的值,所以我們可以加上一步,在加載到緩存的時候從 store buffer 讀取最新的值。這樣,就能保證上面我們看到的結果 b 最後是 2:
5.2.4. 簡易 CPU 模型 - 避免 Store Buffer 帶來的亂序執行 - 內存屏障
我們下面再來看一個示例:假設有兩個變量 a 和 b,不會處於同一個緩存行,初始都是 0。假設 CPU A (緩存行裏面包含 b,這個緩存行狀態是 Exclusive)執行:
假設 CPU B 執行:
如果一切按照程序順序預期執行,那麼我們期望 CPU B 執行 assert(a == 1) 是成功的,但是我們來看下面這種執行流程: 1.CPU A 執行 a = 1:
(1)CPU A 緩存裏面沒有 a,並且要修改,所以發佈 Read Invalidate 消息。
(2)CPU A 將 a 的修改(a=1)放入 Storage Buffer
2.CPU B 執行 while (b == 0) continue:
(1)CPU B 緩存裏面沒有 b,發佈 Read 消息。 3.CPU A 執行 b = 1:
(1)CPU A 緩存行裏面有 b,並且狀態是 Exclusive,直接更新緩存行。
(2)之後,CPU A 收到了來自於 CPU B 的關於 b 的 Read 消息。
(3)CPU A 響應緩存中的 b = 1,發送 Read Response 消息,並且緩存行狀態修改爲 Shared
(4)CPU B 收到 Read Response 消息,將 b 放入緩存
(5)CPU B 代碼可以退出循環了,因爲 CPU B 看到 b 此時爲 1
4.CPU B 執行 assert(a == 1),但是由於 a 的更改還沒更新,所以失敗了。
像這種亂序,CPU 一般是無法自動控制的,但是一般會提供內存屏障指令,告訴 CPU 防止亂序,例如:
smp_mb() 會讓 CPU 將 Store Buffer 中的內容刷入緩存。加入這個內存屏障指令後,執行流程變成:
1.CPU A 執行 a = 1:
(1)CPU A 緩存裏面沒有 a,並且要修改,所以發佈 Read Invalidate 消息。
(2)CPU A 將 a 的修改(a=1)放入 Storage Buffer
2.CPU B 執行 while (b == 0) continue:
(1)CPU B 緩存裏面沒有 b,發佈 Read 消息。 3.CPU B 執行 smp_mb():
(1)CPU B 將當前 Store Buffer 的所有條目打上標記(目前這裏只有 a,就是對 a 打上標記)
4.CPU A 執行 b = 1:
(1)CPU A 緩存行裏面有 b,並且狀態是 Exclusive,但是由於 Store Buffer 中有標記的條目 a,不直接更新緩存行,而是放入 Store Buffer(與 a 不同,沒有標記)。併發出 Invalidate 消息。
(2)之後,CPU A 收到了來自於 CPU B 的關於 b 的 Read 消息。
(3)CPU A 響應緩存中的 b = 0,發送 Read Response 消息,並且緩存行狀態修改爲 Shared
(4)CPU B 收到 Read Response 消息,將 b 放入緩存
(5)CPU B 代碼不斷循環,因爲 CPU B 看到 b 還是 0
(6)CPU A 收到前面對於 a 的 "Read Invalidate" 相關的消息響應,將 Store Buffer 中打好標記的 a 條目刷入緩存,這個緩存行狀態爲 modified。
(7)CPU B 收到 CPU A 發的 Invalidate b 的消息,將 b 的緩存行失效,回覆 Invalidate Acknowledge
(8)CPU A 收到 Invalidate Acknowledge,將 b 從 Store Buffer 刷入緩存。
(9)由於 CPU B 不斷讀取 b,但是 b 已經不在緩存中了,所以發送 Read 消息。
(10)CPU A 收到 CPU B 的 Read 消息,設置 b 的緩存行狀態爲 shared,返回緩存中 b = 1 的 Read Response
(11)CPU B 收到 Read Response,得知 b = 1,放入緩存行,狀態爲 shared
5.CPU B 得知 b = 1,退出 while (b == 0) continue 循環
6.CPU B 執行 assert(a == 1)(這個比較簡單,就不畫圖了): (1)CPU B 緩存中沒有 a,發出 Read 消息。 (2)CPU A 從緩存中讀取 a = 1,響應 Read Response (3)CPU B 執行 assert(a == 1) 成功
Store Buffer 一般都會比較小,如果 Store Buffer 滿了,那麼還是會發生 Stall 的問題。我們期望 Store Buffer 能比較快的刷入 CPU 緩存,這是在收到對應的 Invalidate Acknowledge 之後進行的。但是,其他的 CPU 可能在忙,沒發很快應對收到的 Invalidate 消息並響應 Invalidate Acknowledge,這樣可能造成 Store Buffer 滿了導致 CPU Stall 的發生。所以,可以引入每個 CPU 的 Invalidate queue 來緩存要處理的 Invalidate 消息。
5.2.5. 簡易 CPU 模型 - 解耦 CPU 的 Invalidate 與 Store Buffer - Invalidate Queues
加入 Invalidate Queues 之後,CPU 結構如下所示:
有了 Invalidate Queue,CPU 可以將 Invalidate 放入這個隊列之後立刻將 Store Buffer 中的對應數據刷入 CPU 緩存。同時,CPU 在想主動發某個緩存行的 Invalidate 消息之前,必須檢查自己的 Invalidate Queue 中是否有相同的緩存行的 Invalidate 消息。如果有,必須等處理完自己的 Invalidate Queue 中的對應消息再發。
同樣的,Invalidate Queue 也帶來了亂序執行。
5.2.6. 簡易 CPU 模型 - 由於 Invalidate Queues 帶來的進一步亂序 - 需要內存屏障
假設有兩個變量 a 和 b,不會處於同一個緩存行,初始都是 0。假設 CPU A (緩存行裏面包含 a(shared), b(Exclusive))執行:
CPU B(緩存行裏面包含 a(shared))執行:
1.CPU A 執行 a = 1:
(1)CPU A 緩存裏面有 a(shared),CPU A 將 a 的修改(a=1)放入 Store Buffer,發送 Invalidate 消息。
2.CPU B 執行 while (b == 0) continue:
(1)CPU B 緩存裏面沒有 b,發佈 Read 消息。
(2)CPU B 收到 CPU A 的 Invalidate 消息,放入 Invalidate Queue 之後立刻返回。
(3)CPU A 收到 Invalidate 消息的響應,將 Store Buffer 中的緩存行刷入 CPU 緩存
3.CPU A 執行 smp_mb():
(1)因爲 CPU A 已經把 Store Buffer 中的緩存行刷入 CPU 緩存,所以這裏直接通過
4.CPU A 執行 b = 1:
(1)因爲 CPU A 本身包含 b 的緩存行 (Exclusive),直接更新緩存行即可。
(2)CPU A 收到 CPU B 之前發的 Read 消息,將 b 的緩存行狀態更新爲 Shared,之後發送 Read Response 包含 b 的最新值
(3)CPU B 收到 Read Response, b 的值爲 1
5.CPU B 退出循環,開始執行 assert(a == 1)
(1)由於目前關於 a 的 Invalidate 消息還在 Invalidate queue 中沒有處理,所以 CPU B 看到的還是 a = 0,assert 失敗
所以,我們針對這種亂序,在 CPU B 執行的代碼中也加入內存屏障,這裏內存屏障不僅等待 CPU 刷完所有的 Store Buffer,還要等待 CPU 的 Invalidate Queue 全部處理完。加入內存屏障,CPU B 執行的代碼是:
這樣,在前面的第 5 步,CPU B 退出循環,執行 assert(a == 1) 之前需要等待 Invalidate queue 處理完: (1)處理 Invalidate 消息,將 b 置爲 Invalid (2)繼續代碼,執行 assert(a == 1),這時候緩存內不存在 b,需要發 Read 消息,這樣就能看到 b 的最新值 1 了,assert 成功。
5.2.7. 簡易 CPU 模型 - 更細粒度的內存屏障
我們前面提到,在我們前面提到的 CPU 模型中,smp_mb()
這個內存屏障指令,做了兩件事:等待 CPU 刷完所有的 Store Buffer,等待 CPU 的 Invalidate Queue 全部處理完。但是,對於我們這裏 CPU A 與 CPU B 執行的代碼中的內存屏障,並不是每次都要這兩個操作同時存在:
所以,一般 CPU 還會抽象出更細粒度的內存屏障指令,我們這裏管等待 CPU 刷完所有的 Store Buffer 的指令叫做寫內存屏障(Write Memory Buffer),等待 CPU 的 Invalidate Queue 全部處理完的指令叫做讀內存屏障(Read Memory Buffer)。
5.2.8. 簡易 CPU 模型 - 總結
我們這裏通過一個簡單的 CPU 架構出發,層層遞進,講述了一些簡易的 CPU 結構以及爲何會需要內存屏障,可以總結爲下面這個簡單思路流程圖:
- CPU 每次直接訪問內存太慢,會讓 CPU 一直處於 Stall 等待。爲了減少 CPU Stall,加入了 CPU 緩存。
- CPU 緩存帶來了多 CPU 間的緩存不一致性,所以通過 MESI 這種簡易的 CPU 緩存一致性協議協調不同 CPU 之間的緩存一致性
- 對於 MESI 協議中的一些機制進行優化,進一步減少 CPU Stall:
- 通過將更新放入 Store Buffer,讓更新發出的 Invalidate 消息不用 CPU Stall 等待 Invalidate Response。
- Store Buffer 帶來了指令(代碼)亂序,需要內存屏障指令,強制當前 CPU Stall 等待刷完所有 Store Buffer 中的內容。這個內存屏障指令一般稱爲寫屏障。
- 爲了加快 Store Buffer 刷入緩存,增加 Invalidate Queue,
5.3. CPU 指令亂序相關
CPU 指令的執行,也可能會亂序,我們這裏只說一種比較常見的 - 指令並行化。
5.3.1. 增加 CPU 執行效率 - CPU 流水線模式(CPU Pipeline)
現代 CPU 在執行指令時,是以指令流水線的模式來運行的。因爲 CPU 內部也有不同的組件,我們可以將執行一條指令分成不同階段,不同的階段涉及的組件不同,這樣僞解耦可以讓每個組件獨立的執行,不用等待一個指令完全執行完再處理下一個指令。
一般分爲如下幾個階段:取指(Instrcution Fetch,IF)、譯碼(Instruction Decode,ID)、執行(Execute,EXE)、存取(Memory,MEM)、寫回(Write-Back, WB)
5.3.2. 進一步降低 CPU Stall - CPU 亂序流水線(Out of order execution Pipeline)
由於指令的數據是否就緒也是不確定的,比如下面這個例子:
倘若數據 a 沒有就緒,還沒有載入到寄存器,那麼我們其實沒必要 Stall 等待加載 a,可以先執行 c = 1; 由此,我們可以將程序中,可以並行的指令提取出來同時安排執行,CPU 亂序流水線(Out of order execution Pipeline)就是基於這種思路:
如圖所示,CPU 的執行階段分爲:
- Instructions Fetch:批量拉取一批指令,進行指令分析,分析其中的循環以及依賴,分支預測等等
- Instruction Decode:指令譯碼,與前面的流水線模式大同小異
- Reservation stations:需要操作數輸入的指令,如果輸入就緒,就進入 Functoinal Unit (FU) 處理,如果沒有沒有就緒就監聽 Bypass network,數據就緒發回信號到 Reservation stations,讓指令進圖 FU 處理。
- Functional Unit:處理指令
- Reorder Buffer:會將指令按照原有程序的順序保存,這些指令會在被 dispatched 後添加到列表的一端,而當他們完成執行後,從列表的另一端移除。通過這種方式,指令會按他們 dispatch 的順序完成。
這樣的結構設計下,可以保證寫入 Store Buffer 的順序,與原始的指令順序一樣。但是加載數據,以及計算,是並行執行的。前面我們已經知道了在我們的簡易 CPU 架構裏面,有着多 CPU 緩存 MESI, Store Buffer 以及 Invalidate Queue 導致讀取不到最新的值,這裏的亂序並行加載以及處理更加劇了這一點。並且,結構設計下,僅能保證檢測出同一個線程下的指令之間的互相依賴,保證這樣的互相依賴之間的指令執行順序是對的,但是多線程程序之間的指令依賴,CPU 批量取指令以及分支預測是無法感知的。所以還是會有亂序。這種亂序,同樣可以通過前面的內存屏障避免。
5.4. 實際的 CPU
實際的 CPU 多種多樣,有着不同的 CPU 結構設計以及不同的 CPU 緩存一致性協議,就會有不同種類的亂序,如果每種單獨來看,就太複雜了。所以,大家通過一種標準來抽象描述不同的 CPU 的亂序現象(即第一個操作爲 M,第二個操作爲 N,這兩個操作是否會亂序,是不是很像 Doug Lea 對於 JMM 的描述,其實 Java 內存模型也是參考這個設計的),參考下面這個表格:
我們先來說一下每一列的意思:
- Loads Reordered After Loads:第一個操作是讀取,第二個也是讀取,是否會亂序。
- Loads Reordered After Stores:第一個操作是讀取,第二個是寫入,是否會亂序。
- Stores Reordered After Stores:第一個操作是寫入,第二個也是寫入,是否會亂序。
- Stores Reordered After Loads:第一個操作是寫入,第二個是讀取,是否會亂序。
- Atomic Instructions Reordered With Loads:兩個操作是原子操作(一組操作,同時發生,例如同時修改兩個字這種指令)與讀取,這兩個互相是否會亂序。
- Atomic Instructions Reordered With Stores:兩個操作是原子操作(一組操作,同時發生,例如同時修改兩個字這種指令)與寫入,這兩個互相是否會亂序。
- Dependent Loads Reordered:如果一個讀取依賴另一個讀取的結果,是否會亂序。
- Incoherent Instruction Cache/Pipeline:是否會有指令亂序執行。
舉一個例子來看即我們自己的 PC 上面常用的 x86 結構,在這種結構下,僅僅會發生 Stores Reordered After Loads 以及 Incoherent Instruction Cache/Pipeline。其實後面要提到的 LoadLoad,LoadStore,StoreLoad,StoreStore 這四個 Java 中的內存屏障,爲啥在 x86 的環境下其實只需要實現 StoreLoad,其實就是這個原因。
5.5. 編譯器亂序
除了 CPU 亂序以外,在軟件層面還有編譯器優化重排序導致的,其實編譯器優化的一些思路與上面說的 CPU 的指令流水線優化其實有些類似。比如編譯器也會分析你的代碼,對相互不依賴的語句進行優化。對於相互沒有依賴的語句,就可以隨意的進行重排了。但是同樣的,編譯器也是隻能從單線程的角度去考慮以及分析,並不知道你程序在多線程環境下的依賴以及聯繫。再舉一個簡單的例子,假設沒有任何 CPU 亂序的環境下,有兩個變量 x = 0,y = 0,線程 1 執行:
線程 2 執行:
那麼線程 2 是可能 assert 失敗的,因爲編譯器可能會讓 x = 1
與 y = 1
之間亂序。
編譯器亂序,可以通過增加不同操作系統上的編譯器屏障語句進行避免。例如線程一執行:
這樣就不會出現 x = 1
與 y = 1
之間亂序的情況。
同時,我們在實際使用的時候,一般內存屏障指的是硬件內存屏障,即通過硬件 CPU 指令實現的內存屏障,這種硬件內存屏障一般也會隱式地帶上編譯器屏障。編譯器屏障一般被稱爲軟件內存屏障,僅僅是控制編譯器軟件層面的屏障,舉一個例子即 C++ 中的 volaile,它與 Java 中的 volatile 不一樣, C++ 中的 volatile 僅僅是禁止編譯器重排即有編譯器屏障,但是無法避免 CPU 亂序。
以上,我們就基本搞清楚了亂序的來源,以及內存屏障的作用。接下來,我們即將步入正題,開始我們的 Java 9+ 內存模型之旅。在這之前,再說一件需要注意的事情:爲什麼最好不要自己寫代碼驗證 JMM 的一些結論,而是使用專業的框架去測試
6. 爲什麼最好不要自己寫代碼驗證 JMM 的一些結論
通過前面的一系列分析我們知道,程序亂序的問題錯綜複雜,假設一段代碼,沒有任何限制所有可能的輸出結果是如下圖所示這個全集:
在 Java 內存模型的限制下,可能的結果被限制到了所有亂序結果中的一個子集:
在 Java 內存模型的限制下,在不同的 CPU 架構上,CPU 亂序情況不同,有的場景有的 CPU 會亂序,有的則不會,但是都在 JMM 的範圍內所以是合理的,這樣所有可能的結果集又被限制到 JMM 的一個個不同子集:
在 Java 內存模型的限制下,在不同的操作系統的編譯器編譯出來的 JVM 的代碼執行順序不同,底層系統調用定義不同,在不同操作系統執行的 Java 代碼又有可能會有些微小的差異,但是由於都在 JMM 的限制範圍內,所以也是合理的:
最後呢,在不同的執行方式以及 JIT 編譯下,底層執行的代碼還是有差異的,進一步導致了結果集的分化:
所以,如果你自己編寫代碼在自己的唯一一臺電腦唯一一種操作系統上面去試,那麼你所能試出來的結果集只是 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
這裏的標題我不太清楚究竟應該翻譯成什麼,因爲我看網上很多地方把 CPU Cache Coherence Protocol 翻譯成了 CPU 緩存一致性協議,即 Coherence 在那種語境下代表一致性,但是我們這裏的 Coherence 如果翻譯成一致性就不太合適。所以,之後的一些名詞我也直接沿用 Doug Lea 大神的以及 Aleksey 大神的定義。
那麼這裏什麼是 coherence 呢?舉一個簡單的例子:假設某個對象字段 int x 初始爲 0,一個線程執行:
另一個線程執行(r1, r2 爲本地變量):
那麼在 Java 內存模型下,可能的結果是包括:
r1 = 1, r2 = 1
r1 = 0, r2 = 1
r1 = 1, r2 = 0
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)是互相依賴的,進而限制了這種亂序:
這就是我在前面一章中提到的,爲什麼最好不要自己寫代碼驗證 JMM 的一些結論。雖然在 Java 內存模型的限制中,是允許第三種結果 1, 0 的,但是這裏通過這個例子是試不出來的。
我們這裏通過一個別扭的例子來騙過 Java 編譯器造成這種亂序:
我們不用太深究其原理,直接看結果:
發現出現了亂序的結果,並且,如果你自己跑一下這個例子,會發現這個亂序是發生在執行 JIT C2 編譯後的 actor2 方法纔會出現。
那麼如何避免這種亂序呢?使用 volatile 肯定是可以避免的,但是這裏我們並不用勞煩 volatile 這種重操作出馬,就用 Opaque 訪問即可。Opaque 其實就是禁止 Java 編譯器優化,但是沒有涉及任何的內存屏障,和 C++ 中的 volatile 非常類似。測試下:
運行下,可以發現,這個就沒有亂序了(命令行如果沒有 ACCEPTABLE_INTERESTING,FORBIDDEN,UNKNOWN 的 結果就不會輸出了,只能最後看輸出的 html):
7.2. Causality(因果性)與 Acquire/Release
在 Coherence 的基礎上,我們一般在某些場景還會需要 Causality
一般到這裏,大家會接觸到兩個很常見的詞,即 happens-before 以及 synchronized-with order,我們這裏先不從這兩個比較晦澀的概念開始介紹(具體概念介紹不會在這一章節解釋),而是通過一個例子,即假設某個對象字段 int x 初始爲 0,int y 也初始爲 0,這兩個字段不在同一個緩存行中(後面的 jcstress 框架會自動幫我們進行緩存行填充),一個線程執行:
另一個線程執行(r1, r2 爲本地變量):
這個例子與我們前面的 CPU 緩存那裏的亂序分析舉得例子很像,在 Java 內存模型中,可能的結果有:
r1 = 1, r2 = 1
r1 = 0, r2 = 1
r1 = 1, r2 = 0
r1 = 0, r2 = 0
同樣的,第三個結果也是很有趣的,第二個線程先看到 y 更新,但是沒有看到 x 的更新。這個在前面的 CPU 緩存亂序那裏我們詳細分析,在前面的分析中,我們需要像這樣加內存屏障才能避免第三種情況的出現,即:
以及
簡單回顧下,線程 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)理解成一個接收點,如果接收到發出的包,就在這裏將包打開並讀取進來。所以,如下圖所示:
在發射點,會將發射點之前(包括髮射點本身的信息)的所有結果打包,如果在執行接收點的代碼的時候接收到了這個包,那麼在這個接收點之後的所有指令就能看到包裏面的所有內容,即發射點之前以及發射點的內容。Causality(因果性),有的地方也叫做 Casual Consistency(因果一致性),它在不同的語境下有不同的含義,我們這裏僅特指:可以定義一系列寫入操作,如果讀取看到了最後一個寫入,那麼這個讀取之後的所有讀取操作,都能看到這個寫入以及之前的所有寫入操作。這是一種 Partial Order(半順序),而不是 Total Order(全順序),關於這個定義將在後面的章節詳細說明。
在 Java 中,Plain 訪問與 Opaque 訪問都不能保證 Causality,因爲 Plain 沒有任何的內存屏障,Opaque 只是有編譯器屏障,我們可以通過如下代碼測試出來:
首先是 Plain:
結果是:
然後是 Opaque:
這裏我們需要注意:由於前面我們看到, x86 CPU 是天然保證一些指令不亂序的,稍後我們就能看到是哪些不亂序保證了這裏的 Causality,所以 x86 的 CPU 都看不到亂序,Opaque 訪問就能看到因果一致性的結果,如下圖所示(AMD64 是一種 x86 的實現): 但是,如果我們換成其他稍微弱一致一些的 CPU,就能看到 Opaque 訪問保證不了因果一致性,下面的結果是我在 aarch64 (是一種 arm 的實現):
並且,還有一個比較有意思的點,即亂序都是 C2 編譯執行的時候發生的。
那麼,我們如何保證 Causality 呢?同樣的,我們同樣不必勞煩 volatile 這麼重的操作,採用 release/acquire 模式即可。release/acquire 可以保證 Coherence + Causality。release/acquire 必須成對出現(一個 acquire 對應一個 release),可以將 release 視爲前面提到的發射點,acquire 視爲前面提到的接收點,那麼我們就可以像下圖這樣實現代碼:
然後,繼續在剛剛的 aarch64 的機器上面執行,結果是:
可以看出,Causuality 由於使用了 Release/Acquire 保證了 Causality。注意,對於發射點和接收點的選取一定要選好,例如這裏我們如果換個位置,那麼就不對了:
示例一:發射點只會打包之前的所有更新,對於 x = 1 的更新在發射點之後,相當於沒有打包進去,所以還是會出現 1,0 的結果。
示例二:在接收點會解包,從而讓後面的讀取看到包裏面的結果,對於 x 的讀取在接收點之前,相當於沒有看到包裏面的更新,所以還是會出現 1,0 的結果。
由此,我們類比下 Doug Lea 的 Java 內存屏障設計,來看看這裏究竟用了哪些 Java 中設計的內存屏障。在 Doug Lea 的很早也是很經典的一篇文章中,介紹了 Java 內存模型以及其中的內存屏障設計,提出了四種屏障:
1.LoadLoad
如果有兩個完全不相干的互不依賴(即可以亂序執行的)的讀取(Load),可以通過 LoadLoad 屏障避免它們的亂序執行(即在 Load(x) 執行之前不會執行 Load(y)):
2.LoadStore
如果有一個讀取(Load)以及一個完全不相干的(即可以亂序執行的)的寫入(Store),可以通過 LoadStore 屏障避免它們的亂序執行(即在 Load(x) 執行之前不會執行 Store(y)):
3.StoreStore
如果有兩個完全不相干的互不依賴(即可以亂序執行的)的寫入(Store),可以通過 StoreStore 屏障避免它們的亂序執行(即在 Store(x) 執行之前不會執行 Store(y)):
4.StoreLoad
如果有一個寫入(Store)以及一個完全不相干的(即可以亂序執行的)的讀取(Load),可以通過 LoadStore 屏障避免它們的亂序執行(即在 Store(x) 執行之前不會執行 Load(y)):
那麼如何通過這些內存屏障實現的 Release/Acquire 呢?我們可以通過前面我們的抽象推出來,首先是發射點。發射點首先是一個 Store,並且保證打包前面的所有,那麼不論是 Load 還是 Store 都要打包,都不能跑到後面去,所以需要在 Release 的前面加上 LoadStore,StoreStore 兩種內存屏障來實現。同理,接收點是一個 Load,並且保證後面的都能看到包裏面的值,那麼無論 Load 還是 Store 都不能跑到前面去,所以需要在 Acquire 的後面加上 LoadLoad,LoadStore 兩種內存屏障來實現。
但是呢我們可以在下一章中看到,其實目前來看這四個內存屏障的設計有些過時了(由於 CPU 的發展以及 C++ 語言的發展) ,JVM 內部用的更多的是 acquire,release,fence 這三個。這裏的 acquire 以及 release 其實就是我們這裏提到的 Release/Acquire。這三個與傳統的四屏障的設計的關係是:
我們這裏知道了 Release/Acquire 的內存屏障,x86 爲何沒有設置這個內存屏障就沒有這種亂序呢?參考前面的 CPU 亂序圖:
通過這裏我們知道,x86 對於 Store 與 Store,Load 與 Load,Load 與 Store 都不會亂序,所以天然就能保證 Casuality
7.3. Consensus(共識性)與 Volatile
最後終於來到我們所熟悉的 Volatile 了,Volatile 其實就是在 Release/Acquire 的基礎上,進一步保證了 Consensus;Consensus 即所有線程看到的內存更新順序是一致的,即所有線程看到的內存順序全局一致,舉個例子:假設某個對象字段 int x 初始爲 0,int y 也初始爲 0,這兩個字段不在同一個緩存行中(後面的 jcstress 框架會自動幫我們進行緩存行填充),一個線程執行:
另一個執行:
在 Java 內存模型下,同樣可能有4種結果:
r1 = 1, r2 = 1
r1 = 0, r2 = 1
r1 = 1, r2 = 0
r1 = 0, r2 = 0
第四個結果比較有意思,他是不符合 Consensus 的,因爲兩個線程看到的更新順序不一樣(第一個線程看到 0 代表他認爲 x 的更新是在 y 的更新之前執行的,第二個線程看到 0 代表他認爲 y 的更新是在 x 的更新之前執行的)。如果沒有亂序,那麼肯定不會看到 x, y 都是 0,因爲線程 1 和線程 2 都是先更新後讀取的。但是也正如前面所有的講述一樣,各種亂序造成了我們可以看大第三個這樣的結果。那麼 Release/Acquire 能否保證不會出現這樣的結果呢?我們來簡單分析下,如果對於 x,y 的訪問都是 Release/Acquire 模式的,那麼線程 1 實際執行的就是:
這裏我們就可以看出來,x = 1 與 int r1 = y 之間沒有任何內存屏障,所以實際可能執行的是:
同理,線程 2 可能執行的是:
或者:
這樣,就會造成我們可能看到第四種結果。我們通過代碼測試下:
測試結果是:
如果要保證 Consensus,我們只要保證線程 1 的代碼與線程 2 的代碼不亂序即可,即在原本的內存屏障的基礎上,添加 StoreLoad 內存屏障,即線程 1 執行:
線程 2 執行:
這樣就能保證不會亂序,這其實就是 volatile 訪問了。Volatile 訪問即在 Release/Acquire 的基礎上增加 StoreLoad 屏障,我們來測試下:
結果是:
那麼引出另一個問題,這個 StoreLoad 屏障是 Volatile Store 之後添加,還是 Volatile Load 之前添加呢?我們來做下這個實驗:
首先保留 Volatile Store,將 Volatile Load 改成 Plain Load,即:
測試結果:
從結果中可以看出,仍然保持了 Consensus。再來看保留 Volatile Load,將 Volatile Store 改成 Plain Store:
測試結果:
發現又亂序了。
所以,可以得出結論,這個 StoreLoad 是加在 Volatile 寫之後的,在後面的 JVM 底層源碼分析我們也能看出來。
7.4 Final 的作用
Java 中,創建對象通過調用類的構造函數實現,我們還可能在構造函數中放一些初始化一些字段的值,例如:
我們可以這樣調用構造器創建一個對象:
我們合併這些步驟,用僞代碼表示底層實際執行的是:
他們之間,沒有任何內存屏障,同時根據語義分析,1 和 5 之間有依賴關係,所以 1 和 5 的前後順序不能變。1,2,3,4 之間有依賴,所以 1,2,3,4 的前後順序也不能變。2,3,4 與 5 之間,沒有任何關係,他們之間的執行順序是可能亂序的。如果 5 在 2,3,4 中的任一一步之前執行,那麼就會造成我們可能看到構造器還未執行完,x,y,z 還是初始值的情況。測試下:
在 x86 平臺的測試結果,你只會看到兩個結果,即 -1, -1, -1(代表沒看到對象初始化)和 1, 2, 3(看到對象初始化,並且沒有亂序),結果如下圖所示(AMD64 是一種 x86 的實現):
這是因爲,前文我們也提到過類似的, x86 CPU 是比較強一致性的 CPU,這裏不會亂序。至於由於 x86 哪種不亂序性質這裏纔不亂序,我們後面會看到。
還是和前文一樣,我們換到不那麼強一致性的 CPU (ARM)上執行,這裏看到的結果就比較熱鬧了,如下圖所示(aarch64 是一種 ARM 實現):
那我們如何保證看到構造器執行完的結果呢? 用前面的內存屏障設計,我們可以把僞代碼的第五步改成 setRelease,即:
前面我們提到過 setRelease 會在前面加上 LoadStore 和 StoreStore 屏障,StoreStore 屏障會防止 2,3,4 與 5 亂序,所以可以避免這個問題,我們來試試看:
再到前面的 aarch64 機器上試一下,結果是:
從結果可以看出,只能看到要麼沒初始化,要麼完整的構造器執行後的結果了。
我們再進一步,其實我們這裏只需要 StoreStore 屏障就夠了,由此引出了 Java 的 final 關鍵字:final 其實就是在更新後面緊接着加入 StoreStore 屏障,這樣也相當於在構造器結束之前加入 StoreStore 屏障,保證了只要我們能看到對象,對象的構造器一定是執行完了的。測試代碼:
我們再進一步,由於僞代碼中 2,3,4 是互相依賴的,所以這裏我們只要保證 4 先於 5 執行,那麼2,3,一定先於 5 執行,也就是我們只需要對 z 設置爲 final,從而加 StoreStore 內存屏障,而不是每個都聲明爲 final,從而多加內存屏障:
然後,我們繼續用 aarch64 測試,測試結果依然是對的:
最後我們需要注意,final 僅僅是在更新後面加上 StoreStore 屏障,如果你在構造器過程中,將 this 暴露了出去,那麼還是會看到 final 的值沒有初始化,我們測試下:
這次我們在 x86 的機器上就能看到 final 沒有初始化:
最後,爲何這裏的示例中 x86 不需要內存屏障就能實現,參考前面的 CPU 圖:
x86 本身 Store 與 Store 之間就不會亂序,天然就有保證。
最後給上表格:
8. 底層 JVM 實現分析
8.1. JVM 中的 OrderAccess 定義
JVM 中有各種用到內存屏障的地方:
- 實現 Java 的各種語法元素(volatile,final,synchronized,等等)
- 實現 JDK 的各種 API(VarHandle,Unsafe,Thread,等等)
- GC 需要的內存屏障:因爲要考慮 GC 多線程與應用線程(在 GC 算法中叫做 Mutator)的工作方式,究竟是停止世界(Stop-the-world, STW)的方式,還是併發的方式
- 對象引用屏障:例如分代 GC,複製算法,年輕代 GC 的時候我們一般是從一個 S 區複製存活對象到另一個 S 區,如果複製的過程,我們不想停止世界(Stop-the-world, STW),而是和應用線程同時進行,那麼我們就需要內存屏障,例如;
- 維護屏障:例如分區 GC 算法,我們需要維護每個區的跨區引用表以及使用情況表,例如 Card Table。這個如果我們想要應用線程與 GC 線程併發修改訪問,而不是停止世界,那麼也需要內存屏障。
- JIT 也需要內存屏障:同樣地,應用線程究竟是解釋執行代碼還是執行 JIT 優化後的代碼,這裏也是需要內存屏障的。
這些內存屏障,不同的 CPU,不同的操作系統,底層需要不同的代碼實現,統一的接口設計是:
不同的 CPU,不同的操作系統實現是不一樣的,結合前面 CPU 亂序表格:
我們來看下 linux + x86 的實現:
源代碼地址:orderAccess_linux_x86.hpp
對於 x86,由於 Load 與 Load,Load 與 Store,Store 與 Store 本來有一致性保證,所以只要沒有編譯器亂序,那麼就天生有 StoreStore,LoadLoad,LoadStore 屏障,所以這裏我們看到 StoreStore,LoadLoad,LoadStore 屏障的實現都只是加了編譯器屏障。同時,前文中我們分析過,acquire 其實就是相當於在 Load 後面加上 LoadLoad,LoadStore 屏障,對於 x86 還是需要編譯器屏障就夠了。release 我們前文中也分析過,其實相當於在 Store 前面加上 LoadStore 和 StoreStore,對於 x86 還是需要編譯器屏障就夠了。於是,我們有如下表格:
我們再看下前面我們經常使用的 Linux aarch64 下的實現:
源代碼地址:orderAccess_linux_aarch64.hpp
如前面表格裏面說,ARM 的 CPU Load 與 Load,Load 與 Store,Store 與 Store,Store 與 Load 都會亂序。JVM 針對 aarch64 沒有直接使用 CPU 指令,而是使用了 C++ 封裝好的內存屏障實現。C++ 封裝好的很像我們前面講的簡易 CPU 模型的內存屏障,即讀內存屏障(__atomic_thread_fence(__ATOMIC_ACQUIRE)
),寫內存屏障(__atomic_thread_fence(__ATOMIC_RELEASE)
),讀寫內存屏障(全內存屏障,__sync_synchronize()
)。acquire 的作用是作爲接收點解包讓後面的都看到包裏面的內容,類比簡易 CPU 模型,其實就是阻塞等待 invalidate queue 完全處理完保證 CPU 緩存沒有髒數據。release 的作用是作爲發射點將前面的更新打包發出去,類比簡易 CPU 模型,其實就是阻塞等待 store buffer 完全刷入 CPU 緩存。所以,acquire,release 分別使用讀內存屏障和寫內存屏障實現。
LoadLoad 保證第一個 Load 先於第二個,那麼其實就是在第一個 Load 後面加入讀內存屏障,阻塞等待 invalidate queue 完全處理完;LoadStore 同理,保證第一個 Load 先於第二個 Store,只要 invalidate queue 處理完,那麼當前 CPU 中就沒有對應的髒數據了,就不需要等待當前的 CPU 的 store buffer 也清空。
StoreStore 保證第一個 Store 先於第二個,那麼其實就是在第一個寫入後面放讀內存屏障,阻塞等待 store buffer 完全刷入 CPU 緩存;對於 StoreLoad,比較特殊,由於第二個 Load 需要看到 Store 的最新值,也就是更新不能只到 store buffer,同時過期不能存在於 invalidate queue 未處理,所以需要讀寫內存屏障,即全屏障。
8.2. volatile 與 final 的內存屏障源碼
我們接下來看一下 volatile 的內存屏障插入的相關代碼,以 arm 爲例子. 我們其實通過跟蹤 iload 這個字節碼就可以看出來如果 load 的是 volatile 關鍵字或者 final 關鍵字修飾的字段會怎麼樣,以及 istore就可以看出來如果 store的是 volatile 關鍵字或者 final 關鍵字修飾的字段會怎麼樣
對於字段訪問,JVM 中也有快速路徑和慢速路徑,我們這裏只看快速路徑的代碼:
對應源碼:
9. 一些 QA
9.1. 爲什麼看到某些地方在方法本地變量使用 final
對於本地變量中的 final(和前面提到的修飾字段的 final 不是一回事),這個單純從語義上講,其實並沒有什麼性能方面的考慮,僅僅是作爲一種標記。即:你可能在方法本地聲明很多變量,但是爲了語義清晰,就將肯定不會改的聲明爲 final。
JDK 的開發者一般用 final 本地變量來做這樣一件事,假設有如下代碼:
假設編譯器不會做任何優化,那麼 1,2,4 我們都各做了一次對於字段的訪問。如果有編譯器優化參與進來,那麼是有可能優化成下面的代碼的:
這樣,只會讀取 1 次 x 字段。這樣造成的問題是,代碼在被解釋器執行,不同的 JIT 優化執行的時候,如果 x 有併發的更新,那麼看到的可能的結果集是不一樣的。爲了避免這種歧義,如果我們確定我們這裏的函數只想讀取一次 x,那麼就直接寫成:
爲了標記 lx 是不會變的(同時也爲了表達我們只想讀一次 x),加上 final,就變成:
微信搜索“我的編程喵”關注公衆號,加作者微信,每日一刷,輕鬆提升技術,斬獲各種offer: 我會經常發一些很好的各種框架的官方社區的新聞視頻資料並加上個人翻譯字幕到如下地址(也包括上面的公衆號),歡迎關注: