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虛擬機》