JVM-記憶體模型

牛覓發表於2019-02-16

Java併發是基於共享記憶體模型實現的。學習並深入地理解__Java記憶體模型__,有助於開發人員瞭解Java的執行緒間通訊機制原理,從而實現安全且高效的多執行緒功能。

處理器記憶體模型

計算機在執行程式時,每條指令都是在__CPU__中執行的,而執行指令過程中,勢必涉及到對主存中資料的讀取和寫入。由於__CPU__的處理速度相比對記憶體資料的訪問速度快很多,如果任何時候對資料的操作都要通過和記憶體的互動來進行,會大大降低指令執行的速度。因此在__CPU__裡面就有了快取記憶體。

640.png | center | 607x294

然而引入快取記憶體帶來方便的同時,也帶來了快取一致性的問題。當多個處理器的運算任務都涉及同一塊主記憶體區域時,將可能導致各自快取資料不一致的問題。解決方法是快取一致性協議(如Intel 的MESI協議)。

MESI協議保證了每個快取中使用的共享變數的副本是一致的。當CPU寫資料時,如果發現操作的變數是共享變數,會發出訊號通知其他CPU將該變數的快取行置為無效狀態。因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀取。

除了增加快取記憶體之外,為了使得處理器內部的運算單元能儘量被充分利用,處理器可能會對輸入程式碼進行亂序執行(Out-Of-Order Execution)優化,處理器會在計算之後將亂序執行的結果重組,保證該結果與順序執行的結果是一致的,但並不保證程式中各個語句計算的先後順序與輸入程式碼中的順序一致,因此,如果存在一個計算任務依賴另外一個計算任務的中間結果,那麼其順序性並不能靠程式碼的先後順序來保證。

Java記憶體模型

Java記憶體模型(Java Memory Model ,JMM)就是一種符合記憶體模型規範的,遮蔽了各種硬體和作業系統的訪問差異的,保證了Java程式在各種平臺下對記憶體的訪問都能保證效果一致的機制及規範

為了方便理解Java記憶體模型,我們可以抽象地認為,所有變數都儲存在主記憶體中(Main Memory),每個執行緒都擁有一個私有的工作記憶體(Working Memory),儲存了該執行緒已訪問的變數副本。執行緒對變數的所有操作都必須在工作記憶體中進行,而不能直接對主存進行操作。

640-2.png | center | 641x377

假設__執行緒A__要向__執行緒B__發訊息,__執行緒A__需要先在自己的工作記憶體中更新變數,再將變數同步到主記憶體中,隨後__執行緒B__再去主記憶體中讀取A更新過的變數。因而可以看出,JMM通過控制主記憶體與每個執行緒的本地記憶體之間的互動,提供記憶體可見性保證。

記憶體互動操作

Java記憶體模型定義的8個操作指令來進行記憶體之間的互動,如下:

  • read 讀取主記憶體的值,並傳輸至工作記憶體。
  • load 將read的變數值存放到工作記憶體。
  • use 將工作記憶體的變數值,傳遞給執行引擎。
  • assign 執行引擎對變數進行賦值。
  • store 工作記憶體將變數傳輸到主記憶體。
  • write 主記憶體將工作記憶體傳遞過來的變數進行儲存。
  • lock 用作主記憶體變數,它把一個變數在記憶體裡標識為一個執行緒獨佔狀態。
  • unlock 用作主記憶體變數,它對被鎖定的變數進行解鎖。

工作記憶體和主記憶體間的指令操作互動,如下圖所示:

image | left

指令規則

  • read 和 load、store和write必須成對出現
  • assign操作,工作記憶體變數改變後必須刷回主記憶體
  • 同一時間只能執行一個執行緒對變數進行lock,當前執行緒lock可重入,unlock次數必須等於lock的次數,該變數才能解鎖。
  • 對一個變數lock後,會清空該執行緒工作記憶體變數的值,重新執行load或者assign操作初始化工作記憶體中變數的值。
  • unlock前,必須將變數同步到主記憶體(store/write操作)。

重排序

在沒有正確同步的情況下,即使要推斷最簡單的併發程式的行為也很困難。程式碼如下:

public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0;

public static void main(String[] args) throws InterruptedException {
    Thread one = new Thread(new Runnable() {
        public void run() {
            a = 1;
            x = b;
        }
    });

    Thread other = new Thread(new Runnable() {
        public void run() {
            b = 1;
            y = a;
        }
    });
    one.start();
    other.start();
    one.join();other.join();
    System.out.println(“(” + x + “,” + y + “)”);
}
複製程式碼

很容易想象PossibleReordering的輸出結果是(1,0)或(0,1)或(1:1)的,__但奇怪的是__還可以輸出(0,0)。

由於每個執行緒中的各個操作之間不存在資料依賴性,因此這些操作可以亂序執行。下圖給出了一種可能由重排序導致的交替執行方式,在這種情況中會輸出(0,0)。

