深入理解JVM(二)——記憶體模型、可見性、指令重排序

飄揚的紅領巾發表於2017-08-14

    上一篇我們介紹了JVM的基本執行流程以及記憶體結構,對JVM有了初步的認識,這篇文章我們將根據JVM的記憶體模型探索java當中變數的可見性以及不同的java指令在併發時可能發生的指令重排序的情況。

記憶體模型

    首先我們思考一下一個java執行緒要向另外一個執行緒進行通訊,應該怎麼做,我們再把需求明確一點,一個java執行緒對一個變數的更新怎麼通知到另外一個執行緒呢?我們知道java當中的例項物件、陣列元素都放在java堆中,java堆是執行緒共享的。(我們這裡把java堆稱為主記憶體),而每一個執行緒都是自己私有的記憶體空間(稱為工作記憶體),如果執行緒1要向執行緒2通訊,一定會經過類似的流程:

clip_image002

1、 執行緒1將自己工作記憶體中的X更新為1並重新整理到主記憶體中;

2、 執行緒2從主記憶體讀取變數X=1,更新到自己的工作記憶體中,從而執行緒2讀取的X就是執行緒1更新後的值。

從上面的流程看出執行緒之間的通訊都需要經過主記憶體,而主記憶體與工作記憶體的互動,則需要Java記憶體模型(JMM)來管理器。下圖演示了JMM如何管理主記憶體和工作記憶體:

clip_image004

當執行緒1需要將一個更新後的變數值重新整理到主記憶體中時,需要經過兩個步驟:

1、 工作記憶體執行store操作;

2、 主記憶體執行write操作;

完成這兩步即可將工作記憶體中的變數值重新整理到主記憶體,即執行緒1工作記憶體和主記憶體的變數值保持一致;

當執行緒2需要從主記憶體中讀取變數的最新值時,同樣需要經過兩個步驟:

1、主記憶體執行read操作,將變數值從主記憶體中讀取出來;

2、工作記憶體執行load操作,將讀取出來的變數值更新到本地記憶體的副本;

完成這兩步,執行緒2的變數和主記憶體的變數值就保持一致了。

可見性

    Java中有一個關鍵字volatile,它有什麼用呢?這個答案其實就在上述java執行緒間通訊機制中,我們想象一下,由於工作記憶體這個中間層的出現,執行緒1和執行緒2必然存在延遲的問題,例如執行緒1在工作記憶體中更新了變數,但還沒重新整理到主記憶體,而此時執行緒2獲取到的變數值就是未更新的變數值,又或者執行緒1成功將變數更新到主記憶體,但執行緒2依然使用自己工作記憶體中的變數值,同樣會出問題。不管出現哪種情況都可能導致執行緒間的通訊不能達到預期的目的。例如以下例子:

//執行緒1 boolean stop = false; while(!stop){ doSomething(); } //執行緒2

stop 
= true;

這個經典的例子表示執行緒2透過修改stop的值,控制執行緒1中斷,但在真實環境中可能會出現意想不到的結果,執行緒2在執行之後,執行緒1並沒有立刻中斷甚至一直不會中斷。出現這種現象的原因就是執行緒2對執行緒1的變數更新無法第一時間獲取到。

但這一切等到Volatile出現後,再也不是問題,Volatile保證兩件事:

1、 執行緒1工作記憶體中的變數更新會強制立即寫入到主記憶體;

2、 執行緒2工作記憶體中的變數會強制立即失效,這使得執行緒2必須去主記憶體中獲取最新的變數值。

所以這就理解了Volatile保證了變數的可見性,因為執行緒1對變數的修改能第一時間讓執行緒2可見。

指令重排序

關於指令排序我們先看一段程式碼:

int a = 0;
boolean flag = false;

//執行緒1

public void writer() {

a = 1;

flag = true;

}

//執行緒2

public void reader() {

if (flag) {

int i= a+1;

...... }

}

執行緒1依次執行a=1,flag=true;執行緒2判斷到flag==true後,設定i=a+1,根據程式碼語義,我們可能會推斷此時i的值等於2,因為執行緒2在判斷flag==true時,執行緒1已經執行了a=1;所以i的值等於a+1=1+1=2;但真實情況卻不一定如此,引起這個問題的原因是執行緒1內部的兩條語句a=1;flag=true;可能被重新排序執行,如圖:

clip_image006

這就是指令重排序的簡單演示,兩個賦值語句儘管他們的程式碼順序是一前一後,但真正執行時卻不一定按照程式碼順序執行。你可能會說,有這個指令重排序那不是亂套了嗎?我寫的程式都不按我的程式碼流程走,這怎麼玩?這個你可以放心,你的程式不會亂套,因為java和CPU、記憶體之間都有一套嚴格的指令重排序規則,哪些可以重排,哪些不能重排都有規矩的。下列流程演示了一個java程式從編譯到執行會經歷哪些重排序:

clip_image008

在這個流程中第一步屬於編譯器重排查,編譯器重排序會按JMM的規範嚴格進行,換言之編譯器重排序一般不會對程式的正確邏輯造成影響。第二、三步屬於處理器重排序,處理器重排序JMM就不好管了,怎麼辦呢?它會要求java編譯器在生成指令時加入記憶體屏障,記憶體屏障是什麼?你可以理解為一個不透風的保護罩,把不能重排序的java指令保護起來,那麼處理器在遇到記憶體屏障保護的指令時就不會對它進行重排序了。關於在哪些地方該加入記憶體屏障,記憶體屏障有哪些種類,各有什麼作用,這些知識點這裡就不再闡述了。可以參考JVM規範相關資料。

下面介紹一下在同一個執行緒中,不會被重排序的邏輯:

clip_image010

這三種情況中,任意改變一個程式碼的順序,結果都會大不相同,對於這樣的邏輯程式碼,是不會被重排序的。注意這是指單執行緒中不會被重排序,如果在多執行緒環境下,還是會產生邏輯問題,例如我們一開始舉的例子。

結語

本文簡單介紹了java在實現執行緒間通訊時的簡單原理,並介紹了volatile關鍵字的作用,最後介紹了java當中可能會出現指令重排序的情況。下一篇將介紹JVM中的引數設定對java程式的影響。

 

參考資料:

《實戰Java虛擬機器》 葛一鳴

《深入理解Java虛擬機器(第2版)》 周志明

《深入理解Java記憶體模型》 程曉明

相關文章