深入Java記憶體模型
你可以在網上找到一大堆資料讓你瞭解JMM是什麼東西,但大多在你看完後仍然會有很多疑問。happen-before是怎麼工作的呢?用volatile會導致快取的丟棄嗎?為什麼我們從一開始就需要記憶體模型?
通過這篇文章,讀者可以學習到足以回答以上所有問題的知識。它包含兩大部分:第一部分是硬體層次的大體架構,第二部分是深入OpenJdk原始碼和實現。因此,即使你沒有太深入Java,你可能也會對第一部分感興趣。
硬體相關的東西
搞硬體的工程師一直在努力地優化他們產品的效能,使我們可以獲取更多的程式碼外的高效能部件。然而,它帶來的問題是:當你的程式碼在執行時,你並不能直觀地檢視它是執行在什麼場景下。有著無數硬體細節被抽象化。而抽象往往意味著容易有 ofollow,noindex">漏洞 。
處理器快取
對主存的請求是一項昂貴的操作,即使是在現代機器上,在執行的時候也會花上百納秒的時間。然後,其他操作的執行時間,不同於主存的訪問,其發展就顯緩慢。這個問題通常被稱為 Memory Wall ,而最明顯的解決方案就是引入快取。簡單來說,處理器對它經常訪問的主存資料儲存一份拷貝。你可以在 這裡 深入閱讀不同的快取架構,我們將會繼續另外一個問題:保持快取最新。
很明顯,當我們只有一個執行部件(從現在開始這裡指處理器)時是沒問題的,但當你擁有多於一個時,事情會變得複雜。
如果 A 快取了某些值,處理器 A 怎麼知道處理器 B 已經修改了它們呢?
或者,更一般地說,你怎麼保證 快取一致性
為了儲存記憶體狀態的一致性,處理器需要進行互動。那種互動的規則稱之為 快取一致性協議(cache coherency protocol)
快取一致性協議
現在有著很多不同的協議,不同的硬體廠商,甚至同一個廠商的不同產品線都會有所不同。儘管有著各種各樣的區別,但大部分協議都有著很多共同點,這也是我們需要深入 MESI 的原因。然而,它並沒有給讀者一個所有協議的完整概述。有一些協議(例如 基於目錄的 )是完全不一樣的。我們不準備深入他們。
在MESI中,每一個快取條目都會屬於以下狀態之一:
無效(Invalid) 快取不再擁有該條目
獨佔(Exclusive) 這個條目只存在於這個快取,沒有被修改
已修改(Modified) 處理器已經修改過這個值,但還沒有寫回主存或者傳送給其他處理器
共享(Shared) 多於一個處理器的快取擁有該條目
狀態之間的轉換是通過傳送特定的協議訊息。具體的訊息型別關係不大,所以在本文忽略了。你可以通過很多其他的資料去深入瞭解它們。我會推薦 Memory Barriers: a Hardware View for Software Hackers
諷刺的是:當我們深入時,訊息被用於併發修改狀態。這是個問題。那麼那些討厭Actor Model的人怎麼辦?
MESI優化和他們引入的問題
在還沒有說到細節時,我們知道訊息的傳遞是需要時間的,它使得狀態切換有更多的延遲。重要的是我們也需要意識到某些狀態的切換需要特殊的處理,可能會阻塞處理器。這些都將會導致各種各樣的穩定性和效能問題。
儲存快取(Store Bufferes)
如果你需要對一個在快取中的共享的變數進行寫入,你需要傳送一個失效( Invalidate )訊息給它的所有持有者,並且等待它們的確認。處理器在這段時間間隔內會阻塞,這是一個不爽的事情,因為這個時間的要求比普通執行一個指令要長得多。
在現實生活中,快取條目不只包含一個變數。這個被劃分出來的單元是一個快取鏈,通常包含多於一個變數,並且很多是64位元組大小的。
它會導致有趣的問題,例如 快取競爭
為了避免這種時間的浪費, Store Bufferes 被引入使用。處理器把它想要寫入到主存的值寫到快取,然後繼續去處理其他事情。當所有 失效確認(Invalidate Acknowledge) 都接收到時,資料才會最終被提交。
有人會想到這裡有一些隱藏的危險存在。簡單的一個就是處理器會嘗試從儲存快取(Store buffer)中讀取值,但它還沒有進行提交。這個的解決方案稱為 Store Forwarding ,它使得載入的時候,如果儲存快取中存在,則進行返回。
第二個陷阱是:儲存什麼時候會完成,這個並沒有任何保證。考慮一下下面的程式碼:
voidexecutedOnCpu0() {
value = 10;
finished = true;
}
voidexecutedOnCpu1() {
while(!finished);
assertvalue == 10;
}
試想一下開始執行時, CPU 0 儲存著finished在 Exclusive 狀態,而value並沒有儲存在它的快取中。(例如, Invalid )。在這種情況下,value會比finished更遲地拋棄儲存快取。完全有可能 CPU 1 讀取finished的值為true,而value的值不等於10。
這種在可識別的行為中發生的變化稱為 重排序(reordings) 。注意,這不意味著你的指令的位置被惡意(或者好意)地更改。
它只是意味著其他的CPU會讀到跟程式中寫入的順序不一樣的結果。
無效佇列
執行失效也不是一個簡單的操作,它需要處理器去處理。另外,儲存快取(Store Buffers)並不是無窮大的,所以處理器有時需要等待失效確認的返回。這兩個操作都會使得效能大幅降低。為了應付這種情況,引入了失效佇列。它們的約定如下:
對於所有的收到的 Invalidate 請求, Invalidate Acknowlege 訊息必須立刻傳送
Invalidate 並不真正執行,而是被放在一個特殊的佇列中,在方便的時候才會去執行。
處理器不會發送任何訊息給所處理的快取條目,直到它處理 Invalidate
也正是那些優化的情況會導致這種跟直覺不符的結果。讓我們看回程式碼,假設 CPU 1 存有 Exclusive 狀態的value。這裡有一張圖表,表示其中一種可能的執行情況:

