JVM之記憶體構成(二)--JAVA記憶體模型與併發
這部分內容,跟併發有關
我們知道,多工處理,在現代作業系統幾乎是必備功能。讓計算機同時去做幾件事情,不僅因為CPU運算能力太強大了,還有一個重要原因,CPU的運算速度遠遠高於它的儲存和通訊子系統的速度,大量時間耗費在磁碟I/O,網路I/O,資料庫訪問
虛擬機器層面,如何實現多執行緒,多執行緒之間因資料共享或競爭而引發的一系列問題及解決方案
物理機中的併發–硬體效率與一致性
物理機遇到的併發與虛擬機器中的情況,有不少相似之處,再擴充套件到分散式系統,我發現,其實也有不少相似之處。這之間有許多值得玩味的地方。
讓計算機併發執行多個運算任務
這裡面,不可能僅僅靠CPU計算就搞定的。CPU至少要跟記憶體互動,讀取運算資料,儲存運算結果,這個IO很難消除。當然,也無法僅僅靠CPU內的暫存器完成所有運算任務
CPU與儲存裝置之間的運算速度有幾個數量級的差距,所以現代計算機系統都不得不加入一層讀寫儘可能接近CPU速度的快取記憶體(Cache),作為記憶體與CPU間的緩衝。將運算需要的資料複製到緩衝,讓運算快速進行,完後將快取同步到記憶體。如此,CPU就無需等待緩慢的記憶體讀寫
在速度差距很大時,利用快取來緩衝,用空間換時間;但同時會帶來資料同步問題
引入了快取一致性(Cache Coherence)問題
多核處理器裡,每個CPU都有自己的快取記憶體(一級、二級、三級),而它們又共享同一主記憶體。
當多個CPU的運算任務都涉及同一塊主記憶體區域,可能導致各自的快取資料不一致;資料同步回主存時,以誰的快取資料為準呢?
為解決一致性問題,需要CPU訪問快取時都遵循一些協議,讀寫時,根據操作協議來。如MSI、MESI、MOSI、Synapse、Firefly、Dragon、Protocol
記憶體模型: 可以理解為,在特定操作協議下,對特定的記憶體或快取記憶體進行讀寫訪問的過程抽象
同時為了使得處理器充分被利用,CPU可能會對輸入程式碼進行亂序執行(Out-of-Order Execution)優化,CPU會在計算後將結果重組,保證結果與順序執行一致。Java虛擬機器的即時編譯器也有類似的指令重排序(Instruction Reorder)優化
若一個計算任務依賴另一計算任務的中間結果,那其順序性,不能靠程式碼的先後順序來保證
Java執行緒執行的記憶體模型
Java虛擬機器使用定義種Java記憶體模型,以遮蔽各種硬體和OS的記憶體訪問差異,以實現讓Java程式在各個平臺下都能達到一致的併發效果。C/C++直接使用物理硬體和OS的記憶體模型。
目標
定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出這樣的底層細節。此處的變數包括例項欄位、靜態欄位和構成陣列物件的元素,但是不包括區域性變數和方法引數,因為這些是執行緒私有的,不會被共享,所以不存在競爭問題
工作記憶體
工作記憶體
- 每條執行緒都有自己的工作記憶體(可與快取記憶體類比)
- 執行緒讀寫變數,必須在自己工作的工作記憶體中進行
- 工作記憶體儲存主記憶體變數的值的拷貝
- 不能直接讀寫主記憶體的變數
- 不同執行緒間,無法直接訪問對方工作記憶體的變數
- 執行緒間變數值的傳遞,需要通過主記憶體
主記憶體
主記憶體
- 所有的變數存在主記憶體(雖然名字跟物理機的主記憶體一樣,可類比,但此主記憶體只是虛擬機器記憶體的一部分)
記憶體間互動
一個變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體,Java記憶體模型定義了8種操作。這些操作,都是原子操作
Operation | Place | Instruction |
---|---|---|
lock | Main Memory | 將變數標識為一條執行緒獨佔狀態 |
unlock | Main Memory | 釋放被鎖定的變數,釋放後的變數才能被其他執行緒鎖定 |
read | Main Memory | 變數值從主記憶體讀取到執行緒的工作記憶體,以便緊接著的load操作 |
load | Working Memeory | 將read操作得到變數值放入工作記憶體的變數副本中 |
use | Working Memeory | 將工作記憶體的變數值傳遞給執行緒執行引擎 |
assign | Working Memory | 將一個從執行引擎接收到的值,賦給工作記憶體的變數 |
store | Working Memory | 將工作記憶體中的一個變數的值,傳送到主記憶體,以便緊接著的write操作 |
write | Main Memory | 將store操作的變數值,放入主記憶體的變數中 |
變數從主記憶體複製到工作記憶體,順序執行read和load
變數從工作記憶體同步到主記憶體,順序執行store和write
long和double的非原子性協定
Nonatomic Treatment of double and long Variables
Java記憶體模型要求8個操作都具有原子性,但對64位的資料型別,long和double,模型定義了相對寬鬆
- 允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作,劃分為2次32位的操作。
允許,並強烈建議,虛擬機器將這些操作實現為原子性操作。
目前商用Java虛擬機器幾乎都選擇把64位資料的讀寫作為原子操作來對待
編寫程式碼時,一般不需為long或double專門宣告為volatile
Volatile型別變數的特殊規則和語義
前面說過,Java記憶體模型,其實是定義讀寫記憶體變數的規則。
有些型別的變數比較特殊,除了上面所述的8個基本操作原則外,有特殊的規則。
特殊規則
- read、load、use操作,須連續一起出現,每次use時,都從主記憶體read,工作記憶體load主記憶體的值,相當於每次use都從主記憶體中獲取變數的最新值。保證能看見其他執行緒對變數的修改
- assign、store、write操作,須連續一起出現,工作記憶體中的每次修改,須立刻同步回主記憶體。保證其他執行緒可以看到自己對變數的修改
- 兩條執行緒,若A執行緒對變數a的use/assign操作,先於B執行緒對變數b的use/assign操作,那麼A執行緒對a變數的read/write操作,先於B執行緒對變數b的read/write操作。該規則要求變數不被指令重排序優化,保證程式碼執行順序與程式的順序相同
特殊語義
- 保證可見性
- 禁止指令重排優化
保證可見性
volatile是輕量級的synchronized,在多CPU開發中,保證了共享變數的“可見性”。
指當一條執行緒修改了變數的值,新的值可以被其他執行緒立即知道
volatile只能保證可見性,但無法保證原子性,which is a necessity for synchronization.
因此,如果不符合下面兩個規則的運算場景,我們需要通過加鎖,如synchronized關鍵字和java.util.concurrent包下的原子類,來保證源自性。如果符合,volatile就能保證同步
- 運算結果不依賴當前值,或者能夠確保只有單一執行緒修改變數的值
- 變數不需要與其他的狀態變數共同參與不變約束
如下面的程式碼,就非常適合用volatile變數來控制併發
volatile boolean shutdownRequested;
public void shutdown() {
shutdownRequested = true;
}
//當shutdown()被呼叫時,能保證所有執行緒中執行的doWork()方法都停下來
public void doWork() {
while(!shutdownRequest) {
//do something
}
}
禁止指令重排優化
被volatile修飾的變數,多執行了lock addl $0x0,(%esp)
操作
這個操作,相當於一個記憶體屏障(Memory Barrier/Memory Fence),意思是,重排序時,不能把後面的指令重排序到記憶體屏障之前的位置
lock addl $0x0,(%esp)
彙編指令,把ESP暫存器的值加0,這個是空操作。其作用,是使得本CPU的Cache寫入記憶體,該寫入動作,也會引起別的CPU或別的核心無效化(Invalidate)其Cache,相當於對Cache中的變數,做了一次如Java記憶體模型中的”Store且Write操作”。所以,通過這樣一個空操作,可讓volatile變數的修改,對其他CPU立即可見
硬體架構上講,指令重排序,是指CPU採用了允許將多條指令不按程式規定的順序,分開發送給各個相應電路單元處理,同時保證結果正確,與程式順序執行的結果一致。
高效併發的原則
Java記憶體模型,圍繞著併發過程中如何實現原子性、可見性和有序性,3個特徵來建立。我們來看看哪些操作,實現了這些特徵
可見性、有序性和原子性
原子性(Atomicity)
- 對基本資料型別的訪問和讀寫是具備原子性的。
- 對於更大範圍的原子性保證,Java記憶體提供lock,unlock操作,但未直接開發給使用者使用
- 更高層次,可以使用位元組碼指令monitorenter和monitorexit來**隱式使用**lock和unlock操作。這兩個位元組碼指令反映到Java程式碼中,就是同步塊——synchronized關鍵字。因此synchronized塊之間的操作也具有原子性。
可見性(Visibility)
- 當一個執行緒修改了共享變數的值,其他執行緒能夠立即得知這個修改。Java記憶體模型是通過在變數修改後將新值同步回主記憶體,在變數讀取之前從主記憶體重新整理變數值來實現
可見性的。volatile的特殊規則保證了新值能夠立即同步到主記憶體,每次使用前立即從主記憶體重新整理。 - synchronized和final也能實現可見性。unlock前,先同步資料到主存。final修飾的欄位在構造器中一旦被初始化完成,並且構造器沒有把this的引用傳遞出去,那麼其他執行緒中就能看見final欄位的值
有序性(Ordering)
- Java程式的有序性可以總結為一句話,如果在本執行緒內觀察,所有的操作都是有序的(執行緒內表現為序列的語義);如果在一個執行緒中觀察另一個執行緒,所有的操作都是
無序的(指令重排序和工作記憶體與主記憶體同步延遲線性)
先行發生(Happens-Before)
如果Java記憶體模型中所有的有序性,僅僅靠volatile和synchronized來完成,那麼一些操作會很繁瑣,但我們沒有感覺得到,因為有happens-before原則。
該原則是判斷資料是否存在競爭、執行緒是否安全的主要依據
- 先行原則
- Java記憶體模型中定義的兩項操作之間的偏序關係。如果操作A Happens-Before 操作B,意思是,B發生時,A產生的影響能被B觀察到
//執行緒A中執行
i = 1;
//執行緒B中執行
j = i;
//執行緒C中執行
i = 2;
如果操作A和操作C之間,不存在先行發生關係,C出現在A和B之間,那麼,C執行緒對變數j的修改,B執行緒不一定觀察得到,此時,B讀取到的資料可能不是最新的,不是執行緒安全的
Java記憶體模型中的先行發生
8條規則
- 程式次序規則(Program Order Rule)
- 一個執行緒內, 按照控制流順序,寫在前面的操作先行發生與寫在後面的操作
- 管程鎖定規則(Monitor Lock Rule)
- 一個unlock操作先行發生於後面對同一個鎖的lock操作(就是拿到一個同步監視器的鎖後,其他執行緒在這個鎖被釋放前,必須等待)
- Volatile變數規則(Volatile Variable Rule)
- 對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,“後面”指時間上的先後
- 執行緒啟動規則(Thread Start Rule)
- Thread物件的start()方法先行發生於此執行緒每一個動作
- 執行緒終止規則(Thread Termination Rule)
- 執行緒中的所有操作都先行發生於對此執行緒的終止檢測
- 執行緒中斷規則(Thread Interruption Rule)
- 對執行緒的interrupt()方法的呼叫,先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生
- 物件終結規則(Finalizer Rule)
- 物件的初始化完成(建構函式執行結束)先行發生於它的finalize()方法
傳遞性(Transitivity)
- A先行發生於B,B先行發生於C,那麼,A先行發生於C
時間上的先後,不等於“先行發生”。
- 一操作先行發生,推不出時間上先發生。有指令重排序存在。
時間先後順序與happens-before基本沒太大關係,衡量併發安全問題,一切以happens-before原則為準,不要受到時間順序的干擾
推薦閱讀
infoq 深入理解Java記憶體模型
infoq Java併發程式設計的藝術
併發程式設計網