Java並發編程:Java內存模型


一、Java內存模型基礎

1. 兩個關鍵問題

  • 線程之間如何通信;
  • 線程之間如何同步。

線程之間的通信機制:共享內存+消息傳遞

在共享內存的並發模型里,線程之間共享程序的公共狀態,通過讀寫內存中的公共狀態進行隱式通信。
在消息傳遞的並發模型里,線程之間沒有公共狀態,線程之間必須通過發送消息來顯式進行通信。

Java並發采用的是共享內存模型,Java線程之間的通信總是隱式進行的。

2. Java內存模型的抽象結構

Java線程之間的通信由Java內存模型控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見。

JMM定義了線程和主內存之間的抽象關系:線程之間的共享變量存儲在主內存(Main Memory)中,每個線程都有一個私有的本地內存(Local Memory),本地內存中存儲了該線程以讀寫共享變量的副本。

從上圖可以看出,如果線程A和線程B之間要通信的話,必須要經歷兩個步驟:

  1. 線程A把本地內存A中更新過的共享變量刷新到主內存中去;
  2. 線程B到主內存中去讀取線程A之前已經更新過的共享變量。

從整體上看,上述兩個步驟實質上是線程A在向線程B發送消息,而且這個通信過程必須要經過主內存。JMM通過控制主內存與每個線程的本地內存之間的交互,來為Java程序員提供內存可見性保證。

3. 指令序列的重排序

為了提高性能,編譯器和處理器常常會對指令進行重排序。

重排序包括以下三種類型:

  1. 編譯器優化的重排序
  2. 指令級並行的重排序——指令級並行技術。如果不存在數據依賴性,處理器可以改變語句對應機器指令的執行順序。
  3. 內存系統的重排序

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

對於處理器重排序,JMM的處理器重排序規則要求Java編譯器在生成指令序列時,插入特定類型的內存屏障指令,通過內存屏障指令來禁止特定類型的處理器重排序。

4. 並發編程模型的分類

處理器使用寫緩沖區臨時保存向內存寫入的數據。

示例:

其內部執行過程如下所示:

這里,處理器A和處理器B可以同時把共享變量寫入自己的寫緩沖區(A1,B1),然后從內存中讀取另一個共享變量(A2,B2),最后才把自己寫緩沖區中保存的臟數據刷新到內存中(A3,B3)。當以這種時序執行時,程序就可以得到x=y=0的結果。

為了保證內存可見性,Java編譯器在生成指令序列的適當位置插入內存屏障指令來禁止特定類型的處理器重排序。JMM的內存屏障指令分為以下4類:

5. happens-before

Java JSR-133使用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。

happens-before與JMM的關系:

二、指令重排序

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

1. 數據依賴性

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

對於數據依賴性,主要分為三種類型:

對於上述三種情況,只要重排序兩個操作的操作順序,程序的執行結果就會被改變。

2. as-if-serial語義

as-if-serial語義的意思是:不管怎么重排序,單線程程序的執行結果不能被改變。

為了遵守as-if-serial語義,編譯器和處理器不會對存在數據依賴關系的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在數據依賴關系,這些操作就可能被編譯器和處理器重排序。

例如:

double pi = 3.14;         //A
double r = 1.0;           //B
double area = pi * r * r; //C

對於上述操作的數據依賴關系,如下所示:

其存在兩種執行順序,如下所示:

as-if-serial語義使單線程程序員無需擔心重排序是否會干擾到其正常運行,也無需擔心內存可見性問題。

3. 重排序對多線程的影響

示例代碼:

class RecordExample {
    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、數據競爭與順序一致性

Java內存模型規范對數據競爭的定義如下:

  • 在一個線程寫一個變量;
  • 在另一個線程讀同一個變量;
  • 讀和寫沒有通過同步來排序。

當代碼中包含數據競爭時,程序的執行往往產生違反直覺的結果。如果一個多線程程序能正確同步,這個程序將是一個沒有數據競爭的程序。

順序一致性(Sequentially Consistent)——程序的執行結果與該程序在順序一致性內存模型中的執行結果相同。

2、順序一致性內存模型

順序一致性內存模型有兩大特性:

  • 一個線程中的所有操作必須按照程序的順序來執行;

