1. 程式人生 > >Java併發讀書筆記:JMM與重排序

Java併發讀書筆記:JMM與重排序

目錄

  • Java記憶體模型(JMM)
    • JMM抽象結構
  • 重排序
    • 原始碼->最終指令序列
      • 編譯器重排序
      • 處理器重排序
    • 資料依賴性
    • as-if-serial
    • happens-before
      • happens-before的規則
      • happens-before關係的定義
    • 重排序對多執行緒的影響
  • 順序一致性
    • 資料競爭與順序的一致性
    • 順序一致性記憶體模型
  • 總結
    • JMM遵循的基本原則:
    • as-if-serial與happens-before的異同

Java記憶體模型(JMM)

Java記憶體模型(JMM)定義了程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。

在Java中,所有例項域、靜態域和陣列元素都存在堆記憶體中,堆記憶體線上程之間共享,這些變數就是共享變數。

區域性變數(Local Variables),方法定義引數(Formal Method Parameters)和異常處理引數(Exception Handler Parameters)不會線上程之間共享,它們不存在記憶體可見性問題。

JMM抽象結構

圖參考自《Java併發程式設計的藝術》3-1

上圖是抽象結構,一個包含共享變數的主記憶體(Main Memory),出於提高效率,每個執行緒的本地記憶體中都擁有共享變數的副本。Java記憶體模型(簡稱JMM)定義了執行緒和主記憶體之間的抽象關係,抽象意味著並不具體存在,還涵蓋了其他具體的部分,如快取、寫快取區、暫存器等。

此時執行緒A、B之間是如何進行通訊的呢?

  • A把本地記憶體中的更新的共享變數重新整理到主記憶體中。
  • B再從主記憶體中讀取更新後的共享變數。

明確一點,JMM通過控制主記憶體與每個執行緒的本地記憶體之間的互動,確保記憶體的可見性。

重排序

編譯器和處理器為了優化程式效能會對指令序列進行重新排序,重排序可能會導致多執行緒出現記憶體可見性問題。

原始碼->最終指令序列

下圖為《Java併發程式設計的藝術》3-3

編譯器重排序

  • 編譯器優化的重排序:編譯器不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。

JMM對於編譯器重排序規則會禁止特定型別的編譯器重排序。

處理器重排序

  • 指令級並行的重排序:現代處理器採用指令級並行技術(Instruction-Level-Parallelism,ILP)將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應及其指令的執行順序。
  • 記憶體系統的重排序:處理器使用快取和讀/寫緩衝區,使得載入和儲存的操作看起來在亂序執行。

對於處理器重排序,JMM的處理器重排序會要求Java編譯器在生成指令序列時,插入特定型別的記憶體屏障指令,以禁止特定型別的處理器重排序。

資料依賴性

如果兩個操作訪問同一變數,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性。

編譯器和處理器會遵守資料依賴性,不會改變存在資料依賴關係的兩個操作的執行順序。(針對單個處理器中執行的指令序列和單個執行緒中執行的操作)

考慮抽象記憶體模型,現代處理器處理執行緒之間資料的傳遞的過程:將資料寫入寫緩衝區,以批處理的方式重新整理寫緩衝區,合併寫緩衝區對同一記憶體地址的多次寫,減少記憶體匯流排的佔用。但每個寫緩衝區只對它所在的處理器可見,處理器對記憶體的讀/寫操作可能就會改變。

as-if-serial

不管怎麼重排序,(單執行緒)程式的執行結果不能被改變,同樣,不會對具有資料依賴性的操作進行重排序,相應的,如果不存在資料依賴,就會重排序。

double pi = 3.14; // A 
double r = 1.0; // B 
double area = pi * r * r; // C
  • C與A訪問同一變數pi、C與B訪問同一變數r,且存在寫操作,具有依賴關係,它們之間不會進行重排序。
  • A與B之間不存在依賴關係,編譯器和處理器可以重排序,可以變成B->A->C。

很明顯,as-if-serial語義很好地保護了上述單執行緒,讓我們以為程式就是按照A->B->C的順序執行的。

happens-before

從JDK5開始,Java使用新的JSR-133記憶體模型,使用happens-before的概念闡述操作之間的記憶體可見性。

有個簡單的例子理解所謂的可見性和happens-before“先行發生”的規則。

i = 1;  //線上程A中執行
j = i;   //線上程B中執行

我們對執行緒B中這個j的值進行分析:
假如A happens-before B,那麼A操作中i=1的結果對B可見,此時j=1,是確切的。但如果他們之間不存在happens-before的關係,那麼j的值是不一定為1的。

在JMM中,如果一個操作執行的結果需要對另一個操作可見,兩個操作可以在不同的執行緒中執行,那麼這兩個操作之間必須要存在happens-before。

happens-before的規則

以下源自《深入理解Java虛擬機器》
意味著不遵循以下規則,編譯器和處理器將會隨意進行重排序。

  1. 程式次序規則(Program Order Rule):在一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。
  2. 監視器鎖規則(Monitor Lock Rule):一個unLock操作在時間上先行發生於後面對同一個鎖的lock操作。
  3. volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作在時間上先行發生於後面對這個量的讀操作。
  4. 執行緒啟動規則(Thread Start Rule):Thread物件的start()先行發生於此執行緒的每一個動作。
  5. 執行緒終止規則(Thread Termination Rule):執行緒中的所有操作都先行發生於對此執行緒的終止檢測。
  6. 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生。
  7. 物件終結規則(Finalizer Rule):一個物件的初始化完成先行發生於它的finalize()方法的開始。
  8. 傳遞性(Transitivity):A在B之前發生,B在C之前發生,那麼A在C之前發生。

