深入理解Java記憶體模型(JMM)
JMM(Java Memory Model),Java記憶體模型,它是一種Java虛擬機器需要遵守的規範,定義了執行緒間如何在記憶體中正確地互動。JDK5以後的JMM規範在JSR-133中詳細列出。
1. 記憶體模型
1.1 為什麼需要記憶體模型
多執行緒程式設計的困難在於很難對程式進行調式,如果控制不好,就會產生意料之外的結果。對於傳統的單核CPU來說,由於是併發執行,即同一時刻只有一個執行緒在執行,所以一般不會出現資料的訪問衝突。這也不是絕對的,單核多執行緒場景下,如果允許搶佔式排程,仍存線上程安全問題。當前的處理器架構大多是多核+多級快取+主存的模式,這樣在多執行緒場景下就存在資料競爭從而造成 快取不一致 的問題。另外CPU可能會對程式進行優化,進行 指令重排序 ,只要重排後程序的語義沒有發生變化,指令重排就是有可能發生的(編譯器和JVM也存在指令重排),但這有時會讓多執行緒執行的結果出乎意料。

現代處理器架構
1.2 什麼是記憶體模型
對處理器來說,記憶體模型定義了充分必要條件,以知道其他處理器對記憶體的寫入對當前處理器可見,而當前處理器的寫入對其他處理器可見。一些處理器使用強記憶體模型,即所有處理器在任何給定的記憶體位置上始終能看到完全相同的值,但這也不是絕對的,某些時候也需要使用特殊指令(稱為記憶體屏障)來完成。其他處理器使用弱記憶體模型,需要記憶體屏障來重新整理或使本地處理器快取失效,以便檢視其他處理器的寫操作或使此處理器的寫操作對其他處理器可見。這些記憶體屏障通常在lock和unlock時執行;對於使用高階語言的程式設計師來說,它們是不可見的。處理器的設計趨勢是鼓勵使用弱記憶體模型,因為它們的規範具有更強的可伸縮性。
1.3 其他語言有記憶體模型嗎
大多數其他程式語言(如C和C ++)的設計並未直接支援多執行緒。 這些語言針對編譯器和體系結構中發生的各種重排序提供的保護很大程度上取決於所使用的執行緒庫(例如pthread),所使用的編譯器以及執行程式碼的平臺所提供的保證。
2.Java記憶體模型
2.1 簡介
Java記憶體模型是建立在記憶體模型之上的,它回答了當讀取一個確定的欄位時什麼樣的值是可見的。它將一個Java程式分解成若干動作(actions)並且為這些動作分配一個順序。如果分配的這些順序中能在對一個欄位的寫操作(write actions)和讀操作(read actions)間形成一個叫 happens-before
的關係,那麼Java記憶體模型保證了讀操作將返回一個正確的值。
JMM規定所有例項域、靜態域和陣列元素儲存在JVM記憶體模型的的堆中,堆記憶體線上程間是共享的。區域性變數和異常處理器引數不會共享,他們不存在記憶體可見性問題。每個執行緒建立時JVM都會為其建立一個工作記憶體(棧空間),工作記憶體是每個執行緒的私有資料區域,執行緒對變數的操作必須在工作記憶體中進行,首先要將變數從主記憶體拷貝到自己的工作記憶體空間,然後對變數進行操作,操作完成後再將變數寫回主記憶體`,不能直接操作主記憶體中的變數,各個執行緒中的工作記憶體中儲存著主記憶體中的變數副本拷貝,因此執行緒間的通訊必須通過主記憶體來完成。

2.2 程式碼優化問題

上面一段程式碼,模擬了兩個執行緒。期望可能是thread1執行一次 count++
,thread2修改 flag
的值,然後thread1退出迴圈。但是在未做同步控制的情況下多執行緒的執行情況是無法預料的。還存在一個很重要的問題,那就是編譯器優化(這裡編譯器可以是Java編譯器如JIT,JVM,CPU)。

- 對於thread1,沒有對flag的寫操作,所以編譯器認為flag的值總是true,就將flag直接改為true來提高程式執行速度,這種優化是被允許的,因為對於它本身而言沒有改變程式語義。
- 對於thread2,沒有要求對flag的值要刷回主存,編譯器就可能優化為忽略對flag的寫指令,因為不刷回主存的值改變只有執行緒自己可見。
2.3 指令重排序問題

對上圖中三條指令,我們期望是順序執行,但某些編譯器為了提高速度,很可能對指令重排序變成下面一種執行順序。

再來看看下面的例子
處理器A | 處理器B |
---|---|
a = 1; // 寫操作A1 | b =2; //寫操作B1 |
x = b; //讀操作A2 | y = a; //讀操作B2 |
初始狀態 a = b = 0 | 結果 x = y = 0 |
之所以會出現以上結果,是因為處理器對寫讀指令進行了重排序,如將順序A1 -> A2重排成A2 -> A1。對寫讀的重排序在x86架構下是被允許的。下圖是不同架構下支援的重排序型別,這解釋了為什麼相同的程式在不同的架構系統下會產生不同的結果,因為編譯器可能對你的程式碼進行了不同的重排序。

另外重排序需要考慮到資料之間的依賴性,比如下面3條指令,3是不會排到指令1之前的,因為指令3依賴於指令1的資料x。
int x = 1; //1 int y = 2; //2 y = x * x; //3
2.4 可見性問題

觀察以上程式碼,寫執行緒在自己的工作記憶體中改變了x的值卻並未來得及刷回主記憶體,這樣讀執行緒讀取到的值仍然是舊值,讀執行緒此時對寫執行緒的操作不可見。Java為此提供了volatile關鍵字解決方案:只要用volatile修飾變數x,對x進行原子操作後,x的值將立馬刷回主記憶體,這樣保證了讀執行緒對寫執行緒的可見性。
2.5 原子性問題

Java中long型佔8位元組,也就是64位,如果在64位作業系統中執行以上程式碼不存在原子性問題,對foo的寫操作一步完成,但是在32位作業系統中這種寫操作就失去了原子性。32位作業系統中對foo的寫操作分兩步進行-分別對高32位和低32位進行寫操作。在這種情況下就可能產生如下結果

2.6 Happens-before規則
Happens-before表示動作上的偏序關係,官方文件對於該規則的定義如下

大致翻譯一下就是:
兩個動作可以由happends-before關係排序,如果一個動作happens-before另一個動作,那麼第一個動作的執行結果對後一個動作可見。兩個操作之間存在happens-before關係,並不意味著必須要按照happens-before關係指定的順序來執行。如果重排序之後的執行結果,與按happens-before關係來執行的結果一致,那麼這種重排序並不非法。例如,線上程構造的物件的每個欄位中寫入預設值不需要在該執行緒的開始之前發生,只要沒有讀取操作就會觀察到該事實。另外,當兩個動作存在於不同的執行緒中時,也存在這種關係,此種情況下兩者之間會存在一個訊息傳遞機制。
happens-before的8條規則如下:
- 程式順序規則:一個執行緒中的每個操作,happens-before於該執行緒中的任意後續操作。
- 監視器鎖規則:對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
- volatile變數規則:對一個volatile域的寫,happens-before於任意後續對這個volatile域的讀。
- 傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C。
- start()規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那麼A執行緒的ThreadB.start()操作happens-before於執行緒B中的任意操作。
- join()規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作happens-before於執行緒A從ThreadB.join()操作成功返回。
- 程式中斷規則:對執行緒interrupted()方法的呼叫先行於被中斷執行緒的程式碼檢測到中斷時間的發生。
- 物件finalize規則:一個物件的初始化完成(建構函式執行結束)先行於發生它的finalize()方法的開始
2.7 實現
欄位域 | 方法域 |
---|---|
final | synchronized(method/block) |
volatile | java.util.concurrent.* |
volatile
public class VolatileFieldsVisivility{ int a = 0; volatile int x = 0; public void writeThread(){ a = 1; //1 x = 1; //2 } public void readThread(){ int r2 = x; //3 int d1 = a; //4 } }
假設寫執行緒執行完後,問讀執行緒讀變數a的值是1還是0還是不確定?答案是確定的1,即使變數a未用volatile修飾。由上面給出的happens-before規則可推得:1 happens-before 2, 2 happens-before 3 , 3 happens-before 4 --> 1 happens-before 4(傳遞性),即讀執行緒讀a的時候一定能看到寫執行緒的執行結果,簡短來說就是當一個執行緒對volatile修飾的變數寫入,並且讀取時也是此變數時在他之前的所有寫操作被保證對其他執行緒是可見的。值得注意的是,寫讀操作必須是原子性的,如果被volatile修飾的是long或者double,那麼這個64位的變數不能被拆分儲存。也就是說volatile保證了可見性和有序性,但不保證原子性。

由於篇幅過長,其他方式的實現我將在其他文章中單獨抽出來分析。
3. 總結
Java記憶體模型就是Java語言在記憶體模型上的實現,它是為了保證多執行緒場景下的原子性、可見性和有序性提出的規範。Java語言提供了 volatile
、 synchronized
、 final
關鍵字和 java.util.concurrent.*
併發程式設計包來實現這些規範,這些提供給程式設計師的原語和包遮蔽了和底層互動的細節,讓程式設計師可以更方便快捷地程式設計。