  • 所有線程都只能看到一個單一的操作執行順序。在順序一致性內存模型中,每個操作都必須原子執行且立刻對所有線程可見。

順序一致性內存模型為程序員提供的視圖:

當多個線程並發執行時,上圖的開關裝置能把所有線程的所有內存讀寫操作串行化,即在順序一致性模型中,所有操作之間具有全序關系。

舉例說明:A和B兩個線程。

一種執行過程(同步):

另一種執行過程(非同步):

3. 同步程序的順序一致性效果

示例程序:

class SynchronizeExample {
    int a = 0;
    boolean flag = false;
    public synchronized void writer() {    // 獲取鎖
        a = 1;
        flag = true;
    }                                       // 釋放鎖
    public synchronized void reader() {     // 獲取鎖
        if (flag) {
            int i = a;
            //...
        }
    }                                        // 釋放鎖
}

兩個內存模型中的執行順序:

JMM在具體實現上的基本方針:在不改變(正確同步的)程序執行結果的前提下,盡可能地為編譯器和處理器的優化打開方便之門。

未同步程序在兩個模型中的執行特性存在如下幾個差異:

  1. 順序一致性模型保證單線程內的操作會按程序的順序執行,而JMM不保證單線程內的操作會按程序的順序執行(重排序)。
  2. 順序一致性模型保證所有線程只能看到一致的操作執行順序,而JMM不保證所有線程能看到一致的操作執行順序。
  3. JMM不保證對64位的long型和double型變量的寫操作具有原子性,而順序一致性模型保證對所有的內存讀寫操作具有原子性。

總線仲裁 –> 總線事務!!!

四、volatile的內存語義

1、volatile的特性

volatile變量具有以下特性:

  • 可見性。

  • 原子性。對任意單個volatile變量的讀寫具有原子性,但類似volatile++這種復合操作不具有原子性。

2、volatile讀寫建立的happens-before關系

volatile對於線程的內存可見性的影響比volatile自身的特性更為重要。

從內存語義上來說,volatile的寫讀與鎖的釋放獲取有相同的內存效果。

請看下面使用volatile變量的示例代碼:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
    public void writer() {
        a = 1;          //1
        flag = true;    //2
    }
    public void reader() {
        if (flag) {     //3
            int i = a;  //4
            ...
        }
    }
}

假設線程A執行writer()方法之后,線程B執行reader()方法。根據happens-before規則,該過程建立的happens-before關系可以分為3類:

  • 根據程序次序規則:1 happens-before 2; 3 happens-before 4。
  • 根據volatile規則:2 happens-before 3。
  • 根據happens-before的傳遞性規則:1 happens-before 4。

因此,上述關系的圖形化表現形式如下:

在上圖中,每一個箭頭鏈接的兩個節點,代表了一個happens-before關系。黑色箭頭表示程序順序規則;橙色箭頭表示volatile規則;藍色箭頭表示組合這些規則后提供的happens-before保證。

3、volatile寫讀的內存語義

volatile寫的內存語義如下:

  • 當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量值刷新到主內存。

volatile讀的內存語義如下:

  • 當讀一個volatile變量時,JMM會把該線程對應的本地內存設置為無效。線程接下來將從主內存中讀取共享變量。

總結一下:

  • 線程A寫一個volatile變量,實質上是線程A向接下來將要讀這個volatile變量的某個線程發出了其對共享變量所做修改的消息。
  • 線程B讀一個volatile變量,實質上是線程B接收了之前某個線程發出的在寫這個volatile變量之前對共享變量所做修改的消息。
  • 線程A寫一個volatile變量,隨后線程B讀這個volatile變量,這個過程實質上是線程A通過主內存向線程B發送消息。

4. volatile內存語義的實現

為了實現volatile內存語義,JMM會限制編譯器重排序和處理器重排序。

規則表:

從上表,我們可以看出:

  • 當第二個操作是volatile寫時,不管第一個操作是什么,都不能重排序。其確保了volatile寫之前的操作不會被編譯器重排序到volatile寫之后

  • 當第一個操作是volatile讀時,不管第二個操作是什么,都不能重排序。其確保了volatile讀之后的操作不會被編譯器重排序到volatile讀之前

  • 當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

實現方式:內存屏障。