同步是很簡單容易,不是嗎?問題在於steps (4) – (6)。當 CPU 1 在(4)接收到 Invalidate 時,它只是把它進行排列,並沒有執行。 CPU 1 在(6)得到 Read Response ,而對應的 Read 在(2)之前就被髮送。儘管這樣,我們也沒有使value失效,所以造成了assertion的失敗。如果那個操作早點執行就好了。但,唉,這該死的優化搞壞了所有事情!但從另一方面考慮,它給予了我們重要的效能優化。
那些硬體工程師沒辦法提前知道的是:什麼時候優化是允許的,而什麼時候並不允許。這也是他們為什麼把這個問題留給我們。它同時也給予我們一些小東西,標誌著:“單獨使用它很危險!用這個!”
硬體記憶體模型
軟體工程師在出發和巨龍搏鬥時被授予的魔法劍並不是真正的劍。同樣,那些搞硬體的傢伙給我們的是寫好的規則。他們描述著:當這個(或其他)處理器執行指令時,處理器能夠看見什麼值。我們能夠像符咒一樣把他們分類成Memory Barriers。對於我們的MESI例子,它描述如下:
Store Memory Barrier (a.k.a. ST, SMB, smp_wmb)是一條告訴處理器在執行這之後的指令之前,應用所有已經在儲存快取(store buffer)中的儲存的指令。
Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一條告訴處理器在執行任何的載入前,先應用所有已經在失效佇列中的失效操作的指令。
因此,這兩個方法可以防止我們之前遇到的兩種情況。我們應該使用它:
voidexecutedOnCpu0() {
value = 10;
storeMemoryBarrier(); // Mighty Spell!
finished = true;
}
voidexecutedOnCpu1() {
while(!finished);
loadMemoryBarrier(); // I am a Wizard!
assertvalue == 10;
}
哈!我們現在安全了。是時候來寫一些高效能並且正常的併發程式碼了!
啊,等等。它甚至不能編譯,顯示找不到方法。真糟糕。
一次編寫,處處執行
上面的那些快取一致性協議,memory barriers,記憶體清除(dropped caches)和類似的東西看起來都是噁心的平臺相關的東西。Java開發人員不應該關心這些東西。畢竟Java記憶體模型沒有重排序的概念。
如果你沒有完全理解上面的最後一段,你不應該繼續往下閱讀了。一個好的建議是先去學習一下JMM相關的知識。一個很好的入門教程應該是這篇 FAQ
但應該會有更抽象層次的重排序。你應該會有興趣看看JMM是怎麼對映到硬體模型的。讓我們從一個簡單的類開始:( github )
有許多不同的場景供我們去了解究竟發生了什麼:PrintAssembly很有趣,可以看到編譯器正在做什麼,而不用再去問別人, 疑惑 地告訴你快取被丟序了等等。我決定深入看看OpernJDK的C1(client編譯器)。由於client編譯器很少用在真實的應用中,作為教學用是一個好選擇。
我使用的是jdk8,版本號為 933:4f8fa4724c14 。在其他版本有可能會不一樣。
如果你以前從來就沒有深入過OpenJDK的原始碼(或者你有),你很難找你很感興趣的地方在哪裡。一個縮小查詢範圍的方法是取得你感興趣的位元組碼指令,大概看一下它。好的,我們就這樣做:
$ javac TestSubject.java && javap -c TestSubject
void executedOnCpu0();
Code:
0: aload_0 //Push this to the stack
1: bipush 10 //Push 10 to the stack
3: putfield #2 // Assign 10 to the second field(value) of this
6: aload_0 //Push this to the stack
7: iconst_1 //Push 1 to the stack
8: putfield #3 // Assign 1 to the third field(finished) of this
11: return
void executedOnCpu1();
Code:
0: aload_0 //Push this to the stack
1: getfield #3 // Load the third field of this(finished) and push it to the stack
4: ifne 10 //If the topof the stack is not zero, go to label 10
7: goto 0 //One moreiteration of the loop
10: getstatic #4 // Get the static system field $assertionsDisabled:Z
13: ifne 33 //If the assertions are disabled, go to label 33(the end)
16: aload_0 //Push this to the stack
17: getfield #2 // Load the second field of this(value) and push it to the stack
20: bipush 10 //Push 10 to the stack
22: if_icmpeq 33 //If the toptwo elements of the stack are equal, go to label 33(the end)
25: new #5 // Create a new java/lang/AssertionError
28: dup //Duplicate the topof the stack
29: invokespecial #6 // Invoke the constructor (the method)
32: athrow //Throw what we have at the topof the stack (an AssertionError)
33: return
你不應該單單看了位元組碼就開始猜想你程式的執行(或者底層操作)。當JIT編譯器編譯它時,程式碼會跟現在看到的基本兩樣。
我們做這個的目的就是為了瞭解它們是為誰工作的。
這些有兩個有趣的事情:
許多人都會忘記:斷言在預設情況下是關閉的。用-ea來啟用他們。
我們查詢的名字:getfield和putfield
深入剖析
我們看到,用於載入和儲存volatile和普通屬性的指令是一樣的。所以,一個好辦法就是找到編譯器是在哪裡知道一個屬性是否是volatile。隨便看了一下,我們的目光停留在share/vm/ci/ciField.hpp檔案。有趣的方法是
1boolis_volatile() { returnflags().is_volatile(); }
所以,我們現在的任務就是找到處理載入和儲存屬性的方法,並且通過結果分析呼叫上面這個方法的程式碼層次關係。Client編譯器在 Low-Level Intermediate Representation(LIR) 層上處理它們,程式碼在檔案:share/vm/c1/c1_LIRGenerator.cpp
C1中間展示
我們由儲存開始。我們深入的方法是void LIRGenerator::do_StoreField(StoreField* x),它在1658:1751行。我們看到的第一個顯眼的操作是:
if(is_volatile && os::is_MP()) {
__ membar_release();
}
很好,一個memory barrier!兩個下劃線是一個巨集,可以被展開為gen()->lir()->,另外,被呼叫的方法定義在share/vm/c1/c1_LIR.hpp:
1voidmembar_release() { append(newLIR_Op0(lir_membar_release)); }
所以,發生的事情就是我們對我們的展示新增多了一個操作ir_membar_release
被呼叫的方法有著平臺相關的實現。給x86(cpu/x86/vm/c1\_LIRGenerator\_x86.cpp)的很簡單:對於64位的屬性,我們嘗試一些技巧性的方法來保證寫入的原子性。因為 標準寫著 。這有點過時,但會在 Java9 的時候更新。最後一個我們需要看的是在方法最後的又一個memory barrier。
if(is_volatile && os::is_MP()) {
__ membar();
}
voidmembar() { append(newLIR_Op0(lir_membar)); }
這就是儲存的處理。
載入在原始碼稍微底層點,幾乎沒有包含任何新東西。它同樣有一些技巧性的方法來處理long和double的原子性,在載入完成後會新增lir_membar_acquire。
注意,我故意省略一些跟這關聯的東西,例如:GC相關的指令。
Memory Barrier型別和抽象層次(Abstraction Levels)
這個時候,你肯定會想 release 和 acquire memory barriers是什麼東西,因為我們到現在都還沒介紹。這完全是因為我們所看到的 store 和 load memory barriers是在MESI模型中的操作,然後我們現在正在看的是在其上幾層的抽層層次(或者任何其他的記憶體一致性協議)。在這一層,我們有不同的術語。
考慮到我們有兩種型別的操作, Load 和 Store ,我們擁有四種組合: LoadLoad , LoadStore , StoreLoad , StoreStore 。因此也很方便的得到四種相同名稱的memory barriers。
如果我們有一個 XY memory barrier,它表示所有的在barrier前的X操作必須比在barrier後的任意Y操作提前完成它們的操作。
例如,所有的在 StoreStore barrier前的 Store 操作必須比barrier後的任意 Store 操作早完成。 JSR-133 是關於這個主題的一本好書。
有些人會疑惑,認為memory barriers接收一個變數作為引數,然後阻止跨程序間對該變數的重排序。
Memory barriers只能用在一個執行緒內。恰當地組合使用它們,你可以保證其他執行緒在載入這些值的時候看到一致的情況。一般地說,JMM的所有抽象都是由正確的組合memory barriers來實現的。
還有一些 Acquire 和 Release 語義。一個擁有 release 語義的 write 操作要求所有在它之前的記憶體操作都必須在它執行前完成。而 read-acquire 操作是相反的情況。
有人會發現 Release Memory Barrier 可以通過LoadStore|StoreStore的結合來實現,而 Acquire Memory Barrier 是LoadStore|LoadLoad。StoreLoad就是我們上面看到的lir_membar。
生成彙編程式碼
現在我們已經找到了IR和它的memory barriers,我們可以深入本地實現層了。所有的處理都在share/vm/c1/c1_LIRAssembler.cpp檔案內:
caselir_membar_release:
membar_release();
break;
memory barriers是平臺相關的,對於x86平臺,我們看cpu/x86/vm/c1_LIRAssembler_x86.cpp檔案。我們發現x86在記憶體模型架構上相當嚴格,因此大部分的memory barriers都是沒有處理的。
voidLIR_Assembler::membar_acquire() {
// No x86 machines currently require load fences
// __ load_fence();
}
voidLIR_Assembler::membar_release() {
// No x86 machines currently require store fences
// __ store_fence();
}
然而這並不是所有:
voidLIR_Assembler::membar() {
// QQQ sparc TSO uses this,
__ membar( Assembler::Membar_mask_bits(Assembler::StoreLoad));
}
(我們深入cpu/x86/vm/assembler_x86.hpp)
// Serializes memory and blows flags
voidmembar(Membar_mask_bits order_constraint) {
if(os::is_MP()) {
// We only have to handle StoreLoad
if(order_constraint & StoreLoad) {
// All usable chips support "locked" instructions which suffice
// as barriers, and are much faster than the alternative of
// using cpuid instruction. We use here a locked add [esp],0.
// This is conveniently otherwise a no-op except for blowing
// flags.
// Any change to this code may need to revisit other places in
// the code where this idiom is used, in particular the
// orderAccess code.
lock();
addl(Address(rsp, 0), 0);// Assert the lock# signal here
}
}
}
因此,對於每一個volatile寫入,我們必須使用代價較大的形式為lock addl $0x0,(%rsp)的StoreLoad barrier。它強制要求我們去執行所有的掛起的儲存,並且有效地保證其他執行緒可以很快地看到最新的值。而對於volatile讀取,我們沒有使用其他的barriers。然而我們不能想著 volatile讀取跟普通的讀取是一樣簡單的 。
我們應該清楚雖然barriers沒有生成彙編程式碼,但它仍然存在在IR中的。如果他們被可以修改程式碼的元件(這裡指編譯器)忽略,那將會是一個類似的bug。
完整性檢查
雖然通過檢視OpenJDK原始碼來學習是很好的,所有真正的科學家都這樣,並且測試他們的理論。我們還是不要搞特例,同樣來學習吧。
Java併發很有趣
好訊息是我們不需要再重造輪子,因為已經有 jcstress 工具來長時間執行程式碼,並且把輸出完全聚合起來。它同樣幫我們做了很多沒什麼意義工作,包括那些我們根本沒意識到我們必須要去做的。
與其同時,jcstress已經擁有了我們需要的充分的 測試 。
staticclassState {
intx;
inty; // acq/rel var
}
@Override
publicvoidactor1(State s, IntResult2 r) {
s.x = 1;
s.x = 2;
s.y = 1;
s.x = 3;
}
@Override
publicvoidactor2(State s, IntResult2 r) {
r.r1 = s.y;
r.r2 = s.x;
}
我們有一個執行緒,用來執行儲存,而另外一個執行讀取,然後會輸出相應的狀態。框架已經幫我們聚合了需要的結果,這些結果滿足一定的 規則 。我們對由第二個執行緒得到的兩個可能出現的結果感興趣:[1,0]和[1,1]。在這兩種情況中,它載入了y == 1,但看不到寫入給x的值,或者載入了一個並不是y寫入時的最新值。根據我們的理論,這種情況的出現原因在於沒有volatile識別符號。讓我們看一下:
$ java -jar tests-all/target/jcstress.jar -v-t ".*UnfencedAcquireReleaseTest.*"
...
Observed state Occurrence Expectation Interpretation
----------------------------------------------------------------------------------------------------------------
[0, 0] 32725135 ACCEPTABLE Before observing releasing write to, any value is OK for$x.
[0, 1] 15 ACCEPTABLE Before observing releasing write to, any value is OK for$x.
[0, 2] 36 ACCEPTABLE Before observing releasing write to, any value is OK for$x.
[0, 3] 10902 ACCEPTABLE Before observing releasing write to, any value is OK for$x.
[1, 0] 65960 ACCEPTABLE_INTERESTING Can readthe default or old value for$x after $y is observed.
[1, 3] 50929785 ACCEPTABLE Can see a released value of $x if$y is observed.
[1, 2] 7 ACCEPTABLE Can see a released value of $x if$y is observed.
因此,83731840中有65960次 (≈ 0.07%),第二個執行緒得到了y == 1 && x == 0,這也證明了重排序是確實有執行的。
PrintAssembly的樂趣
我們需要檢查的第二件事情是:我們是否正確地預測生成的彙編程式碼。因此,我們添加了很多所需程式碼的呼叫,為了方便結果演示,取消了inlining,開啟了斷言(assertoins),並且跑在client模式下。
$ java -client -ea -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:MaxInlineSize=0 TestSubject
...
# {method} 'executedOnCpu0' '()V' in 'TestSubject'
...
0x00007f6d1d07405c: movl $0xa,0xc(%rsi)
0x00007f6d1d074063: movb $0x1,0x10(%rsi)
0x00007f6d1d074067: lock addl $0x0,(%rsp) ;*putfield finished
; - TestSubject::executedOnCpu0@8 (line 15)
...
# {method} 'executedOnCpu1' '()V' in 'TestSubject'
...
0x00007f6d1d061126: movzbl 0x10(%rbx),%r11d ;*getfield finished
; - TestSubject::executedOnCpu1@1 (line 19)
0x00007f6d1d06112b: test%r11d,%r11d
...
啊,就跟預想的一樣!是時候完成了。
讓我來提醒你那些你現在應該可以回答的問題:
它是怎麼實現的?
使用volatile會導致快取被丟棄嗎?
為什麼我們一開始就需要記憶體模型?
你覺得你可以回答這些?歡迎留言!