Java記憶體模型的基礎
在併發程式設計中,需要處理兩個關鍵問題:執行緒之間如何通訊及執行緒之間如何同步,通訊指執行緒之間以何種機制來交換資訊。在指令式程式設計中,執行緒之間的通訊機制有兩種:共享記憶體和訊息傳遞。
Java語言的併發採用的是共享記憶體模型,Java執行緒之間的通訊總是隱式進行,整個通訊過程對程式設計師完全透明。Java執行緒之間的通訊由Java記憶體模型簡稱JMM(Java Memory Mode)控制,JMM決定一個執行緒對共享變數的寫入何時對另一個執行緒可見。從抽象的角度來看,JMM是這樣定義執行緒和主記憶體之間的抽象關係的:執行緒之間的共享變數儲存在主記憶體(Main Memory)中,每個執行緒都有一個私有的本地記憶體(Local Memory),本地記憶體中儲存了該執行緒以讀/寫共享變數的副本。
主記憶體主要對應用於Java堆中的物件例項資料部分,而本地記憶體則對應於虛擬機器棧中的部分割槽域。從更基礎的層面上說,主記憶體直接對應於物理硬體記憶體,而為了獲取更好的執行速度,虛擬機器可能會讓本地記憶體優先儲存於暫存器和快取記憶體中,因為程式執行時主要訪問的是本地記憶體。
本地記憶體是JMM的一個抽象概念,並不是真實存在的。它涵蓋了快取、寫緩衝區、暫存器以及其他硬體和編譯器優化。Java記憶體模型的抽象示意圖如下所示。
從示意圖中來看,如果執行緒A與執行緒B之間要進行通訊,必須經歷如下2個步驟。
1. 執行緒A把本地記憶體中更新過的共享變數重新整理到主記憶體中。
2. 執行緒B從主記憶體中讀取執行緒A之前更新的共享變數。
重排序
在執行程式的過程中,為了提高效能,編譯器和處理器常常會對指令做重排序。重排序分3中型別。
1. 編譯器優化重排序。
Java虛擬機器的即時編譯器中存在指令重排序(Instruction Reorder),編譯器在不改變單執行緒程式語義的前提下,可以重新安排語句執行。
2. 指令級並行的重排序。
現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
3. 記憶體系統的重排序。
由於處理器使用快取和讀/寫緩衝區,當多個處理器的運算任務都設計同一塊記憶體區域時,資料的載入和儲存操作看上去可能是亂序執行的。
從Java原始碼到最終實際執行的指令序列,會分別經歷下面3中重排序。
上述1屬於編譯器重排序,2、3屬於處理器重排序。這些重排序可能會導致多執行緒程式出現記憶體可見性問題。對於編譯器,JMM的編譯器重排序規則會禁止特定型別的編譯器重排序(不是所有的編譯器重排序都要禁止)。對於處理器重排序,Java編譯器在生成指令序列時,插入特定的記憶體屏障(Memory Barriers)指令,通過記憶體屏障來禁止特定型別的處理器重排序。
編譯器和處理器為了優化程式效能,可能會對指令序列進行重新排序。下表展示了常見處理器允許重排序的型別列表。(Load:裝載 Store:儲存)
Load-Load | Load-Store | Store-Store | Store-Load | 資料依賴 | |
SPARC-TSO | N | N | N | Y | N |
x86 | N | N | N | Y | N |
IA64 | Y | Y | Y | Y | N |
PowerPC | Y | Y | Y | Y | N |
表格中“N”表示處理器不允許兩個操作重排序,“Y”表示允許重排序。從表中可以看到,常見處理器都允許Store-Load重排序;常見處理器都不允許對存在資料依賴的操作做重排序。SPARC-TSO和X86處理器擁有相對較強的處理器記憶體模型,它們僅僅允許對寫-讀操作做重排序(因為它們都使用了寫緩衝區)。
為了保證記憶體可見性,Java編譯器在生成指令序列時的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。JMM把記憶體屏障分為4類,如下表所示。
屏障型別 | 指令示例 | 說明 |
LoadLoad Barriers | Load1;LoadLoad;Load2 |
確保Load1資料的裝載先於Load2及所有後續裝載指令的裝載。 |
StoreStore Barriers | Store1; StoreStore;Store2 |
確保Store1資料對其他處理器可見(重新整理到記憶體)先於Store2 及後續所有儲存指令。 |
LoadStore Barriers | Load1;LoadStore;Store2 |
確保Load1資料裝載先於Store2及所有後續的儲存指令重新整理到記憶體。 |
StoreLoad Barriers |
Store1;StoreLoad;Load2 |
確保Store1資料對其他處理器可見(重新整理到記憶體)先於Load2及所有 後續裝載指令的裝載。 StoreLoad Barriers會使該記憶體屏障之前的所有記憶體訪問指令(儲存 和裝載)完成之後,才執行該屏障之後的記憶體訪問指令。 |
併發程式設計模型
由於計算機的儲存裝置和處理器的運算速度有著幾個量級的差距,所以現代計算機系統加入一層或者多層讀寫速度儘可能接近處理器速度的快取記憶體(Cache)來作為記憶體與處理器之間的快取。寫緩衝區可以保證指令流水線持續執行,它避免由於處理器停頓下來等待向記憶體寫入資料而產生的延遲。同時,通過批處理的方式重新整理寫緩衝區,以及合併寫緩衝區對同一記憶體地址的多次寫,減少對記憶體匯流排的佔用。
快取記憶體雖然解決了處理器與記憶體速度之間的矛盾,但是引入了新的問題:快取一致性(Cache Chherence)。
下面用一個例子來具體說明:
處理器A | 處理器B | |
程式碼 |
a = 1; //A1 x = b; //A2 |
b = 2; //B1 y = a; //B2 |
執行結果 |
初始狀態:a = b = 0 處理器允許執行後得到結果: x = y = 0 |
假設有處理器A和處理器B按照程式順序並行執行記憶體訪問,最終可能得到 x = y = 0 的結果。具體原因如下圖所示。
這裡處理器A和處理器B可以同時把共享變數寫入自己的寫緩衝區(A1,B1),然後從記憶體中讀取另一個共享變數(A2,B2),最後才把自己寫緩衝區裡中儲存的髒資料重新整理到記憶體中(A3,B3)。當以這種時序執行時,程式就有可能得到 a = b = 0 的結果。
從記憶體操作的實際發生順序來看,直到處理器A執行A3來重新整理自己的寫緩衝區,寫操作A1才算真正執行完成。雖然處理器A執行記憶體操作的順序為A1 → A2,但記憶體操作實際發生順序卻是A2 → A1。此時處理器A的記憶體操作順序被重排序了(處理器B的情況一樣)。
as-if-serial 語義
as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不能被改變。編譯器和處理器都必須遵守as-if-serial語義。
為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴的操作做重排序,因為這種操作會改變執行結果。但是如果操作之間不存在資料依賴關係,這些操作就有可能被重排序。下面舉例說明。
1 double pi = 3.14; // A 2 double r = 1.0; // B 3 double area = pi * r * r; // C
上面這段程式碼所示,A和C之間存在資料依賴關係,B和C之間也存在資料依賴關係。因此操作C不能被重排序到A和B前面(這樣程式的執行結果將被改變)。但是A和B之間沒有資料依賴關係,編譯器和處理器可以重排序A和B之間的順序。這段程式可能存在兩種執行順序,如下圖所示。
那哪些操作之間會存在資料依賴呢?如果兩個操作訪問同一個變數,且者兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。資料依賴分為下列3種型別。
名稱 | 程式碼示例 | 說明 |
寫後讀 |
a = 1; b = a; |
寫一個變數之後,再讀這個變數 |
寫後寫 |
a = 1; a = 2; |
寫一個變數之後,再寫這個變數 |
讀後寫 |
a = b; b = 1; |
讀一個變數之後,再寫這個變數 |
以上3種情況,只要重排序兩個操作之間的執行順序,程式的執行結果就會被改變。所以編譯器和處理器重排序時,會遵守資料依賴性,不會改變存在資料依賴性的兩個操作的執行順序。但是這裡說的資料依賴性僅僅指對單個處理器中的指令序列和單個執行緒中執行的操作。不同處理器和不同執行緒之間的資料依賴性不被編譯器和處理器考慮。
參考資料:《Java併發程式設計的藝術》、《深入理解Java虛擬機器》