《java併發程式設計的藝術》記憶體模型

sayWhat_sayHello發表於2018-07-17

執行緒之間的通訊機制:共享記憶體和訊息傳遞。

執行緒之間的同步(程式用於控制不同執行緒間操作的相對順序的機制)
在共享記憶體中,程式設計師要顯示指定某個方法或某段程式碼需要線上程之間互斥執行。
在訊息傳遞中,訊息傳送必須在訊息接收之前,同步式隱式的。

java併發採用的是共享記憶體模型。

在java中,所有例項域、靜態域、陣列元素都儲存在堆記憶體中,堆記憶體線上程間共享。
區域性變數、方法定義引數和異常處理器引數不會再執行緒間共享。

JMM

java執行緒間的通訊有java記憶體模型(JMM)控制,JMM決定了一個執行緒對共享變數的寫入何時對另一個執行緒可見。

從抽象角度來看:執行緒之間的共享變數儲存在主記憶體,每個執行緒都有一個私有的本地記憶體(抽象的但實際不存在),本地記憶體儲存了該執行緒以讀/寫共享變數的副本。

重排序:
1. 編譯器優化的重排序。
2. 指令級優化的重排序。
3. 記憶體系統的重排序。

JMM中,禁止特定型別的編譯器重排序,
(2,3統稱處理器重排序)JMM的處理器重排序要求java編譯器在生成指令序列時,插入特定型別的記憶體屏障(Memory Barriers)

現代的處理器使用寫緩衝區儲存向記憶體寫入的資料。寫緩衝區保證指令流水線持續執行,避免由於處理器停頓等待向記憶體寫入資料的延遲。同時通過批處理的方式重新整理寫緩衝區,合併寫緩衝區中對同一記憶體地址的多次寫,減少對記憶體匯流排的佔用。寫緩衝區僅對所在處理器可用。

StoreLoad Barriers一種記憶體屏障型別。確保Store1資料對其他處理器可見(重新整理到記憶體)先於Load2及後續所有裝載指令的裝載。

happens-before:在發生前
一個執行緒的每個操作,happens-before於該執行緒的任意後續操作。並不意味著前一個操作必須要在後一個操作前執行,happens-before僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前。
對一個鎖的解鎖,happens-before於隨後對這個鎖的加鎖。
對一個volatile域的寫,happens-before於任意後續對這個域的讀。
傳遞性:A happens-before B,且 B happens-before C,那麼 A happens-before C。

as-if-serial:不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不能被改變。故重排序不能對具有依賴關係的操作做重排序。

順序一致性

當程式為正確同步時,可能存在資料競爭(在一個執行緒中寫另一箇中讀同一個變數,且讀寫沒有通過同步排序)。

如果程式是正確同步的,程式的執行將與順序一致性記憶體模型中的執行結果相同。

順序一致性記憶體模型有兩大特性:
1. 一個執行緒中所有操作必須按照程式的順序來執行
2. 所有執行緒都只能看到一個單一的操作執行順序。在順序一致性記憶體模型中每個操作都必須原子執行且立刻對都有執行緒可見。

JMM不允許臨界區內的程式碼越界。因此會在退出和進入臨界區這兩個關鍵時間點做特別處理,使得執行緒在這兩個時間點具有與順序一致性模型系統的記憶體檢視。這種在邊界區的重排序既提高了執行效率,又不會影響執行效果。

對於未同步或未正確同步的多執行緒程式,JMM只提供最小安全性:執行緒執行是讀取到的值,要麼是之前某個執行緒寫入的值,要麼是預設值(0,null,false)
為了實現最小安全性,JVM在堆上分配物件時,首先對記憶體空間進行清零,然後才會在上面分配物件(JVM內部會同步這兩個操作)。
未同步的程式在JMM中執行時,整體上是無序的,執行結果無法預知

volatile記憶體語義

class VolatileExample{
    volatile long v1 = 0L;//使用volatile宣告64位的long變數

    public void set(long l){ //寫方法
        v1 = l;
    }

    public void getAndIncrement(){ //自增方法
        v1++;
    }

    public long get(){ //讀方法
        return v1;
    }
}


語義上相當於:

class VolatileExample{
    long v1 = 0L;

    public synchronized void set(long l){
        v1 = l;
    }

    public synchronized long get(){
        return v1;
    }

    public void getAndIncrement(){
        long temp = get();
        temp += 1L;
        set(temp);
    }
}

