Java記憶體模型基礎
Java記憶體模型的基礎
併發程式設計模型的兩個關鍵問題
在併發程式設計種,需要處理兩個關鍵問題:執行緒之間如何通訊及執行緒之間如何同步(這裡的執行緒是指併發執行的活動實體)。通訊是指執行緒之間以何種機制來交換資訊。在指令式程式設計種,執行緒之間的通訊機制有兩種:共享記憶體和訊息傳遞。
在共享記憶體的併發模型裡,執行緒之間共享程式的公共狀態,通過寫-讀記憶體中的公共狀態進行隱式通訊。在訊息傳遞的併發模型裡,執行緒之間沒有公共狀態,執行緒之間必須通過傳送訊息來顯式進行通訊。
同步是指程式中用於控制不同執行緒間操作發生相對順序的機制。在共享記憶體併發模型裡,同步是閒時間進行的。程式設計師必須顯式指定某個方法或某段程式碼需要線上程之間互斥執行。在訊息傳遞的併發模型裡,由於訊息的傳送必須在訊息的接受之前,因此同步是隱式進行的。
Java的併發採用的是共享記憶體模型,Java執行緒之間的通訊總是隱式進行,整個通訊過程對程式設計師完全透明。如果編寫多執行緒程式的Java程式設計師不理解隱式進行的執行緒之間通訊的工作機制,很可能會遇到各種奇怪的記憶體可見性問題。
Java記憶體模型的抽象結構
Java執行緒之間的通訊由Java記憶體模型(簡稱為JMM)控制,JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。
Java記憶體模型的抽象示意圖如圖所示:
如果執行緒A與執行緒B之間要通訊的話,必須要經歷下面2個步驟:
1)執行緒A把本地記憶體A中更新過的共享變數重新整理到主記憶體中去。
2)執行緒B到主記憶體中去讀取執行緒A之前已更新過的共享變數。
下面通過示意圖來說明這兩個步驟:
如圖所示,本地記憶體A和本地記憶體B由主記憶體中共享變數x的副本。假設初始時,這3個記憶體中的x值都為0。執行緒A在執行時,把更新後的x值(假設值為1)臨時存放在自己的本地記憶體A中。當執行緒A和執行緒B需要通訊時,執行緒A首先會把自己本地記憶體中修改後的x值重新整理到主記憶體中,此時主記憶體中的x值變為了1.隨後,執行緒B到主記憶體中讀取執行緒A更新後的x值,此時執行緒B的本地記憶體的x值也變為了1。
從整體來看,這兩個步驟實質上是執行緒A在向執行緒B傳送訊息,而且這個通訊過程必須要經過主記憶體。JMM通過控制主記憶體與每個執行緒的本地記憶體之間的互動,來為Java程式提供記憶體可見性保證。
從原始碼到指令序列的重排序
在執行程式時,為了提高效能,編譯器和處理器常常會對指令做重排序。重排序分3種類型。
1) 編譯器優化的重排序。編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
2) 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism,ILP)來講多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
3) 記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。
從Java原始碼到最終實際執行的指令序列,會分別經歷下面3種重排序,如圖所示:
上述的1屬於編譯器重排序,2和3屬於處理器重排序。
併發程式設計模型的分類
現在的處理器使用寫緩衝區臨時儲存向記憶體寫入的資料。寫緩衝區可以保證指令流水線持續執行,它可以避免由於處理器停頓下來等待向記憶體寫入資料而產生的延遲。同時,通過以批處理的方式重新整理寫緩衝區,以及合併寫緩衝區中對同一記憶體地址的多次寫,減少對記憶體匯流排的佔用。雖然寫快取區有這麼多好處,但每個處理器上的寫緩衝區,僅僅對它所在的處理器可見。這個特性會對記憶體操作的執行順序產生重要的影響:處理器對記憶體的讀/寫操作的執行順序,不一定與記憶體實際發生的讀/寫操作順序一致!為了具體說明,請看下面的圖:
假設處理器A和處理器B按程式的順序並執行記憶體訪問,最終可能得到x=y=0的結果。
這裡處理器A和處理器B可以同時把共享變數寫入自己的寫緩衝區(A1,B1),然後從記憶體中讀取另一個共享變數(A2,B2),最後才把自己寫快取區中儲存的髒資料重新整理到記憶體中(A3,B3)。當以這種時序執行時,程式就可以得到x=y=0的結果。
從記憶體操作實際發生的順序來看,直到處理器A執行A3來重新整理自己的寫快取區,寫操作A1才算真正執行了。雖然處理器A執行記憶體操作的順序為A1->A2,但記憶體操作實際發生的順序卻是A2->A1。此時,處理器A的記憶體操作順序被重排序了(處理器B的情況和處理器A一樣這裡就不贅述了)。
注意
- sparc_TSO是指以TSO(Total Store Order)記憶體模型執行時sparc處理器的特性。
- 表中的X86包含X64及AMD64。
- 由於ARM處理器的記憶體模型與PowerPC處理器的記憶體模型非常類似,本文將忽略它。
為了保證記憶體可見性,Java編譯器在生成指令序列的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。JMM把記憶體屏障指令來禁止指令分為四類,如表所示:
屏障型別 | 指令例項 | 說明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 確保Load1資料的裝載先於Load2及所有後續裝載指令的裝載 |
StoreStore Barriers | Store1;StoreStore;Store2 | 確保Store1資料對其他處理器可見(重新整理到記憶體)先於Store2及所有後續儲存指令的儲存 |
LoadStore Barriers | Load1;LoadStroe;Store2 | 確保Load1資料裝載先於Store2及所有後續的儲存指令重新整理到記憶體 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 確保Store1資料對其他處理器變得可見(指重新整理到記憶體)先於Load2及所有後續裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有記憶體訪問指令(儲存和裝載指令)完成之後,才執行該屏障之後的記憶體訪問指令。 |
StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他3個屏障的效果。現代的多處理器大多支援該屏障(其他型別的屏障不一定被所有處理器支援)。執行該屏障開銷會很昂貴,因為當前處理器通常要把寫緩衝區中的資料全部重新整理到記憶體中(Buffer Fuller Flush)。
happens-before簡介
從JDK5開始,Java使用新的JSR-133記憶體模型(除非特別說明,本文針對的都是JSR-133記憶體模型)。JSR-133使用happens-before的概念來闡述操作之間的記憶體可見性。在JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關係。這裡提到的兩個操作既可以是在一個執行緒之內,也可以是在不同執行緒之間。
與程式設計師密切相關的happens-before規則如下。
- 程式順序規則:一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作。
- 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
- volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
- 傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。
注意 兩個操作之間具有happens-before關係,並不意味著前一個操作必須要在後一個操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前(this first is visible to and ordered before the second)。
happens-before與JMM的關係如圖所示:
如圖所示,一個happens-before規則對應於一個或多個編譯器和處理器重排序規則。對於Java程式設計師來說,happens-before規則簡單易懂,它避免Java程式設計師為了理解JMM提供的記憶體可見性保證而去學習複雜的重排序規則以及這些規則的具體實現方法。
重排序
重排序是指編譯器和處理器為了優化程式效能而對指令序列進行重新排序的一種手段。
資料依賴性
如果兩個操作訪問同一個變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。資料以來分為下列3種類型。
名稱 | 程式碼示例 | 說明 |
---|---|---|
寫後讀 | a=1; b=a; | 寫一個變數之後,再讀這個位置 |
寫後寫 | a=1; a=2; | 寫一個變數之後,再寫這個變數 |
讀後寫 | a=b; b=1; | 讀一個變數之後,再寫這個變數 |
as-if-serial語義
as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。
為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。
順序一致性
資料競爭與順序一致性
Java記憶體模型規範對資料競爭的定義如下。
在一個執行緒中寫一個變數,
在另一個執行緒讀同一個變數,
而且寫和讀沒有通過同步來排序。
如果一個多執行緒程式能夠正確同步,這個程式將是一個沒有資料競爭的程式。
如果程式是正確同步的,程式的執行講具有順序一致性(Sequentially Consistent)——即程式的執行結果與該程式在順序一致性記憶體模型中的執行結果相同。這裡的同步是指廣義上的同步,包括對常用同步原語(synchronized、volatile和final)的正確使用。
順序一致性記憶體模型
順序一致性記憶體模型有兩大特性。
1)一個執行緒中的所有操作必須按照程式的順序來執行。
2)(不管程式是否同步)所有現場都只能看到一個單一的操作執行順序。在順序一致性記憶體模型中,每個操作都必須源自執行且立刻對所有執行緒可見。
順序一致性模型中,所有操作完全按程式的順序序列執行。
未同步程式的執行特性
未同步程式在兩個模型中的執行特性有如下幾個差異。
1)順序一致性模型保證單執行緒內的操作會按程式的順序執行,而JMM不保證單執行緒內的操作會按程式的順序執行
2)順序一致性模型保證所有執行緒只能看到一致的操作執行順序,而JMM不保證所有執行緒能看到一致的操作執行順序。
3)JMM不保證對64位的long型和double型變數的寫操作具有原子性,而順序一致性模型保證對所有的記憶體讀/寫操作都具有原子操作
在計算機中,資料通過匯流排在處理器和記憶體之間傳遞。每次處理器和記憶體之間的資料傳遞都是通過一系列步驟來完成的,這一系列步驟稱之為匯流排事務( Bus Transaction)。匯流排事務包括讀事務(Read Transaction)和寫事務(Write Transaction)。讀事務從記憶體傳送資料到處理器,寫事務從處理器傳送資料到記憶體,每個事務會讀/寫記憶體中一個或多個物理上來連續的字。這裡的關鍵是,匯流排會同步檢視併發使用匯流排的事務。在一個處理器執行匯流排事務期間,匯流排會禁止其他的處理器和I/O裝置執行記憶體的讀/寫。
在任意時間點,最多隻能有一個處理器可以訪問記憶體。這個特性確保了單個匯流排事務之中的記憶體讀/寫操作具有原子性。
在一些32位的處理器上,如果要求對64位資料的寫操作具有原子性,會有比較大的開銷。當JMM在這種處理器上執行時,可能會把一個64位long/double型變數的寫操作拆分為兩個32位的寫操作來執行。這兩個32位的寫操作可能會被分配到不同的匯流排事務中執行,此時對這個64位變數的寫操作將不具有原子性。