Java虛擬機器–記憶體模型

Java虛擬機器–記憶體模型

快取一致性:

“讓計算機併發處理多個任務”和“更充分利用計算機處理器的效能”之間看起來是因果關係,但實現起來非常麻煩。因為絕大多數運算任務都需要與記憶體互動,並非純粹的計算。由於處理器和記憶體的處理速度不匹配(處理器運算速度遠大於從記憶體中讀取資料的速度),所以現代計算機系統通常加入一層快取記憶體(Cache)來作為記憶體和處理器之間的緩衝:將運算需要的資料複製到Cache中,讓運算能快速進行;運算結束後再從快取同步到記憶體中。這樣處理器可以不用等待緩慢的記憶體讀寫。

這種方法解決了處理器和記憶體之間的速度問題,但引入了一個更復雜的問題:快取一致性。在多處理器系統中每個處理器都有自己的快取記憶體(Cache),而他們又共享同一個主記憶體,很容易想到,不同處理器對主記憶體的資料進行快取和回寫會帶來資料的不一致問題。這種情況下要引入一些協議來解決這個問題。

亂序執行:

為了使處理器內部運算單元能儘量被充分運用,處理器會對程式碼進行亂序執行優化,然後在計算後將結果重組,保證該結果與順序執行的結果一致,但不保證各語句的先後執行順序與輸入時的順序一致。Java虛擬機器的即時編譯器中也有類似的指令重排序優化。

Java記憶體模型:

記憶體模型可以理解為:在特定操作協議下,對特定的記憶體或快取進行讀寫訪問的過程抽象。

Java記憶體模型的主要目標是定義程式中的各個變數的訪問規則,即在虛擬機器中將變數儲存在記憶體和從記憶體中讀取變數這樣的底層細節。注意:這裡的變數和Java程式設計中的變數意義不同,它包括例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數和方法引數,因為後者是執行緒私有的,不會被共享,也就不存在競爭問題。

Java記憶體模型規定了所有變數都儲存在主記憶體中,每條執行緒還有自己的工作記憶體,執行緒的工作記憶體儲存了該執行緒所需要用到的變數的副本拷貝,執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接讀寫主記憶體的變數。執行緒間的變數訪問和傳遞不可直接進行,必須要通過主記憶體來完成。

可以將下面這張圖和快取一致性中的圖對比理解:

記憶體間的互動操作:

關於主記憶體和工作記憶體之間的互動協議,即讀取和回寫的實現細節,Java記憶體模型定義了8種操作來完成,這些操作含有原子性:

lock:主記憶體操作,鎖定變數,標識其為執行緒獨佔的狀態。
unlock:主記憶體操作,解鎖變數,將其從執行緒獨佔的狀態中釋放出來。
read:主記憶體操作,讀取變數到工作記憶體。
load:工作記憶體操作,將讀取到的變數賦值給工作記憶體中的變數副本。
use:工作記憶體操作,將變數值傳遞給執行引擎以供操作。
assign:工作記憶體操作,將執行引擎操作後的值賦給工作記憶體中的變數。
store:工作記憶體操作,將工作記憶體中的變數傳遞給主記憶體。
write:主記憶體變數,將store得到的值寫入主記憶體中的變數。

如果把一個變數從主記憶體複製到工作記憶體,必須順序執行read和load操作;反之將一個變數從工作記憶體回寫到主記憶體,store和write也要順序執行。但雖然要順序執行不代表到連續執行,也就是說兩條語句之間可以插入其他執令。

Java記憶體模型還規定了執行上述8鍾基本操作必須滿足的規則:

read和load,store和write必須成對出現,即工作記憶體或主記憶體必須將已經從另一方讀取到的值寫入自己所持有的變數,不允許拒絕。
工作記憶體最後一次assign必須同步回主記憶體,而工作記憶體同步回主記憶體的變數也必須執行過assign操作。
工作記憶體不可以使用未初始化的變數,即對一個變數實施use,store操作之前必須要有load和assign操作。
一個變數只能由一個執行緒lock,也只能由這個執行緒unlock,執行緒可以多次lock,對應的解鎖需要同樣次數的unlock。
不允許unlock未被lock過的變數。
unlock操作必須在store和write之後。

對volatile型變數的特殊規則:

關鍵字volatile可以說是Java虛擬機器提供的最輕量級的同步機制。當一個變數被定義為volatile之後,他將具備兩種特性:

第一,保證此變數對所有執行緒的可見性;
第二,禁止指令重排序優化;

下面分別討論這兩個特性:

保證變數對所有執行緒的可見性:

這裡的“可見性”是指當一條執行緒修改了這個變數的值,新值對其他變數來說是可以立即得知的,而普通變數做不到這一點。普通變數的值均需要通過主記憶體來完成,例如執行緒A修改了一個變數的值,然後向主記憶體回寫,執行緒B線上程A回寫完之後再從主記憶體中讀取值,新變數的值才對執行緒B可見。

但要注意,volatile變數在各個執行緒的工作記憶體中不存在一致性問題,但Java裡面的運算並非原子操作,導致volatile變數的運算在併發情況下一樣是不安全的。

例如:定義一個volatile型別的變數count,有一個方法是簡單地count 。如果發起20個執行緒每個執行緒呼叫1w次自增方法,最後count結果是小於20w。 因為雖然每個執行緒取count的時候能夠取到當前的正確的值,但由於 操作不是原子性的,在這個執行緒進行加操作的時候,其他執行緒可能已經把count的值加大了,而這個執行緒操作棧頂的值已經成了過期資料,然後回寫地時候就可能把較小的值回寫到主記憶體中。

由於volatile變數只能保證可見性,在不符合以下兩條規則的運算場景下,仍然需要通過加鎖(使用synchronized或java.util.concurrent中的原子類)來保證原子性:

運算結果並不依賴變數的當前值,或者能保證只有單一執行緒修改變數的值;
變數不需要與其他狀態變數共同參與不變約束。

禁止指令重排優化:

分析下面的程式碼:

volatile boolean initizlized = false;
//下面程式碼線上程A中執行
configOption = new HashMap();
...
initizlized = true;
//下面程式碼線上程B中執行
while(!initizlized){
sleep();
}

如果定義initialized的時候沒有使用volatile修飾,就有可能由於指令重排序優化,導致位於A執行緒的最後語句程式碼“initialized=true”被提前執行(所指的指令重排序是機器級的優化操作,提前執行是指這條語句對應的組合語言提前執行),這樣會讓B執行緒提前執行相關操作,很容易出問題,而使用volatile則可以避免這種情況的發生。

通過這8個基本操作和上述規定,再加上volatile特殊規則,可以確定Java程式中哪些記憶體訪問操作在併發的情況下是安全的。但根據上述嚴謹的定義去判斷,實踐起來比較麻煩,所以通常可以根據一個和定義等效的判斷原則—-先行發生原則,來判斷一個訪問在併發情況下是否安全。

下一篇:Java虛擬機器–先行發生原則