volatile自身具有以下特性:
- 可見性。對一個volatile變數的讀,總數能看到任意執行緒對這個volatile變數最後的寫入。
- 原子性。對任意單個volatile變數的讀/寫具有原子性,但類似於volatile++這種複合操作不具有原子性。

從記憶體語義的角度來說:
volatile的寫-讀 和 鎖的釋放-獲取有相同的記憶體效果。

class VolatileExample{
    int a = 0;
    volatile flag = false;

    public void write(){
        a = 1;          //1
        flag = true     //2
    }

    public void read(){
        if(flag){       //3
            int i = a;  //4
            ......
        }
    }
}

假設執行緒A執行write()方法之後,執行緒B執行read()方法
happens-before關係分為3類:
1. 根據程式次序規則, 1 happens-before 2 ; 3 happens-before 4
2. 根據volatile規則,先寫後讀 2 happens-before 3
3. 根據happens-before的傳遞規則, 1 happens-before 4

即A執行緒在寫volatile變數前所有可見的共享變數,在B執行緒讀同一個volatile變數後,將立即變得對B執行緒可見。

當寫一個volatile變數時,JMM會把該執行緒對應本地記憶體中的共享變數重新整理到主記憶體中。

當讀一個volatile變數時,JMM會把改執行緒對應的本地記憶體置為無效,從主記憶體中讀取共享變數。

執行緒A寫一個volatile變數,實際上是執行緒A對接下來將要讀這個volatile變數的某個執行緒發出了(其對共享變數所做修改)訊息。
執行緒B讀一個volatile變數,實際上是執行緒B接收了之前某個執行緒發出(在寫這個volatile變數之前對共享變數所做修改的)的訊息
執行緒A寫一個volatile變數,隨後執行緒B讀這個volatile變數,實際上是執行緒A通過主記憶體向執行緒B傳送訊息。

基於保守策略的JMM記憶體屏障插入策略:
在每個volatile寫操作前面插入一個StoreStore屏障,後面插入一個StoreLoad屏障
在每個volatile讀操作後面插入一個LoadLoad屏障,後面插入一個LoadStore屏障(都是後面)

StoreStore保證在volatile寫之前,其前面的所有普通寫操作已經對任意處理器可見(重新整理到主記憶體)
StoreLoad避免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寫
    }
}

編譯器在生成位元組碼時優化為:(注意部分屏障可以省略,這個是最簡單的版本)

第一個volatile讀 –> LoadLoad屏障 –> 第二個volatile讀 –> LoadStore屏障 –> 普通寫 –> StoreStore屏障 –> 第一個volatile寫 –> StoreStore屏障 –> 第二個volatile寫 –> StoreLoad屏障

不省略的版本:
第一個volatile讀 –> LoadLoad屏障 –> LoadStore屏障 –> 第二個volatile讀 –> LoadLoad屏障 –> LoadStore屏障 –> 普通寫 –> StoreStore屏障 –> 第一個volatile寫 –> StoreStore屏障 –> StoreLoad屏障 –> 第二個volatile寫 –> StoreLoad屏障

鎖的記憶體語義

鎖除了讓臨界區互斥執行外,還可以讓釋放鎖的執行緒向獲取同一個鎖的執行緒傳送訊息。

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,簡寫成 1 - 2)
程式次序:1-2,2-3,4-5,5-6
加鎖解鎖:3-4
傳遞性:2-5
執行緒A在釋放鎖之前所有可見的共享變數,線上程B獲取同一個鎖之後,將立刻變得對B執行緒可變。

當執行緒釋放鎖時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體中。
當執行緒獲取鎖時,JMM會把該執行緒對應的本地記憶體置為無效。臨界區程式碼必須從主記憶體中讀取共享變數。
和volatile一樣。

ReentrantLock中,使用lock()獲取鎖,使用unlock()釋放鎖。
實現依賴於java同步器框架AbstractQueuedSynchronizer(簡稱AQS),AQS使用一個整型的volatile變數(命名為state)來維護同步狀態。
在公平鎖中,加鎖方法先讀volatile變數state,在釋放鎖的最後,寫volatile變數state。
在非公平鎖中,使用compareAndSet()方法 以原子操作的方式更新state變數。

由於java的compareAndSet具有volatile讀和volatile寫的記憶體語義,因此java執行緒之間的通訊有以下4種方式:
- A執行緒寫volatile變數,隨後B執行緒讀這個volatile變數
- A執行緒寫volatile變數,隨後B執行緒用CAS更新這個volatile變數
- A執行緒用CAS更新一個volatile變數,隨後B執行緒用CAS更新這個volatile變數
- A執行緒用CAS更新一個volatile變數,隨後B執行緒讀這個volatile變數

