天天看點

Java并發程式設計的藝術筆記-Java記憶體模型

1.Java記憶體模型的基礎

1.1 并發程式設計模型的兩個關鍵問題

  • 線程之間如何通信:
    • 通信是指線程之間以何種機制來交換資訊
    • 通信機制有兩種:共享記憶體和消息傳遞
  • 線程之間如何同步:
    • 同步:指程式中用于控制不同線程間操作發生相對順序的機制

1.2 Java記憶體模型的抽象結構

  • Java線程之間的通信由Java記憶體模型(JMM)控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見
  • 線程之間的共享變量存儲在主記憶體中,每個線程都有一個私有的本地記憶體(抽象概念),本地記憶體中存儲了該線程以讀/寫共享變量的副本
  • JMM通過控制主記憶體與每個線程的本地記憶體之間的互動,來為Java程式員提供記憶體可見性保證
  • 下圖中兩個線程要通信,要經曆下面2個步驟:
    • 線程A把本地記憶體A中更新過的共享變量重新整理到主記憶體中去。
    • 線程B到主記憶體中去讀取線程A之前已更新過的共享變量
Java并發程式設計的藝術筆記-Java記憶體模型

1.3 從源代碼到指令序列的重排序

  • 在執行程式時為了提高性能,編譯器和處理器常常會對指令做重排序
  • 重排序分3種類型,第一種屬于編譯器重排,後兩種屬于處理器重排:
    • 編譯器優化的重排序
    • 指令級并行的重排序
    • 記憶體系統的重排序
  • JMM通過禁止特定類型的編譯器重排序和處理器重排序,提供記憶體可見性保證
由于寫緩沖區僅對自己的處理器可見,它會導緻處理器執行記憶體操作的順序可能會與記憶體實際的操作執行順序不一緻

1.4 happens-before簡介

  • JMM中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在happens-before關系(即保證釋放鎖和擷取鎖的兩個線程之間的記憶體可見性)
  • happens-before規則如下:
    • 程式順序規則:一個線程中的每個操作,happens-before于該線程中的任意後續操作。
    • 螢幕鎖規則:對一個鎖的解鎖,happens-before于随後對這個鎖的加鎖。
    • volatile變量規則:對一個volatile域的寫,happens-before于任意後續對這個volatile域的

    • 傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C
    對于下面代碼,假設線程A執行writer()方法,随後線程B執行reader()方法:
    • 根據程式順序規則:1 happens-before 2,2 happens-before 3;4 happens-before 5,5 happens before 6
    • 根據螢幕鎖規則:3 happens-before 4
    • 根據傳遞性:2 happens-before 5
class MonitorExample {
    int a = 0;
    public synchronized void writer() { // 1
        a++; // 2
        } // 3
    public synchronized void reader() { // 4
        int i = a; // 5
        ……
    } // 6
}

           
Java并發程式設計的藝術筆記-Java記憶體模型

2.重排序

重排序是指編譯器和處理器為了優化程式性能而對指令序列進行重新排序的一種手段

2.1 資料依賴性

  • 如果兩個操作通路同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間

    就存在資料依賴性

  • 資料依賴可分為:讀後寫、寫後讀、寫後寫
  • 編譯器和處理器在重排序時,會遵守資料依賴性(即編譯器和處理器不會改變存在資料依賴關系的兩個操作的執行順序)
  • 資料依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的資料依賴性不被編譯器和處理器考慮

2.2 as-if-serial語義

  • 不管怎麼重排序,單線程的程式的執行結果不能被改變
int a = 10;
int b = 20;
int c = a + b;
執行順序可以是a->b->c,也可以是b->a->c
           
  • 為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關系的操作做重排序(因

    為這種重排序會改變執行結果)

  • as-if-serial語義使程式員無需擔心重排序會幹擾單線程,也無需擔心記憶體可見性問題

2.3 重排序對多線程的影響

假設有兩個線程A和B,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
    ……
	}
}
           
