深入理解JVM(③)學習Java的內存模型
前言
Java內存模型(Java Memory Model)用來屏蔽各種硬件和操作系統的內存訪問差異,這使得Java能夠變得非常靈活而不用考慮各系統間的兼容性等問題。定義Java內存模型並非一件容易的事情,從Java出生開始經過長時間的驗證和修補,直至JDK5發佈後Java內存模型才終於成熟、完善起來了。
主內存與工作內存
Java內存模型的主要目的是定義程序中各種變量的訪問規則,即關注在虛擬機中把變量值存儲到內存和從內存中取出變量值這樣的底層細節。
Java內存模型規定了所有變量都存儲在主內存(Main Memory)中(此處的內存為Java虛擬機內存的一部分)。每條線程還有自己的工作內存(Working Memory),線程的工作內存中保存了被該線程使用的變量內存副本,線程對變量的所有操作都必須在工作內存中進行,也不能直接讀寫主內存中數據。不同的線程之間變量值的傳遞均需要通過主內存來完成。
線程、主內存、工作內存三者的交互關係如下圖。
內存間交互操作
對於主內存和工作內存之間具體的交互協議,Java內存模型中定義了以下8中操作拉完成。
Java虛擬機實現時必須保證下面提及的每一種操作都是原子的、不可再分的
。
- lock(鎖定):作用於主內存的變量,它把一個變量標識為一條線程獨佔的狀態。
- unlock(解鎖):作用於主內存的變量,它把一個處於鎖定狀態的變量釋放出來,釋放後的變量才可以被其他線程鎖定。
- read(讀取):作用於主內存的變量,它把一個變量的值從主內存傳輸到線程的工作內存中,以便最後的load動作使用。
- load(載入):作用於工作內存變量,它把read操作從主內存中得到的變量值放入工作內存的變量副本中。
- use(使用):作用於工作內存的變量,它把工作內存中一個變量的值傳遞給執行引擎,每當遇到一個需要使用變量的值的位元組碼指令時將會執行這個操作。
- assign(賦值):作用於工作內存的變量,它把一個從執行引擎接收的值賦給工作內存的變量,每當虛擬機遇到一個給變量賦值的位元組碼指令時執行這個操作。
- store(存儲):作用於工作內存的變量,它工作內存中一個變量的值傳送到主內存中,以便隨後的write操作使用。
- write(寫入):作用於主內存的變量,它把store操作從工作內存中得到的變量的值放入主內存的變量中。
如果想要把一個變量從主內存複製到工作內存,那就要按順序執行read和load操作,如果要把變量從工作內存同步回主內存,就要按順執行store和write操作。
除此之外Java內存模型還規定了在執行上述8種基本操作時必須滿足如下規則:
- 不允許read和load、store和write操作之一單獨出現,即不允許一個變量從主內存讀取了但工作內存不接受,或工作內存發起會寫了但主內存不接受的情況出現。
- 不允許一個線程丟棄它最近的assign操作,即變量在工作內存中改變了之後必須把該變化同步回主內存。
- 不允許一個線程無原因地(沒有發生任何assign操作)把數據從線程的工作內存同步回主內存中。
- 一個新的變量只能在主內存中「誕生」,不允許在工作內存中直接使用一個未被初始化(load或assign)的變量,換句話說就是對一個變量實施use、store操作之前,必須先執行assign和load操作。
- 一個變量在同一時刻只允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變量才會被解鎖。
- 如果對一個變量執行lock操作,那將會清空工作內存中此變量的值,在執行引擎使用這個變量前,需要重新執行load或assign操作以初始化變量的值。
- 如果一個變量事先沒有被lock操作鎖定,那就不允許對它執行unlock操作,也不允許去unlock一個被其他線程鎖定的變量。
- 對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)。
上面的8中基本操作,以及這些規則限定了Java程序中哪些內存訪問操作在並發下是安全的。雖然繁瑣但很嚴謹,大致操作也可以簡化的描述為read、write、lock、unlock四種。我們只需要理解Java內存模型的定義即可。
對於volatile型變量的特殊規則
關鍵字volatile可以說是Java虛擬機提供的最輕量級的同步機制,但是它並不容易被正確、完整地理解,Java內存模型為volatile專門定義了一些特殊的訪問規則。
當一個變量被定義為volatile之後,它將具備兩項特性:
第一項是保證此變量對所有線程的「可見性」,可見性是指當一條線程修改了這個變量的值,新值對於其他線程來說是可以立即得知的。
關於volatile變量的可見性,雖然volatile變量對所有線程立即可見,但是基於volatile變量的所有的寫操作都能立刻反映到其他線程中的。這句話理論上來說沒毛病,但是volatile變量在運算過程中也是存在不一致的情況,因為在Java裏面的運算操作符並非原子操作,這導致volatile變量的運算在並發下一樣是不安全的。
例如:
/**
* @author jiomer
* @date Create in 2020
* @description volatile變量自增運算測試
*/
public class VolatileOneTest {
public static volatile int race = 0;
public static void increase(){
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0;i<THREADS_COUNT;i++){
threads[i] = new Thread(() -> {
for(int i1 = 0; i1 <10000; i1++){
increase();
}
});
threads[i].start();
}
while (Thread.activeCount() > 2){
Thread.yield();
}
System.out.println(race);
}
}
上述代碼發起了20個線程,每個線程對race變量進行10000次自增操作,如果能夠正確並發的話,運行結果應為20000
,但運行結果並不正確,並且每次輸出結果都小於20000
,且都不相同。
由於volatile變量只能保證可見性,在不符合以下兩條規則的運行場景中,我們仍然要通過加鎖(synchronized、java.util.concurent中的鎖或原子類)來保證原子性:
- 運算結果並不依賴變量的當前值,或者能夠確保只有單一的線程修改變量的值。
- 變量不需要與其他的狀態變量共同參與不變約束。
volatile變量的第二項特性是:禁止指令重排序優化。普通的變量金輝保證在該方法的執行過程中所有依賴複製結果的地方都能獲取到正確的結果,而不能保證變量賦值操作的順序與程序代碼中的執行順序一致。
針對long和double型變量的特殊規則
上面在介紹Java內存模型的內存交互操作時,介紹了8種操作並明確都是具有原子性的。
但是對於64位的數據類型(long和double),在模型中特別定義了一條寬鬆的規定:允許虛擬機將沒有被volatile修飾的64位數據的讀寫操作劃分為兩次32位的操作來進行,即允許虛擬機自行選擇是否要保證64位數據類型的load、store、read、和write這四個操作的原子性,這就是所謂的「long 和 double的非原子協定」。
從JDK9開始,HotSpot增加了一個實驗性的參數-XX:+AlwaysAtomicAccesses 來約束虛擬機堆所有數據類型進行原子性的訪問。另外由於現代中央處理器中一般都包含專門用於處理浮點數據的浮點運算器,所以在實際開發中,除非該數據有明確可知的線程競爭,否則一般不用刻意的把long和double類型的變量聲明為volatile。
原子性、可見性、有序性
Java內存模型是圍繞着在並發過程中如何處理原子性、可見性和有序性這三個特徵來建立的。
原子性
由Java內存模型來直接保證的原子性變量操作包括read
、load
、assign
、use
、store
和write
這六個,我們大致可以認為,基本數據類型的訪問、讀寫都是具備原子性的。
如果需要一個更大範圍的原子性保證,Java內存模型還提供了lock
和unlock
操作來滿足這種需求,這兩個操作更高層次的位元組碼指令是monitorenter
和monitorexit
來隱式地使用這兩個操作。這兩個操作反應到Java代碼中就是synchronized
關鍵字,所以synchronized
代碼塊之間的操作也具備原子性。
可見性
可見性就是指當一個線程修改了共享變量值時,其他線程能夠立即得知這個修改。Java內存模型是通過在變量修改後將新值同步回主內存,在變量讀取前從主內存刷新變量這種依賴主內存作為傳遞媒介的方式來實現可見性,無論是普通變量還是volatile變量都是如此。區別就在於volatile的特殊規則保證了新值能立即同步到主內存,以及每次使用前立即從主內存刷新。
除了volatile之外,Java還有兩個關鍵字能實現可見性,他們是synchronized
和final
。synchronized
的可見性是由「對一個變量執行unlock操作之前,必須先把此變量同步回主內存中(執行store、write操作)」這條規則獲得的。
final關鍵字的可見性是指:被final修飾的字段在構造器中一旦被初始化完成,並且構造器沒有吧「this」的引用傳遞出去,那麼在其他線程中就能看見final字段值。
有序性
Java程序中的有序性可以總結為一句話:如果在本線程內觀察,所有的操作都是有序的;如果在一個線程中觀察另一個線程,所有的操作都是無序的。
Java語言提供了volatile
和synchronized
兩個關鍵字拉保證線程之間操作的有序性,volatile
關鍵字本身就包含了禁止指令重排序的語義,而synchronized
則是由「一個變量在同一時刻只允許一條線程對其進行lock操作」這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步塊只能串行地進入。
先行發生原則(Happens-Before)
Java語言中有一個「先行發生」(Happens-Before)的原則。這個原則非常重要,它是判斷數據是否存在競爭,線程是否安全的非常有用的手段。
先行發生原則是Java內存模型中定義的兩項操作之間的偏序關係,例如操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,「影響」包括修改了內存中共享變量的值、發送了消息、調用了方法等。
下面幾項規則是Java內存模型中「天然的」先行發生關係,可以直接在代碼中使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推導出來,則它們就沒有順序性保障,虛擬機可以對它們隨意地進行重排序。
- 程序次序規則:在一個線程內,安裝控制流順序,書寫在前面的操作先行發生於書寫在後面的操作。(這裡說的是控制流順序,不是程序代碼順序,因為要考慮分支、循環等)
- 管程鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作。
- volatile變量規則:對一個volatile變量的寫操作先行發生於後面對這個變量的讀操作,這裡的「後面」同樣是指時間上的先後。
- 線程啟動規則:Thread對象的start()方法先行發生於此線程的每一個動作。
- 線程終止規則:線程中所有操作都先行發生於對此線程的終止檢測,我們可以通過Thread::join()方法是否結束、Thread::isAlive()的返回值等手段檢測線程是否已經終止執行。
- 線程中斷規則:對線程interrupt()方法的調用先行發生於被中斷線程的代碼檢測到中斷事件發生,可以通過Thread::interrupted()方法檢測到是否有中斷髮生。
- 對象終結規則:一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法開始。
- 傳遞性:如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。
舉個例子說明如何判斷操作間是否具備順序性,對於讀寫共享變量的操作來說,就是線程是否安全。
public class HappensBeforeTest {
private int item = 1;
public int getItem() {
return item;
}
public void setItem(int item) {
this.item = item;
}
}
上面的代碼,假設線程A(時間上的先後)先調用了setItem(2)
,然後線程B調用了同一個對象的getItem()
。
先分析一下,由於兩個方法不在一個線程中,所以程序次序規則不適用;沒有同步塊,所以管程鎖定規則也不適用;item沒有被volatile關鍵字修飾,所以volatile變量規則不適用;後面的線程啟動、終止、中斷規則和對象終結規則也和這沒關係。因為之前也沒有一個適用的先行發生規則,所以也沒有傳遞性規則;
所以我們判定,儘管線程A操作時間上先於線程B,但是無法確定線程B中getItem()方法的返回結果,也就是這裏面的操作不是線程安全的。
修復方式有多種,例如把getter/setter方法都加上synchronized,這樣符合管程鎖定規則;要麼把item設置成volatile變量;要麼等setter方法執行完成後再指向getter方法,符合程序次序規則。
既然一個操作在「時間上的先發生」不代表這個操作會是「先行發生」。那麼一個操作「先行發生」是否就能推導出這個操作必定是「時間上的先行發生」呢?
還是舉例說明吧
// 以下操作在同一個線程中執行
int item = 1;
int value = 2;
根據程序次序規則,int item = 1;
的操作先行發生於int value = 2;
,但是int value = 2;
的代碼完全可能先被處理器執行,這並不影響先行發生原則的正確性。
所以經過上面兩個論證,得出時間先後順序與先行發生原則之間基本沒有因果關係,在衡量是否線程安全時,一切必須以先行發生原則為準。