導讀
JVM記憶體模型
(JMM)是併發的基礎,要是想紮實的理解併發原理,那麼就必須對JMM有比較深刻的認識。相信大部分朋友都有所瞭解了。這兩天回顧了一下相關內容,在琢磨怎麼才能更加直觀的表達出這個記憶體模型
,並且對這個模型有比較深刻的認識。剛好最近想做做動畫,所以打算練練手嘗試下以動畫的形式來描述下這個模型,順便看看有沒有成長為一個動畫大師的資質。
為此,本文我我將從以下幾個方面展開來說明:
- 記憶體模型是什麼,有什麼用,以及
Java記憶體模型
是怎樣的; Java記憶體模型
是如何實現多執行緒同步
的;- 常見的
同步問題
;
無論你是跟同事、同學、上下級、同行、或者面試官撕逼的時候,大家都會使出自己畢生所學,通過各種手段一步一步的把對方逼上投降之路,典型的招式如奪命連環問,一環扣一環,直至分出高下。而討論到JMM
,大家常見的撕逼方式如下:
如果您對這些問題都瞭如指掌,那麼恭喜你,說明你的基礎很紮實,是個狠人。但是也可以看看我下面精心準備的動圖和說明圖,交流下,看看有無錯漏之處。如果你剛好有不太明白的知識點,可以繼續往下看,可以解開你一切的迷惑,撥開Java程式碼背後的記憶體模型迷霧。下次有人跟你討論Java記憶體模型
,就把這些問題甩給他。
本文我們來探討一下Java記憶體模型JMM。
說到JMM,我們不得不提及多處理器體系結構
,以及多執行緒
。
1、什麼是記憶體模型
什麼是記憶體模型
,為什麼需要記憶體模型。我們得從快取記憶體
帶來的一些問題說起。
1.1、快取記憶體
在多處理器
系統中,處理器通常具有一層或者多層的快取記憶體
,這可以通過加快對資料的訪問速度(因為資料更靠近處理器)和減少共享記憶體匯流排上的流量(因為可以滿足許多記憶體操作)來提高效能。
但是以上的流程又帶來了許多新的挑戰,例如:
當兩個處理器同時讀取和寫入相同的記憶體位置的時候會發生什麼呢?他們將在什麼條件下才可以看到一致的內容,怎麼確保所有的快取都是一致的呢?
為了解決一致性問題,需要各個處理器訪問快取的時候都遵循一些協議,讀寫的時候需要根據協議來進行操作,相關協議有:MSI、MESI、MOSI、Synapse、Firefly、Dragon Protocol等,如下圖:
記憶體模型
可以理解為在特定操作協議下對特定的記憶體或快取記憶體進行讀寫訪問過程的一個抽象,即記憶體模型。
1.2、記憶體模型
在處理器級別,記憶體模型
定義了必要和充分的條件,以便當讓其他處理器對記憶體的改動對當前處理器可見,不同的處理器有不同的記憶體模型:
- 一些處理器記憶體模型比較強大,表現為其所有處理器始終在任何給定的記憶體位置看到完全相同的值;
- 其他處理器的記憶體模型表現的比較弱,需要通過特殊的稱為
記憶體屏障
(memory barriers)的指令來重新整理快取,以便對快取的寫入對其他處理器可見,或使本地處理器快取記憶體無效,以便重新獲取其他處理器寫入的快取。這些記憶體屏障通常在執行鎖定和解鎖操作的時候執行,對於高階語言來說,它們是不可見的。後面在講解Java記憶體模型的時候會專門介紹下記憶體屏障。
2、Java記憶體模型
先來點概念性的東西,後面再上圖。
Java記憶體模型
描述了多執行緒
程式碼中哪些行為是合法的,以及執行緒如何通過記憶體進行互動。它描述了程式中變數與低階別的詳細資訊之間的關係,這些低階別詳細資訊在實際計算機系統中的儲存器或暫存器之間進行儲存和檢索。
Java語言提供了volatile, final, 和 synchronized 旨在幫助程式設計師向編譯器描述程式的併發要求。
Java記憶體模型定義了volatile和synchronized的行為,更重要的是,確保做了正確同步的Java程式可以在所有的處理器體系結構上正確執行。
Java記憶體模型主要參與者:
變數
:這裡的變數,主要指例項欄位、類變數,以及陣列中的物件元素,不包括區域性變數和方法引數(執行緒私有);
主記憶體
:共享的主儲存器,變數儲存在這裡;因為一個執行緒不可能訪問另一個執行緒的引數和區域性變數,所以將區域性變數視為駐留在共享主儲存器或者工作記憶體裡面都沒有關係;
工作記憶體
:每個執行緒都有一個工作記憶體,在其中保留了自己必須使用或分配的變數的工作副本。執行緒執行的時候,將對這些工作副本繼續操作。主記憶體包含每個變數的主副本。對於何時允許或要求執行緒將其變數的工作副本的內容傳輸到主副本存在一些規則,反之亦然;
Java執行緒
:後面介紹Java執行緒的文章會詳細講解。
那麼可以得出一些的Java記憶體模型參與者協作圖:
其中的執行緒引擎指的是JVM執行引擎。
2.1、Java記憶體模型原子操作
執行緒和主存的互動,Java記憶體模型定義了8種操作[1]:這些操作都是原子的(double和long型別有例外):
- use:變數從工作記憶體傳遞給執行引擎。每當虛擬機器執行緒遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作;
- assign:把一個從執行引擎接收到的值複製給工作記憶體的變數。每當虛擬機器遇到一個給變數賦值的位元組碼指令時執行這個操作;
- read:把一個變數的值從主記憶體拷貝到工作記憶體,以便為隨後的load動作使用;
- load:把read操作從主記憶體獲取的變數值放入工作空間的副本中;
- store:把工作記憶體中的變數值傳送到主記憶體中,為後續的write操作使用;
- write:把store操作從工作記憶體得到的變數的值放入主記憶體的變數中;
- lock:把一個變數標識為執行緒獨佔狀態;
- unlock:釋放執行緒獨佔的變數。
我在想,怎麼樣才能更好地解釋這8個操作呢?這8個操作主要是為變數服務的,讓變數在主記憶體和工作記憶體之間來回移動,並傳遞給執行緒引擎去執行,最終我覺得用下面的程式碼例子製作成動畫效果來解釋下這個步驟,其中執行引擎執行的程式碼片段為:
1public class InterMemoryInteraction { 2 3 public synchronized static void add() { 4 ClassA classA = new ClassA(); 5 classA.var +=2; 6 System.out.println(classA.var); 7 } 8 9 public static void main(String[] args) {10 add();11 }12}1314class ClassA {15 Integer var = 10;16}複製程式碼
對應的關鍵反彙編指令:
112: getfield #4 // Field com/itzhai/jvm/executeengine/concurrency/ClassA.var:Ljava/lang/Integer;215: invokevirtual #5 // Method java/lang/Integer.intValue:()I318: iconst_2419: iadd520: invokestatic #6 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;623: dup_x1724: putfield #4 // Field com/itzhai/jvm/executeengine/concurrency/ClassA.var:Ljava/lang/Integer;複製程式碼
這8個操作執行的可以通過如下動畫演示:
這動畫效果,看來還是離動畫大師差個十萬八千里,但是夢想還是要有的。畫動畫不易,畫動圖比較花時間,動畫方式闡釋到此告一段落。大家要是覺得好就給我點個贊,說不定第二季很快就上映了…
在Javase8中的文件,為了方便理解,這些操作做了調整,改為了以下幾種操作[4] ,其實底層的模型並沒有變。
Read
:讀一個變數Write
:寫一個變數- 同步操作:
Volatile read
:易變讀一個變數Volatile write
:易變寫一個變數Lock
:鎖定獨佔一個變數Unlock
:釋放一個獨佔的變數- 執行緒的第一個或者最後一個操作
- 啟動執行緒或檢測到執行緒已終止的操作
也可以通過如下流程描述這8個指令的工作:
2.2、volatile可見性和和有序性
volatile是JVM最輕量級的同步機制。
Java中的volatile關鍵字用作Java編譯器和Thread的指示符,它們不快取變數的值,始終從主記憶體讀取它,因此,如果您希望共享例項中的讀寫操作是原子性的,可以將他們宣告為volatile變數。
2.2.1、volatile的作用
變數使用了volatile之後意味著具有兩種特性:
2.2.1.1、可見性:保證變數對所有的執行緒可見
變數的值線上程間傳遞均需要通過主記憶體來完成:在變數修改後將新值同步回主記憶體,在變數讀取前從主記憶體重新整理變數值。
使用了volatile之後,能夠保證新值能夠立即同步到主記憶體,以及每次使用前立即從主記憶體重新整理。
如下圖:針對volatile
變數,執行use
操作之前,總是會同時觸發read
和load
操作,執行assign
操作之後,總是會同時觸發store
和write
操作:
但可見性並不意味這在併發下是安全的,考慮一下程式碼,開啟20個執行緒,每個執行緒迴圈10000次給一個volatile的變數+1,我們期望結果是20000:
1public class VolatileTest { 2 3 public static volatile int race = 0; 4 5 public synchronized static void increase() { 6 race ++; 7 } 8 9 private static final int THREADS_COUNT = 20;1011 public static void main(String[] args) {12 Thread[] threads = new Thread[THREADS_COUNT];13 for (int i = 0; i < THREADS_COUNT; i++) {14 threads[i] = new Thread(() -> {15 for (int j = 0; j < 10000; j++) {16 increase();17 }18 });19 threads[i].start();20 }21 // 等待所有累加執行緒都結束22 while (Thread.activeCount() > 2)23 Thread.yield();24 System.out.println(race);25 }2627}複製程式碼
但是我們最終發現每次執行的結果都不太一樣,但總是不會達到20000。
原因是雖然volatile確保了可見性,更新之後可以對其他執行緒立刻可見,但是這裡的+1操作並不是原子的,看反彙編程式碼就比較清楚了:
10: getstatic #2 // Field race:I23: iconst_134: iadd45: putstatic #2 // Field race:I複製程式碼
嚴格上來說,即使反彙編的程式碼只有一條指令,實際翻譯為本地機器碼的時候也可能會對應多條機器指令,也就是說一條指令也不一定是原子操作。
如上圖,兩個執行緒同時執行getstatic指令,都獲取到了最新的r(race這裡簡寫為r)值10。
假設執行緒1先執行完了所有指令,那麼會把工作記憶體1中最終的值11寫回r變數;
然後執行緒2也執行相同的指令,把工作記憶體2中最終的值11寫回r變數。
可見執行緒1的值被執行緒2覆蓋了。
結論
對於不依賴當前值的assign操作,並且變數不需要與其他的狀態變數共同參與不變約束,volatile可以確保其原子性。
典型的應用如:不管當前開關狀態是什麼,我現在要開啟開關,那麼操作之後,開啟狀態可以立即對其他執行緒可見。
2.2.1.2、有序性:禁止指令重排
下面我們看一個經典的雙重檢查鎖定DCL問題。
為了支援惰性初始化,並且避免同步開銷,我們編寫的檢查鎖定程式碼可能會像下面這樣:
1public class Singleton { 2 3 private static Singleton instance; 4 5 public static Singleton getInstance() { 6 if (instance == null) { 7 synchronized (Singleton.class) { 8 if (instance == null) { 9 instance = new Singleton();10 // 這一句程式碼實際上會翻譯為如下三句11 // reg0 = calloc(sizeof(Singleton));12 // reg0.<init>();13 // instance = reg0;14 }15 }16 }17 return instance;18 }1920 /**21 * hsdis-amd64.dylib https://cloud.tencent.com/developer/article/108267522 * HSDIS是一個Java官方推薦 HotSpot虛擬機器JIT編譯程式碼的反彙編外掛。我們有了這個外掛後,通過JVM引數-XX:+PrintAssembly就可以載入這個HSDIS外掛,23 * 然後為我們把JIT動態生成的那些原生程式碼還原成彙編程式碼,然後列印出來。24 * @param args25 */26 public static void main(String[] args) {27 // 由於指令編排問題,可能返回空物件28 Singleton.getInstance();29 }3031}複製程式碼
如上面註釋所屬,建立單例的語句instance = new Singleton();
會翻譯為三個語句,而這三個語句缺少順序限制,即使是順序的,也可能在單個CPU核心上面併發執行,導致執行順序不確定。
在現代x86晶片上,即使在單個核心上,多個指令也肯能並行發生,早在1993年釋出的第一批奔騰處理器中,x86就能夠在一個核心上同時執行多個指令。從1995年的Pentium Pro開始,x86晶片開始無序執行我們的程式碼。
也就是說編譯器或者CPU核心都有可能對操作指令進行重排序。
最終的執行順序有可能是這樣的
1reg0 = calloc(sizeof(Singleton));2instance = reg0;3reg0.<init>();複製程式碼
這樣子,另一個執行緒就可能拿到了這個還沒有執行構造方法<init>()
的空物件。
為了避免這個不期望的情況出現,我們需要在instance變數前面新增volatile:
private static volatile Singleton instance;
新增之後我們再次執行,可以看到生成的彙編程式碼有一個指令包含了lock字首:
1 0x0000000113fc76a4: movabs $0x7957d2e18,%rax ; {oop(a 'java/lang/Class' = 'com/itzhai/jvm/executeengine/concurrency/Singleton')} 2 0x0000000113fc76ae: mov 0x20(%rsp),%rsi 3 0x0000000113fc76b3: mov %rsi,%r10 4 0x0000000113fc76b6: shr $0x3,%r10 5 0x0000000113fc76ba: mov %r10d,0x68(%rax) 6 0x0000000113fc76be: shr $0x9,%rax 7 0x0000000113fc76c2: movabs $0x10d94f000,%rsi 8 0x0000000113fc76cc: movb $0x0,(%rax,%rsi,1) 9 0x0000000113fc76d0: lock addl $0x0,(%rsp) ;*putstatic instance10 ; - com.itzhai.jvm.executeengine.concurrency.Singleton::getInstance@24 (line 37)複製程式碼
這個lock字首指令之前的一條指令就是對instance的賦值操作。
記憶體屏障:這個lock操作相當於一個記憶體屏障。遇到這個lock字首之後,會讓本CPU的cache寫入記憶體,並且讓其他CPU的cache無效化,從而實現了變數的可見性
。
同時這個記憶體屏障能夠實現有序性
:volatile變數賦值語句所在的位置相當於一個記憶體屏障,賦值語句前後的的指令不能跨過這道屏障。
volatile實際上是通過記憶體屏障實現了可見性和有序性。
2.2.2、什麼時候使用volatile
2.2.2.1、如果要讀寫long和double變數,可以使用volatile
long和double是64位資料型別,他們的原子性與平臺有關,許多平臺long和double變數分兩步進行寫,每步32位,可能會導致資料不一致。您可以通過在Java中使用volatile修飾long和double變數來避免此類問題。
2.2.2.2、需要使用可見性的場景
某一個執行緒更新一個具體的值(這個值的修改不依賴原值並且不需要與其他的狀態變數共同參與不變約束)之後,需要其他執行緒能夠立刻看到。
2.2.2.3、明確變數需要用於多執行緒訪問
volatile變數可用於通知編譯器特定欄位將被多個執行緒訪問,這將阻止編譯器進行任何重排序或任何型別的優化,特別是在在多執行緒環境中是不希望的優化。
如下例
1private boolean isActive = thread;2public void printMessage(){3 while(isActive){4 System.out.println("Thread is Active");5 }6}複製程式碼
如果沒有volatile修飾符,則不能保證一個執行緒從另一執行緒中看到isActive的更新值。編譯器還可以自由快取isActive的值,而不必在每次迭代中從主記憶體中讀取它。通過將isActive設定為volatile變數,可以避免這些問題。
2.2.2.4、雙重鎖檢查
上面已經列舉類這類例子,為了確保指令執行的有序性,所有需要加上volatile關鍵字。
2.2.3、volatile關鍵字使用要點
- 僅適用於變數;
- 保證變數值總是從主記憶體中讀取,而不是從Thread的本地快取,也就是工作記憶體;
- 使用volatile關鍵字宣告的操作不一定都是原子的,取決於編譯出來的彙編指令;
- 除了long和double型別,即使不使用volatile關鍵字,原始型別變數讀和寫都具有可見性;
- 如果一個變數沒有在多執行緒之間共享,則不需要對變數使用volatile關鍵字;
- volatile變數訪問永遠不會有阻塞機會,因為我們只進行簡單的讀取和寫入操作,不會保持任何鎖或等待任何鎖。
2.3、同步操作Synchronized
通過使用同步,可以實現任意大語句塊的原子性單位,使我們能夠解決volatile無法實現的read-modify-write
問題。
2.3.1、底層是怎麼實現的呢?
我們可以寫一個程式碼來看看其反彙編程式碼:
可以發現,synchronized塊最終變為了由monitorenter
和monitorexit
包裹的反彙編指令語句塊。
翻看jvm規範看看這兩個指令的作用 Chapter 6. The Java Virtual Machine Instruction Set[2]:
monitorenter:操作物件是一個reference物件,每個物件都與一個監視器關聯,如果有其他執行緒獲取了這個物件的monitor,當前的執行緒就要等待。每個物件的監視器有一個objectref條目計數器物件,成功進入監視器之後,監視器的objectref+1,然後,該執行緒就成為監視器的所有者了。
同一個執行緒重複執行monitorenter,會重新進入監視器,並且objectref+1。
monitorexit:操作物件是一個reference物件,執行該指令,objectref-1,直到objectref=0的時候,執行緒退出監視器,不再是物件所有者。
2.3.2、Synchronized如何實現可見性
Synchronized確保以可執行的方式使執行緒在同步塊之前或者期間對記憶體的寫入對於監視同一個物件的其他執行緒可見。
- 執行了
monitorenter
之後,釋放監視器,並且將工作記憶體重新整理到主記憶體中,以便該執行緒進行的寫入對其他執行緒可見; - 在進入同步塊之前,我們先要先執行
monitorenter
,使得當前執行緒的工作記憶體無效化,以便從主記憶體中重新載入變數。
思考以下程式碼有何問題?
1synchronized (new Object()) {}複製程式碼
2.4、final
Java中使用final欄位的時候,JVM保證物件的使用者僅在構造完成後才能看到該欄位的最終值。
為了達到這個目的,JVM會在final物件建構函式的末尾引入凍結
操作,該操作可以防止對建構函式進行任何後續操作,或者進行指令重排。
舉個例子:
1instance = new Singleton();複製程式碼
從巨集觀上看,可以認為將new分解為3個語句:
1reg0 = calloc(sizeof(Singleton));2reg0.<init>();3instance = reg0;複製程式碼
在給instance賦值前,確保<init>()
構造方法限制下,保證了instance將得到最終值。
2.4、關於非原子的double和long變數
虛擬機器實現選擇可以不保證64位資料型別的load,store,read,write這個操作的原子性。
但一般虛擬機器實現幾乎都把64位資料的讀寫操作作為原子操作來對待,方便編碼。
2.5、舊版本Java記憶體模型的問題
自1997年以來,在java語言規範中定義的Java記憶體模型中發現了一些嚴重的缺陷,這些缺陷使行為混亂(如final欄位會更改其值),並且破壞了編譯器執行常規優化的能力。為此,引入了JSR 133提案[3],JSR 133為Java語言定義了一種新的記憶體模型,優化了final和volatile的語義,該模型修復了早期記憶體模型的缺陷。本文截止到目前以上內容均是基於JSR 133規範來闡述的。
舊的模型允許將volatile寫入與非volatile讀寫進行重新排序,這與大多數開發人員對volatile的直接並不一致,引發了混亂。程式設計師對於錯誤的同步其程式可能會發生什麼的直接通常是錯的,JSR 133的目標之一是引起人們對這一事實的關注。
3、Java記憶體模型併發不得不關注的三個問題
3.1、原子性
如何保證原子性?
- Java中我們可以認為基本資料型別(除了double和long)的訪問讀寫是具備原子性的;
- 可以通過synchronized關鍵字實現更大範圍的原子性保證,底層是用到了
monitorenter
和monitorexit
指令實現的,對應的操作為:lock和unlock。
3.2、可見性
Java記憶體模型是通過變數修改後將新值同步會記憶體,在變數讀取前從主記憶體重新整理變數值這種依賴主記憶體作為傳遞媒介的方式來實現可見性的。
普通變數和volatile變數都是這樣實現的。
volatile變數能夠立即同步到主記憶體,每次使用前立即從主記憶體重新整理。所以volatile保證了多執行緒操作的變數可見性,而普通變數不能保證這一點。
另外兩個能實現可見性的關鍵字:
- synchronzied:對一個變數執行unlock操作之前,必須先把此變數同步會主記憶體(執行store,write操作)
- final:final欄位在構造器中一旦初始化完成,並且構造器沒有把this引用傳遞出去的話,那麼其他執行緒就能看見final欄位的值。
3.3、有序性
線上程內觀察,所有操作都是有序的(語義的序列),但是在一個執行緒內觀察另一個執行緒,所有操作都是無序的(指令重排序導致的)的。
實現有序性:
- volatile關鍵字:禁止指令重排;
- synchronized:基於一個變數在同一個時刻只允許一條執行緒對其進行lock操作實現的,表現為持有同一個鎖定兩個同步塊只能序列執行。
4、記憶體模型的先行發生規則
Java記憶體模型語義在記憶體操作(讀取變數,寫入變數,鎖定,解鎖)和其他執行緒操作(start和join)上約定了一些執行順序:
程式次序規則:同一個執行緒,書寫在前面的操作先行發生於書寫在後面的操作;
監控鎖定規則:unlock操作先行發生於後面同一個鎖的lock操作;
volatile變數規則:一個volatile變數的寫操作先行發生於後面對這個變數的讀操作;
執行緒啟動規則:Thread的start()方法先行發生於此執行緒每一個動作;
執行緒終止規則:執行緒所有操作都先行發生於對此執行緒的終止檢測;
物件終結規則:物件從建構函式先行發生於它的finalize()方法;
傳遞性:如果A操作先行發生於B,B操作先行發生於C,那麼A操作先行發生於C;
根據以上規則可言判斷程式是否執行緒安全的。
5、結語
好了,本篇文章就介紹到這裡了,相信你對上面的撕逼問題的答案已經有了比較深刻的認識。下次別人跟你討論Java記憶體模型,就可以把這些問題拋給他了。
本文為arthinking基於相關技術資料和官方文件撰寫而成,確保內容的準確性,如果你發現了有何錯漏之處,煩請高抬貴手幫忙指正,萬分感激。
大家可以關注我的部落格:itzhai.com 獲取更多文章,我將持續更新後端相關技術,涉及JVM、Java基礎、架構設計、網路程式設計、資料結構、資料庫、演算法、併發程式設計、分散式系統等相關內容。
如果您覺得讀完本文有所收穫的話,可以關注我的賬號,或者點贊啥的。關注我的公眾號,及時獲取最新的文章。
References
[2]: Chapter 6. The Java Virtual Machine Instruction Set
[3]: JSR 133: Java**TM **Memory Model and Thread Specification Revision
[4]: Java Language Specification#17.4.2. Actions
《深入理解Java虛擬機器-JVM高階特性與最佳實踐》
x86 and amd64 instruction reference
Java Language Specification#17.4. Memory Model
JSR 133 (Java Memory Model) FAQ
How Volatile in Java works? Example of volatile keyword in Java
JSR 133: Java Memory Model and Thread Specification Revision
本文為arthinking
基於相關技術資料和官方文件撰寫而成,確保內容的準確性,如果你發現了有何錯漏之處,煩請高抬貴手幫忙指正,萬分感激。
大家可以關注我的部落格:itzhai.com
獲取更多文章,我將持續更新後端相關技術,涉及JVM、Java基礎、架構設計、網路程式設計、資料結構、資料庫、演算法、併發程式設計、分散式系統等相關內容。
如果您覺得讀完本文有所收穫的話,可以關注
我的賬號,或者點個贊
,您的支援就是我寫作的動力!關注我的公眾號,及時獲取最新的文章。
本文作者: arthinking
部落格連結: https://www.itzhai.com/cpj/how-the-java-memory-model-works.html
版權宣告:
BY-NC-SA
許可協議:創作不易,如需轉載,請務必附加上部落格連結,謝謝!