image | left

Java原始碼到最終實際執行的指令序列,會分別經歷下面3種重排序,如圖所示:

原始碼到最終指令過程.png | left

在執行程式時,為了提高效能,編譯器和處理器會對指令做重排序。重排序分3種型別:

  • 編譯器優化重排序。編譯器在不改變單執行緒程式語義(as-if-serial semantics)的前提下,可重新安排語句的執行順序。
  • 指令級並行的重排序。現代處理器採用了指令級並行技術來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。
  • 記憶體系統的重排序。由於處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行。

先行發生原則(happens-before)

為了提高執行效能,JMM允許編譯器和處理器對指令進行重排序。但是Java語言保證了操作間具有一定的有序性,概括起來就是先行發生原則(happens-before)。也就是說,如果兩個操作的關係無法被happens-before原則推導,則無法保證它們的順序性,有可能發生重排序。happens-before原則包括:

  • 程式次序規則:一個執行緒內,按照程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作
  • 鎖定規則:一個unlock操作先行發生於後面對同一個鎖的lock操作。
  • volatile變數規則:對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作
  • 執行緒啟動規則:Thread物件的start()方法先行發生於此執行緒的每個一個動作
  • 執行緒終結規則:執行緒中的所有操作都先行發生於對此執行緒的終止檢測
  • 執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生
  • 物件終結規則:一個物件的初始化完成先行發生於它的finalize()方法的開始
  • 傳遞規則:如果操作A先行發生於操作B,操作B先行發生於操作C,則有A先行發生於操作C。

實際上,這些規則是由編譯器重排序規則和處理器記憶體屏障插入策略來實現的。

記憶體屏障

記憶體屏障是一條CPU指令,用於控制特定條件下的重排序和記憶體可見性問題。即任何指令都不能與記憶體屏障指令重排序。

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

處理器對重排序的支援

image | left

從上面可以看到不同的處理器架構對重排序的支援也是不一樣(其它處理器架構暫不羅列),所以不同的平臺JMM的記憶體屏障施加也略有不同,具體來說,比如 X86 對Load1Load2不支援重排序,那麼你就沒有必要施加 LoadLoad屏障。

volatile的記憶體語義

volatile關鍵字用來保證資料可見性,防止指令重排的效果。包括JUC裡AQS Lock的底層實現也是基於volatitle來實現。

  • volatile寫的記憶體語義:當寫一個volatile變數的時候,JMM會把該執行緒對應的本地記憶體變數值重新整理到主記憶體。
  • volatile讀的記憶體語義:當讀一個volatile變數的時候,JMM會把執行緒本次記憶體置為無效。執行緒接下來將從主記憶體中讀取共享變數(也就是重新從主記憶體獲取值,更新執行記憶體中的本地變數)。

final的記憶體語義

final修飾的稱作域,對於final域,編譯器和處理器要遵守兩個重排序規則

  • 寫規則:在建構函式內對一個final域的寫入,與隨後把這個被構造的物件的引用賦值給一個引用變數,這兩個操作不可重排序。

    JMM禁止編譯器把final域的寫重排序到建構函式之外, 編譯器會在final域寫入的後面插入StoreStore屏障,該規則可以保證在物件引用為任意執行緒可見之前,物件的final域已經被正確初始化,而普通域無法保障。

  • 讀規則:初次讀一個包含final域的物件的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。

    在一個執行緒中,初次讀物件引用與初次讀該物件包含的final域,JMM禁止處理器重排序這兩個操作。編譯器會在讀final域操作的前面插入一個LoadLoad屏障。

類庫Happens-Before

由於Happens-Before的排序功能很強,因此有時候可以“藉助”現有機制的可見性屬性。這需要將Happens-Before的程式順序規則與其他某個順序規則(通常是監視器鎖規則或者volatile變數規則)結合起來,從而對某個未被鎖保護的變數的訪問操作進行排序。由類庫擔保的其他happens-before排序包括:

  • 將一個元素放入執行緒安全容器happens-before於另一個執行緒從容器中獲取元素。
  • 執行CountDownLatch中的倒數計時happens-before於執行緒從閉鎖(latch)的await中返回。
  • 釋放一個許可證Semaphorehappens-before於從同一Semaphore裡獲得一個許可。
  • Future表現的任務所發生的動作happens-before於另一個執行緒成功地從Future.get中返回。
  • Executor提交一個Runnable或Callable happens-before於開始執行任務。
  • 一個執行緒到達CyclicBarrier或Exchanger happens-before於相同關卡(barrier)或Exchange點中的其他執行緒被釋放。如果CyclicBarrier使用一個關卡(barrier)動作,到達關卡happens-before於關卡動作,依照次序,關卡動作happens-before於執行緒從關卡中釋放。

參考資料

相關文章