concurrent包的實現

首先,宣告共享變數為volatile,
然後,使用CAS的原子條件更新實現執行緒之間的同步,
同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的記憶體語義來實現執行緒之間的通訊

final域的記憶體語義

對於final域,編譯器是處理器要遵守兩個重排序規則:
1. 在建構函式內對一個final域的寫入,與隨後把這個被構造物件的引用賦值給一個引用變數,與這兩個操作之間不能重排序。
2. 初次讀一個包含final域的物件的引用,與隨後初次讀這個final域,這兩個操作之間不能重排序。

舉例:

public class FinalExample{
    int i;                        //普通變數
    final int j;                  //final變數
    static FinalExample obj;      

    public FinalExample(){        //建構函式
        i = 1;                    //寫普通域
        j = 2;                    //寫final域
    }

    public static void writer(){  //寫執行緒A執行
        obj = new FinalExample();
    }

    public static void reader(){  //讀執行緒B執行
        FinalExample object = obj;//讀物件引用
        int a = object.i;         //讀普通域
        int b = object.j;         //讀final域
    }
}

假設執行緒A執行writer()方法,隨後執行緒B執行reader()方法。

寫final域的重排序禁止把final域的寫重排序到建構函式之外:
JMM禁止編譯器吧final域的寫重排序到建構函式之外
編譯器會在final域的寫之後,建構函式return之前,插入一個StoreStore屏障,禁止處理器把final域的寫重排序到建構函式之外。

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

如果final域是引用型別,編譯器和處理器會遵守以下約束:
在建構函式內對一個final引用的物件的成員域的寫入,與隨後在建構函式外把這個被建構函式引用賦值給一個引用變數,這兩個操作不能重排序

happens-befor

JMM把happens-before要求禁止的重排序分為了下面兩類:
會改變程式執行結果的重排序。(禁止)
不會改變程式執行結果的重排序。(允許)

補充:
start規則:如果執行緒A執行操作ThreadB.start()(啟動執行緒B),那麼A執行緒的ThreadB.start()操作 happens-before 於執行緒B中的任意操作。
join規則:如果執行緒A執行操作ThreadB.join()併成功返回,那麼執行緒B中的任意操作 happens-before於執行緒A從ThreadB.join()操作成功返回。

雙重檢查鎖定和延遲初始化

在java多執行緒程式中,降低初始化類和建立物件的開銷可以採用:延遲初始化。
雙重檢查鎖定是常見的延遲初始化技術,但它是一個錯誤的用法!

public class UnsafeLazyInitialization{
    private static Instance instance;
    public static Instance getInstance(){
        if (instance == null){            //1:A執行緒執行
            instance = new Instance();    //2:B執行緒執行
        }
        return instance;
    }
}

假設A執行緒執行程式碼1的同時,B執行緒執行程式碼2.此時執行緒A可能會看到instance引用的物件還沒有完成初始化。
所以我們在getInstance方法前加鎖,即:public synchronized static Instance getInstance().該操作加鎖然後會增加效能開銷。
早期用雙重檢查鎖定來降低開銷。

public class DoubleCheckedLocking {
    private static Instance instance;
    public static Instance getInstance(){
        if (instance == null){
            synchronized(DoubleCheckedLocking.class){
                if(instance == null){
                    instance = new Instance();   //7
                }
            }
        }
        return instance;
    }
}

如果第一次檢查instance不為null,那麼就不需要執行下面的加鎖和初始化操作,因此可以大幅降低synchronized的效能開銷。
多個執行緒試圖在同一時間建立物件時,會通過加鎖來保證只有一個執行緒能建立物件。
在物件建立好之後,執行getInstance()方法將不需要獲得鎖,直接返回建立好的物件。

但是,雙重檢查鎖定是有問題的,主要是第7行等價於以下虛擬碼:

memory = allocate(); //1分配物件記憶體空間
ctorInstance(memory);//2初始化物件
instance = memory;   //3設定instance指向剛分配的記憶體地址。

實際上,2和3有可能會出現重排序的情況。
所以有兩個解決方案:
1. 不允許2和3重排序
2. 允許2和3重排序,但是不允許其他執行緒“看到”這個重排序

1.基於volatile的解決方案:將instance宣告為volatile
2.基於類初始化的解決方案:

public class InstanceFactory{
    private static class InstanceHolder{
        public static Instance instance = new Instance();
    }

    public static Instance getInstance(){
        return InstanceHolder.instance;
    }
}

相關文章