1. 程式人生 > >Java 記憶體模型(JMM)

Java 記憶體模型(JMM)

一、概述

       Java 記憶體模型(Java Memory Model)描述了一組規則或規範,定義了 JVM 將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節,值得注意的是,這裡的變數指的是共享變數(例項欄位、靜態欄位、陣列物件元素),不包括執行緒私有變數(區域性變數、方法引數),因為私有變數不會存在競爭關係。

二、JMM 圖解

       

        在 JMM 中,執行緒直接讀寫的並不是主記憶體,而是一份自己獨享的工作記憶體(工作記憶體中存放著共享變數的副本)。為什麼要這麼做呢?這要從現代計算機的構成說起。我們知道,CPU 與 記憶體之間存在的速度差異是比較大的,為了縮短這種差距,提高 CPU 的利用率,在計算機體系結構中普遍引入了快取的概念。而 Java 的執行緒本質上就是作業系統中的執行緒(JVM 其實是通過系統呼叫將程式的執行緒交給了作業系統核心進行排程),因此其記憶體模型也就要遵從現代作業系統所使用的 “CPU <--> 快取 <--> 記憶體

” 工作模式了。特別是當處理器是多核的時候,Java 執行緒可能是在不同的處理器上分別執行的。簡單來說,JMM 中的工作記憶體對應到底層硬體其實就是 CPU 的快取記憶體了。

        在 JMM 中,工作記憶體是執行緒私有的,不同執行緒之間的變數的傳遞必須通過主記憶體。

        我們可以通過下邊這幅圖,與計算機系統簡單對應一下:

       

  •       處理器對應到執行緒(想象每個處理器運行了一個 Java 執行緒);
  •       快取記憶體就是執行緒的本地工作記憶體;
  •       快取一致性協議就是 JVM 、作業系統等的記憶體管理;
  •       主記憶體就不用多說了,就是計算機記憶體;

 三、JMM 帶來的問題

        我們知道,不同處理器的快取記憶體之間是相互隔離的,只能通過主記憶體通訊。當其中一個處理器修改了快取記憶體的內容,而修改結果並沒有及時同步到主記憶體,其他處理器讀取的將仍然是老的快取資料,結果就會出現資料的不一致。因此,快取記憶體中的資料何時回寫是非常關鍵的

。這裡,我們不必過多關注快取記憶體內容回寫後,其他快取記憶體的資料同步更新,因為這可以通過處理器系統的快取一致性協議來保證(快取一致性協議發現主記憶體的資料被更新後,會自動將引用該主記憶體舊資料的快取記憶體設定為失效,就會觸發快取記憶體的資料重讀)。

        在多執行緒環境中,如果執行緒間存在共享資料,為了保證共享資料在不同執行緒間的可見性,就必須保證資料被修改後能夠立即被回寫到記憶體,關鍵字 volatile 其中一個作用正是這個。如果不用 volatile 關鍵字修飾共享變數,那麼共享變數被更新後的資料回寫時機也就不確定了(也許立馬就回寫了,但更多是過段時間才會同步到主記憶體),如果這時候其他執行緒也在更新該共享變數,兩個執行緒彼此都看不到最新的共享變數結果,都基於舊資料計算,那最後回寫到記憶體的資料將會是不正確的。

       JMM 的工作方式顯然帶來了不同執行緒間共享變數的可見性問題。

四、volatile 修飾變數的資料可見性

       使用 volatile 修飾的變數,JVM 執行時會為其新增記憶體屏障。

       那麼,什麼是記憶體屏障呢?它是一種硬體層次的概念了,不同的硬體平臺實現記憶體屏障的手段並不相同,JVM 會根據平臺的不同通過不同的系統呼叫來設定記憶體屏障。 硬體層的記憶體屏障分為兩種:Load Barrier 和 Store Barrier即讀屏障和寫屏障。

       記憶體屏障的主要作用有兩個:

  •   阻止屏障兩側的指令重排序;
  •   強制把寫緩衝區/快取記憶體中的更新資料寫回主記憶體,讓快取中相應的資料失效       
  • 對於Load Barrier來說,在指令前插入Load Barrier,可以讓快取記憶體中的資料失效,強制重新從主記憶體載入資料;
  • 對於Store Barrier來說,在指令後插入Store Barrier,能讓寫入快取中的最新資料立即寫入主記憶體,讓其他執行緒可見;

       JMM 記憶體屏障通常有四種:

  • LoadLoad屏障:對於這樣的語句Load1; LoadLoad; Load2,在Load2及後續讀取操作要讀取的資料被訪問前,保證Load1要讀取的資料被讀取完畢;
  • StoreStore屏障:對於這樣的語句Store1; StoreStore; Store2,在Store2及後續寫入操作執行前,保證Store1的寫入操作對其它處理器可見;
  • LoadStore屏障:對於這樣的語句Load1; LoadStore; Store2,在Store2及後續寫入操作被執行前,保證Load1要讀取的資料被讀取完畢;
  • StoreLoad屏障:對於這樣的語句Store1; StoreLoad; Load2,在Load2及後續所有讀取操作執行前,保證Store1的寫入對所有處理器可見;

        StoreLoad 屏障的開銷是四種屏障中最大的,在大多數處理器的實現中,這個屏障是個萬能屏障,兼具其它三種記憶體屏障的功能。volatile 所使用的正是 StoreLoad 記憶體屏障。

        因此使用了 volatile 關鍵字修飾的共享變數,線上程讀取前,如果有其他執行緒已經修改了該變數的值到其工作記憶體,會先將新資料刷寫到主記憶體,刷寫完畢後其他執行緒才能進行讀取。也就是每次讀取共享變數的時候,系統都要先檢查一下要不要重新整理主記憶體的問題,自然就做到了共享變數在不同執行緒的可見性了。

四、volatile 防止指令重排

       在第三節我們提到,記憶體屏障除了能夠控制記憶體與快取之間資料同步的時機,而且還有一個重要功能,那就是防止指令重排序。什麼是指令重排序呢?舉一個簡單的例子:    

       線上程A中:

context = loadContext();  // 初始化 context 變數
inited = true;  // 初始化完畢,設定 inited 為 true,通知執行緒 B 使用 context

       線上程B中:

// 根據執行緒 A 對 inited 變數的修改決定是否使用 context 變數
while(!inited ){
   sleep(100);
}
doSomethingwithconfig(context);

       由於執行緒 A 中的兩個賦值語句是沒有關聯的,因此很可能發生指令重排。假設執行緒 A 發生了指令重排:

 inited = true;
context = loadContext();

       顯然,執行緒 A 在將 inited 設定為 true 時,context 還未被初始化,會導致執行緒 B 使用到錯誤的 context 變數。

       在這種情況下,inited 變數其實是應該被 volatile 修飾的,volatile 會組織 JVM 對指令的重排序。

       因此,開發建議是多執行緒共享的變數最好使用 volatile 修飾。