深入Java内存模型
你可以在網上找到一大堆資料讓你了解JMM是什么東西,但大多在你看完后仍然會有很多疑問。happen-before是怎么工作的呢?用volatile會導致緩存的丟棄嗎?為什么我們從一開始就需要內存模型?
通過這篇文章,讀者可以學習到足以回答以上所有問題的知識。它包含兩大部分:第一部分是硬件層次的大體架構,第二部分是深入OpenJdk源代碼和實現。因此,即使你沒有太深入Java,你可能也會對第一部分感興趣。
硬件相關的東西 搞硬件的工程師一直在努力地優化他們產品的性能,使我們可以獲取更多的代碼外的高性能部件。然而,它帶來的問題是:當你的代碼在運行時,你并不能直觀地查看它是運行在什么場景下。有著無數硬件細節被抽象化。而抽象往往意味著容易有漏洞。
處理器緩存 對主存的請求是一項昂貴的操作,即使是在現代機器上,在執行的時候也會花上百納秒的時間。然后,其他操作的執行時間,不同于主存的訪問,其發展就顯緩慢。這個問題通常被稱為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,它使得加載的時候,如果存儲緩存中存在,則進行返回。
第二個陷阱是:保存什么時候會完成,這個并沒有任何保證。考慮一下下面的代碼: void executedOnCpu0() { value = 10; finished = true; }
void executedOnCpu1() { while(!finished); assert value == 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。這里有一張圖表,表示其中一種可能的執行情況:
xx
同步是很簡單容易,不是嗎?問題在于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)是一條告訴處理器在執行任何的加載前,先應用所有已經在失效隊列中的失效操作的指令。
因此,這兩個方法可以防止我們之前遇到的兩種情況。我們應該使用它: void executedOnCpu0() { value = 10; storeMemoryBarrier(); // Mighty Spell! finished = true; } void executedOnCpu1() { while(!finished); loadMemoryBarrier(); // I am a Wizard! assert value == 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 top of the stack is not zero, go to label 10 7: goto 0 // One more iteration 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 top two 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 top of the stack 29: invokespecial #6 // Invoke the constructor (the method) 32: athrow // Throw what we have at the top of the stack (an AssertionError) 33: return 你不應該單單看了字節碼就開始猜想你程序的執行(或者底層操作)。當JIT編譯器編譯它時,代碼會跟現在看到的基本兩樣。
我們做這個的目的就是為了了解它們是為誰工作的。
這些有兩個有趣的事情:
許多人都會忘記:斷言在默認情況下是關閉的。用-ea來啟用他們。 我們查找的名字:getfield和putfield 深入剖析 我們看到,用于加載和保存volatile和普通屬性的指令是一樣的。所以,一個好辦法就是找到編譯器是在哪里知道一個屬性是否是volatile。隨便看了一下,我們的目光停留在share/vm/ci/ciField.hpp文件。有趣的方法是 bool is_volatile() { return flags().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:
1 void membar_release() { append(new LIR_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(); }
void membar() { append(new LIR_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文件內: case lir_membar_release: membar_release(); break; memory barriers是平臺相關的,對于x86平臺,我們看cpu/x86/vm/c1_LIRAssembler_x86.cpp文件。我們發現x86在內存模型架構上相當嚴格,因此大部分的memory barriers都是沒有處理的。 void LIR_Assembler::membar_acquire() { // No x86 machines currently require load fences // __ load_fence(); }
void LIR_Assembler::membar_release() { // No x86 machines currently require store fences // __ store_fence(); } 然而這并不是所有: void LIR_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 void membar(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已經擁有了我們需要的充分的測試。 static class State { int x; int y; // acq/rel var }
@Override public void actor1(State s, IntResult2 r) { s.x = 1; s.x = 2; s.y = 1; s.x = 3; }
@Override public void actor2(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, 2] 36 ACCEPTABLE Before observing releasing write to, any value is OK for x. [1, 0] 65960 ACCEPTABLE_INTERESTING Can read the default or old value for y is observed. [1, 3] 50929785 ACCEPTABLE Can see a released value of y is observed. [1, 2] 7 ACCEPTABLE Can see a released value of 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會導致緩存被丟棄嗎?
為什么我們一開始就需要內存模型?
你覺得你可以回答這些?歡迎留言!
轉載于:https://juejin.im/post/5b9a0eb45188255c85020666
創作挑戰賽新人創作獎勵來咯,堅持創作打卡瓜分現金大獎總結
以上是生活随笔為你收集整理的深入Java内存模型的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 一步一步学会JDBC
- 下一篇: TCP-IP协议详解(2) 小喇叭开始广