上面代碼中,操作1和操作2沒有資料依賴關系,且操作3和操作4沒有資料依賴關系,編譯器和處理器也可以對這兩對操作重排序:
  • 對線程A的兩個操作進行重排序:
    • 線程A首先寫标記變量flag
    • 随後線程B讀這個變量。由于條件判斷為真,線程B将讀取變量a
    • 此時,變量a還沒有被線程A寫入,多線程程式的語義被重排序破壞
  • 對線程B的兩個操作進行重排序:
    • 由于操作3和操作4存在控制依賴關系,執行線程B的處理器可提前讀取并計算a*a
    • 然後把計算結果臨時儲存到一個名為重排序緩沖(Reorder Buffer,ROB)的硬體緩存中
    • 當操作3的條件判斷為真時,就把該計算結果寫入變量i中,多線程程式的語義被重排序破壞
Java并發程式設計的藝術筆記-Java記憶體模型

3.順序一緻性

處理器的記憶體模型和程式設計語言的記憶體模型都會以順序一緻性記憶體模型作為參照

3.1 資料競争與順序一緻性

  • 資料競争的定義:在一個線程中寫一個變量,在另一個線程讀同一個變量,而且寫和讀沒有通過同步來排序
  • 順序一緻性:如果程式是正确同步的,程式的執行将具有順序一緻性(即程式的執行結果與該程式在順序一緻性記憶體模型中的執行結果相同)

3.2 順序一緻性記憶體模型

假設有兩個線程A和B并發執行。其中A線程有3個操作,它們在程式中的順序是A1→A2→A3。B線程也有3個操作,它們在程式中的順序是B1→B2→B3
  • 當A和B線程使用了鎖進行同步:
Java并發程式設計的藝術筆記-Java記憶體模型
  • 當A和B線程沒有進行同步:未同步程式在順序一緻性模型中雖然整體執行順序是無序的,但所有線程都隻能看到一個一緻的整體執行順序(即A和B看到的執行順序為B1→A1→A2→B2→A3→B3),因為順序一緻性記憶體模型中的每個操作必須立即對任意線程可見
Java并發程式設計的藝術筆記-Java記憶體模型
JMM中沒有上述保證。未同步程式在JMM中不但整體的執行順序是無序的,而且所有線程看到的操作執行順序也可能不一緻

3.3 同步程式的順序一緻性效果

順序一緻性模型中,所有操作完全按程式的順序串行執行。在JMM中,臨界區内的代碼可以重排序(因為JMM目的是在不改變程式執行結果的前提下,盡可能優化編譯器和處理器)
Java并發程式設計的藝術筆記-Java記憶體模型

3.4 未同步程式的執行特性

JMM不保證未同步程式的執行結果與該程式在順序一緻性模型中的執行結果一緻。因為如果想要保證執行結果一緻,JMM需要禁止大量的處理器和編譯器的優化,這對程式的執行性能會産生很大的影響

4.volatile的記憶體語義

4.1 volatile的特性

把對volatile變量的單個讀/寫,看成是使用同一個鎖對這些單個讀/寫操作做了同步
  • 可見性:對一個volatile變量的讀,總是能看到任意線程對這個volatile變量最後的寫入
  • 原子性:對任意單個volatile變量的讀/寫具有原子性,但類似于volatile變量++這種複合操作不

    具有原子性(可以認為不具備原子性)

4.2 volatile寫-讀的記憶體語義

  • 當寫一個volatile變量時,JMM會把該線程對應的本地記憶體中的共享變量值重新整理到主記憶體
  • 當讀一個volatile變量時,JMM會把該線程對應的本地記憶體置為無效。線程接下來将從主記憶體中讀取共享變量
為了實作volatile的記憶體語義,編譯器在生成位元組碼時會在指令序列中插入記憶體屏障來禁止特定類型的處理器重排序

5.鎖的記憶體語義

5.1 鎖的釋放-擷取建立的happens-before關系

以1.4節中的線程A和B為例,因為2 happens-before 5,是以線程B擷取同一個鎖之後,共享變量将立刻變得對B線程可見