具體內存屏障插入策略如下:

  • 在每個volatile寫操作前插入一個StoreStore屏障。
  • 在每個volatile寫操作后插入一個StoreLoad屏障。
  • 在每個volatile讀操作后插入一個LoadLoad屏障。
  • 在每個volatile讀操作后插入一個LoadStore屏障。

    保守策略下,volatile寫插入內存屏障后生成的指令序列示意圖:

StoreStore屏障將保障上面所有的普通寫在volatile寫之前刷新到主內存。volatile寫之后的StoreLoad屏障的作用是避免volatile寫與后面可能有的volatile讀寫操作重排序(保守策略)。

保守策略下,volatile讀插入內存屏障后生成的指令序列示意圖:

LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。

舉一個例子:

class VolatileBarrierExample {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;
    void readAndWrite() {
        int i = v1;    // 第一個volatile讀
        int j = v2;    // 第二個volatile讀
        a = i + j;     // 普通寫
        v1 = i + 1;    // 第一個volatile寫
        v2 = j * 2;    // 第二個volatile寫
    }
    ... //寫
}

對應的指令序列示意圖如下:

5. 為什么增強volatile的內存語義

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
    public void writer() {
        a = 1;          //1
        flag = true;    //2
    }
    public void reader() {
        if (flag) {     //3
            int i = a;  //4
            ...
        }
    }
}

在舊的內存模型中,可以使用指令重排序,因此,時序圖如下所示:

結果:讀線程B執行4時,不一定能看到線程A在執行1時對共享變量的修改。

volatile內存語義增強

volatile嚴格限制編譯器和處理器對volatile變量與普通變量的重排序,確保volatile的寫讀和鎖的釋放獲取具有相同的內存語義。

不過,要很好地利用volatile來完成鎖機制下的並發過程,是十分困難的,一定要謹慎。

五、鎖的內存語義

1、鎖的釋放獲取所建立的happens-before關系

鎖是Java並發編程中最重要的同步機制。

示例:

class MonitorExample {
    int a = 0;
    public synchronized void writer() {  //1
        a++;                             //2
    }                                    //3
    public synchronized void reader() {  //4
        int i = a;                       //5
        ...
    }                                    //6
}

假設線程A執行writer()方法,隨后線程B執行reader()方法。根據happens-before規則,該過程包含三類關系:

  • 程序次序規則:1 happens-before 2, 2 happens-before 3, 4 happens-before 5, 5 happens-before 6。

  • 監視器鎖規則:3 happens-before 4。

  • happens-before的傳遞性:2 happens-before 5。

示意圖如下:

在上圖中,每一個箭頭鏈接的兩個節點,代表了一個happens-before關系。黑色箭頭表示程序順序規則;橙色箭頭表示監視器鎖規則;藍色箭頭表示組合這些規則后提供的happens-before保證。

2、鎖的釋放和獲取的內存語義

當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。

當線程獲取鎖時,JMM會把該線程對應的本地內存設置為無效,從而使得被監視器保護的臨界區代碼必須從主內存中讀取變量。

總結一下:

  • 線程A釋放一個鎖,實質上是線程A向接下來將要獲取這個鎖的某個線程發出了線程A對共享變量所做修改的消息。
  • 線程B獲取一個鎖,實質上是線程B接收了之前某個線程發出的在釋放這個鎖之前對共享變量所做修改的消息。
  • 線程A釋放鎖,隨后線程B獲取這個鎖,這個過程實質上是線程A通過主內存向線程b發送消息。

3、鎖內存語義的實現

在這里,我們借助ReentrantLock的源碼,來分析鎖內存語義的具體實現機制。

示例代碼:

class ReentrantLockExample {
    int a = 0;
    ReentrantLock lock = new ReentrantLock();
    public void writer() {
        lock.lock();    //獲取鎖
        try {
            a++;
        } finally {
            lock.unlock(); //釋放鎖
        }
    }
    public void reader() {
        lock.lock();      //獲取鎖
        try {
            int i = a;
            ...
        } finally {
            lock.unlock(); //釋放鎖
        }
    }
}

ReentrantLock的實現依賴於Java同步器框架AbstractQueuedSychronizer(AQS)

基本類圖:

ReentrantLock分為公平鎖和非公平鎖。

公平鎖的加鎖過程