happens-before關係的定義

  1. 如果A happens-before B,A的執行結果對B可見,且A的操作的執行順序排在B之前,即時間上先發生不代表是happens-before。
  2. A happens-before B,A不一定在時間上先發生。如果兩者重排序之後,結果和happens-before的執行結果一致,就ok。

舉個例子:

private int value = 0;

public void setValue(int value){
    this.value = value;
}
public int getValue(){
    return value;
}

假設此時有兩個執行緒,A執行緒首先呼叫setValue(5),然後B執行緒呼叫了同一個物件的getValue,考慮B返回的value值:

根據happens-before的多條規則一一排查:

  • 存在於多個執行緒,不滿足程式次序的規則。
  • 沒有方法使用鎖,不滿足監視器鎖規則。
  • 變數沒有用volatile關鍵字修飾,不滿足volatile規則。
  • 後面很明顯,都不滿足。

綜上所述,最然在時間線上A操作在B操作之前發生,但是它們不滿足happens-before規則,是無法確定執行緒B獲得的結果是啥,因此,上面的操作不是執行緒安全的。

如何去修改呢?我們要想辦法,讓兩個操作滿足happens-before規則。比如:

  • 利用監視器鎖規則,用synchronized關鍵字給setValue()getValue()兩個方法上一把鎖。
  • 利用volatile變數規則,用volatile關鍵字給value修飾,這樣寫操作在讀之前,就不會修改value值了。

重排序對多執行緒的影響

考慮重排序對多執行緒的影響:
如果存在兩個執行緒,A先執行writer()方法,B再執行reader()方法。

class ReorderExample { 
    int a = 0; 
    boolean flag = false; 
    public void writer() { 
        a = 1;              // 1
        flag = true;        // 2 
    }
    Public void reader() { 
        if (flag) {         // 3 
            int i = a * a;  // 4
            …… 
        } 
    } 
}

在沒有學習重排序相關內容前,我會毫不猶豫地覺得,執行到操作4的時候,已經讀取了修改之後的a=1,i也相應的為1。但是,由於重排序的存在,結果也許會出人意料。

操作1和2,操作3和4都不存在資料依賴,編譯器和處理器可以對他們重排序,將會導致多執行緒的原先語義出現偏差。

順序一致性

資料競爭與順序的一致性

上面示例就存在典型的資料競爭:

  • 在一個執行緒中寫一個變數。
  • 在另一個執行緒中讀這個變數。
  • 寫和讀沒有進行同步。

我們應該保證多執行緒程式的正確同步,保證程式沒有資料競爭。

順序一致性記憶體模型

  • 一個執行緒中的所有操作必須按照程式的順序來執行。
  • 所有執行緒都只能看到一個單一的操作執行順序。
  • 每個操作都必須原子執行且立刻對所有執行緒可見。

這些機制實際上可以把所有執行緒的所有記憶體讀寫操作序列化。

順序一致性記憶體模型和JMM對於正確同步的程式,結果是相同的。但對未同步程式,在程式順序執行順序上會有不同。

JMM處理同步程式

對於正確同步的程式(例如給方法加上synchronized關鍵字修飾),JMM在不改變程式執行結果的前提下,會在在臨界區之內對程式碼進行重排序,未編譯器和處理器的優化提供便利。

JMM處理非同步程式

對於未同步或未正確同步的多執行緒程式,JMM提供最小安全性。

一、什麼是最小安全性?
JMM保證執行緒讀取到的值要麼是之前某個執行緒寫入的值,要麼是預設值(0,false,Null)。
二、如何實現最小安全性?
JMM在堆上分配物件時,首先會對記憶體空間進行清零,然後才在上面分配物件。因此,在已清零的記憶體空間分配物件時,域的預設初始化已經完成(0,false,Null)
三、JMM處理非同步程式的特性?

  1. 不保證單執行緒內的操作會按程式的順序執行。
  2. 不保證所有執行緒看到一致的操作執行順序。
  3. 不保證64位的long型和double型的變數的寫操作具有原子性。(與處理器匯流排的工作機制密切相關)
  • 對於32位處理器,如果強行要求它對64位資料的寫操作具有原子性,會有很大的開銷。
  • 如果兩個寫操作被分配到不同的匯流排事務中,此時64位寫操作就不具有原子性。

總結

JMM遵循的基本原則:

對於單執行緒程式和正確同步的多執行緒程式,只要不改變程式的執行結果,編譯器和處理器無論怎麼優化都OK,優化提高效率,何樂而不為。

as-if-serial與happens-before的異同

異:as-if-serial 保證單執行緒內程式的結果不被改變,happens-before 保證正確同步的多執行緒程式的執行結果不被改變。
同:兩者都是為了在不改變程式執行結果的前提下,儘可能的提高程式執行的並行度。


參考資料:
《Java併發程式設計的藝術》方騰飛
《深入理解Java虛擬機器》周