5.2 鎖的釋放和擷取的記憶體語義

鎖除了讓臨界區互斥執行外,還可以讓釋放鎖的線程向擷取同一個鎖的線程發送消息
  • 線程A釋放一個鎖,實質上是線程A向接下來将要擷取這個鎖的某個線程發出了消息(即線程A

    對共享變量做出修改)

  • 線程B擷取一個鎖,實質上是線程B接收了之前某個線程發出的消息(即在釋放這個鎖之前對共

    享變量做出修改)

6.happens-before

6.1 JMM的設計

  • JMM對兩種不同性質的重排序(即會改變程式結果的重排序和不會改變程式結果的重排序),采取不同的政策:
    • 對于會改變程式執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。
    • 對于不會改變程式執行結果的重排序,JMM對編譯器和處理器不做要求
Java并發程式設計的藝術筆記-Java記憶體模型
  • 隻要不改變程式的執行結果,編譯器和處理器怎麼優化都行:
    • 編譯器經過分析後,認定一個鎖隻會被單個線程通路,則該鎖可以被消除
    • 編譯器經過分析後,認定一個volatile變量隻會被單個線程通路,則編譯器可以把該變量當作一個普通變量

6.2 happens-before的定義

  • happens-before的概念來指定兩個操作之間的執行順序(這兩個操作可以在一個線程之内,也可以在不同線程之間)
  • 兩個操作之間存在happens-before關系,并不意味着Java平台的具體實作必須要按照happens-before關系指定的順序來執行(假如重排序後執行結果一緻也是可以的)
  • as-if-serial語義保證單線程内程式的執行結果不被改變,happens-before關系保證正确同步的多線程程式的執行結果不被改變

6.3 happens-before規則

  • 程式順序規則:一個線程中的每個操作,happens-before于該線程中的任意後續操作
  • 螢幕鎖規則:對一個鎖的解鎖,happens-before于随後對這個鎖的加鎖
  • volatile變量規則:對一個volatile域的寫,happens-before于任意後續對這個volatile域的讀
  • 傳遞性:如果A happens-before B,且B happens-before C,那麼A happens-before C
  • start()規則:如果線程A執行操作

    ThreadB.start()

    ,則A線程的

    ThreadB.start()

    操作happens-before于線程B中的任意操作
  • join()規則:如果線程A執行操作

    ThreadB.join()

    并成功傳回,則線程B中的任意操作happens-before于線程A從

    ThreadB.join()

    操作成功傳回。

7.雙重檢查鎖定與延遲初始化

雙重檢查鎖定1:
public class DoubleCheckedLocking { // 1
    private static Instance instance; // 2
    public static Instance getInstance() { // 3
        if (instance == null) { // 4:第一次檢查
            synchronized (DoubleCheckedLocking.class) { // 5:加鎖
                if (instance == null) // 6:第二次檢查
                instance = new Instance(); // 7:問題的根源出在這裡
            } // 8
        } // 9
        return instance; // 10
    } // 11
}
           
在某個線程執行到第4行,代碼讀取到instance不為null時,instance引用的對象有可能還沒有完成初始化。因為

instance = new Instance();

内部可能會發生重排序
Java并發程式設計的藝術筆記-Java記憶體模型
雙重檢查鎖定2:使用volatile禁止A2和A3的重排序
public class SafeDoubleCheckedLocking {
    private volatile static Instance instance;
    public static Instance getInstance() {
        if (instance == null) {
        synchronized (SafeDoubleCheckedLocking.class) {
            if (instance == null)
            	instance = new Instance(); 
            }
        }
        return instance;
    }
}

           
可以使用類初始化的方案解決雙重檢查鎖定1中的問題:因為初始化實際是執行方法,該方法線程安全
public class InstanceFactory {
    private static class InstanceHolder {
    public static Instance instance = new Instance();
    }
    public static Instance getInstance() {
    	return InstanceHolder.instance ; 
    }
}
           

繼續閱讀