Step1: ReentrantLock.lock();
Step2: FairSync.lock();
Step3: AbstractQueuedSynchronizer.acquire(int arg);
Step4: ReentrantLock.tryAcquire(int acquires);

第四步為核心,如下:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();          //獲取鎖的開始,首先讀取volatile變量state
    if (c == 0) {
        if (isFirst(current) && 
            compareAndSetState(0, acquires)) {
             return true;   
         }
    } else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) {
            throw new Error("Maximum lock count exceeded.");
        }
        setState(nextc);
        return true;
    }
    return false;
}

核心:讀取volatile變量state。

公平鎖的解鎖過程

Step1: ReentrantLock.unlock();
Step2: AbstractQueuedSynchronizer.release(int arg);
Step3: Sync.tryRelease(int releases);

第三步為核心,如下:

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread()) {
        throw new IllegalMonitorStateException();
    }
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);                //釋放鎖的最后,寫volatile變量state
    return free;
}

公平鎖在釋放鎖的最后寫volatile變量state,在獲取鎖時首先讀這個volatile變量。根據volatile的happens-before規則,釋放鎖的線程在寫volatile變量之前可見的共享變量,在獲取鎖的線程讀取同一個volatile變量后將立即變得對獲取鎖的線程可見。

4、concurrent包的實現

Java線程之間的通信方式:

  • A線程寫volatile變量,隨后B線程讀這個volatile變量。
  • A線程寫volatile變量,隨后B線程使用CAS更新這個volatile變量。
  • A線程用CAS更新一個volatile變量,隨后B線程用CAS更新這個volatile變量。
  • A線程用CAS更新一個volatile變量,隨后B線程讀這個volatile變量。

六、final域的內存語義

對於final域,編譯器和處理器要遵守兩個重排序規則:

  • 在構造函數內對一個final域的寫入,與隨后把這個被構造對象的引用賦值給一個引用變量,這兩個操作之間不能重排序。

  • 初次讀一個包含final域的對象的引用,與隨后初次讀這個final域,這兩個操作之間不能重排序。

七、happens-before

happens-before是JMM最核心的概念。

1、JMM的設計

在設計JMM時,需要考慮兩個關鍵因素:

  • 程序員對內存模型的使用。程序員希望基於一個強內存模型來編寫代碼。

  • 編譯器和處理器對內存模型的實現。編譯器和處理器希望實現一個弱內存模型。

由於上述兩個因素的互相矛盾,因此需要找到一個好的平衡點:一方面,要為程序員提供足夠強的內存可見性保證;另一方面,對編譯器和處理器的限制要盡可能地放松。

JMM把happens-before要求禁止的重排序分為下面兩類:

  • 會改變程序執行結果的重排序;

  • 不會改變程序執行結果的重排序。

JMM對於這兩種不同性質的重排序,采取了不同的策略,如下:

  • 對於會改變程序執行結果的重排序,JMM要求編譯器和處理器必須禁止這種重排序。

  • 對於不會改變程序執行結果的重排序,JMM對編譯器和處理器不做要求。

如下所示:

如上所示,可以得出以下兩點:

  • JMM向程序員提供的happens-before規則能滿足程序員的需求。

  • JMM對編譯器和處理器的束縛已經盡可能少。

基本原則:只要不改變程序的執行結果,編譯器和處理器怎么優化都行。

2、happens-before的定義

定義如下:

  • 如果一個操作happens-before另一個操作,那么第一個操作的執行結果將對第二個操作可見,而且第一個操作的執行順序排在第二個操作之前。 —— JMM對程序員的承諾

  • 兩個操作之間存在happens-before關系,並不意味着Java平台的具體實現必須要按照happens-before關系指定的順序來執行。只要結果一致,重排序就不非法。 —— JMM對編譯器和處理器重排序的約束原則

3、happens-before規則

  • 程序次序規則:一段代碼在單線程中執行的結果是有序的。注意是執行結果,因為虛擬機、處理器會對指令進行重排序。雖然重排序了,但是並不會影響程序的執行結果,所以程序最終執行的結果與順序執行的結果是一致的。故而這個規則只對單線程有效,在多線程環境下無法保證正確性。

  • 鎖定規則:這個規則比較好理解,無論是在單線程環境還是多線程環境,一個鎖處於被鎖定狀態,那么必須先執行unlock操作后面才能進行lock操作。

  • volatile變量規則:這是一條比較重要的規則,它標志着volatile保證了線程可見性。通俗點講就是如果一個線程先去寫一個volatile變量,然后一個線程去讀這個變量,那么這個寫操作一定是happens-before讀操作的。

  • 傳遞規則:提現了happens-before原則具有傳遞性,即A happens-before B , B happens-before C,那么A happens-before C。

  • 線程啟動規則:假定線程A在執行過程中,通過執行ThreadB.start()來啟動線程B,那么線程A對共享變量的修改在接下來線程B開始執行后確保對線程B可見。

  • 線程終結規則:假定線程A在執行的過程中,通過制定ThreadB.join()等待線程B終止,那么線程B在終止之前對共享變量的修改在線程A等待返回后可見。

線程啟動規則示例:

線程終結規則示例:

八、雙重檢查鎖定與延遲初始化

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
        }                                              //10
        return instance;
    }
}

問題:

在線程執行到第四行,代碼讀取到instance不為null時,instance引用的對象有可能沒有完成初始化。

2、問題的根源

示例代碼第7行instance = new Instance();創建對象,其可分解為:

memory = allocate();  //1. 分配對象的內存空間
ctorInstance(memory); //2. 初始化對象
instance = memory;    //3. 設置instance指向剛分配的內存地址

上面2和3之間可能會被重排序。2和3之間重排序之后的執行時序(並不違反JMM規則)如下:

memory = allocate();   //1. 分配對象的內存空間
instance = memory;     //2. 設置instance指向剛分配的內存地址
                       // 注意:此時對象還未初始化
ctorInstance(memory);  //3. 初始化對象

上述過程多線程下並發執行的情況:

單線程的執行時序圖:

多線程的執行時序圖:

所以對於上述多線程情況,可以知道,線程B訪問目標對象時,目標對象並未進行初始化。此處就會出現問題。

如何解決?

兩種方法:

  • 不允許2和3重排序;
  • 允許2和3重排序,但不允許其他線程看到這個重排序。

3、基於volatile的解決方案

public class SafeDoubleCheckedLocking {
    private volatile static Instance instance;
    public static Instance getInstance() {
        if (instance == null) {
            synchronized (SafeDoubleCheckedLocking.class) {
                if (instance == null) {
                    instance = new Instance();   //instance為volatile,現在沒有問題了
                }
            }
        }
    }
}

當聲明對象的引用為volatile時,2和3之間的重排序在多線程環境中將會被禁止。

4、基於類初始化的解決方案

JVM在類的初始化階段,會執行類的初始化。在執行類的初始化期間,JVM會去獲取一個鎖,這個鎖可以同步多個線程多同一個類的初始化。

public class InstanceFactory {
    private static class InstanceHolder {
        public static Instance instance = new Instance();
    }
    public static Instance getInstance() {
        return InstanceHolder.instance;    //這里將觸發InstancHolder類被初始化
    }
}

上述過程如下:

該方案的實質是:允許2和3重排序,但不允許非構造線程看到這個重排序。

附加:

一個類或接口被初始化的5種情況:
1. T是一個類,而且一個T類型的實例被創建;
2. T是一個類,且T中聲明的一個靜態方法被調用;
3. T中聲明的一個靜態字段被賦值;
4. T中聲明的一個靜態字段被使用,而且這個字段不是一個常量字段;
5. T是一個頂級類,而且一個斷言語句嵌套在T內部被執行。

類初始化的處理過程的五個階段:

  • 第一階段:通過在Class對象上同步(即獲取Class對象的初始化鎖),來控制類或接口的初始化。這個獲取鎖的線程會一直等待,直到當前線程能夠獲取到這個初始化鎖。

  • 第二階段:線程A執行類的初始化,同時線程B在初始化鎖對應的condition上等待。

  • 第三階段:線程A設置state=initialized,然后喚醒在condition中等待的所有線程。

  • 第四階段:線程B結束類的初始化處理。

  • 第五階段:線程C執行類的初始化的處理。

靜態內部類的加載過程:靜態內部類的加載不需要依附外部類,在使用時才加載

九、Java內存模型綜述

十、小結

本文對Java內存模型做了比較全面的解讀。


注意!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系我们删除。



 
粤ICP备14056181号  © 2014-2020 ITdaan.com