1. 程式人生 > >Java記憶體模型乾貨總結

Java記憶體模型乾貨總結

併發程式設計模型 

關鍵問題:執行緒之間如何通訊 執行緒之間如何同步

共享記憶體模型(例:java):執行緒之間共享程式的公共狀態,執行緒之間通過寫-讀記憶體中的公共狀態來隱式進行通訊

  同步是顯式進行的。程式設計師必須顯式指定某個方法或某段程式碼需要線上程之間互斥執行

訊息傳遞模型:執行緒之間沒有公共狀態,執行緒之間必須通過明確的傳送訊息來顯式進行通訊

  由於訊息的傳送必須在訊息的接收之前,因此同步是隱式進行的

Java記憶體模型(JMM)

共享變數:堆記憶體線上程之間共享,儲存在堆記憶體中所有例項域、靜態域和陣列元素共享變數

  (區域性變數,方法定義引數、異常處理器引數不會線上程之間共享,不會有記憶體可見性問題,不受記憶體模型的影響)

JMM定義了執行緒和主記憶體之間的抽象關係:

  1)執行緒之間的共享變數儲存在主記憶體(main memory)中

  2)每個執行緒都有一個私有的本地記憶體(local memory),本地記憶體中儲存了該執行緒用以讀/寫共享變數的副本

  3)本地記憶體是JMM的一個抽象概念,並不真實存在。它涵蓋了快取,寫緩衝區,暫存器以及其他的硬體和編譯器優化

執行緒A與執行緒B通訊:

  1)執行緒A把本地記憶體A中更新過的共享變數重新整理到主記憶體中去

  2)執行緒B到主記憶體中去讀取執行緒A之前已更新過的共享變數

JMM通過控制主記憶體與每個執行緒的本地記憶體之間的互動,提供記憶體可見性保證

重排序

編譯器和處理器會對指令做重排序

目的:提高並行度 提高效能

問題:重排序都可能會導致多執行緒程式出現記憶體可見性問題

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

2)指令級並行的重排序。處理器多條指令重疊執行,改變語句對應機器指令的執行順序(處理器重排)

3)記憶體系統的重排序。處理器使用快取和讀/寫緩衝區,這使得載入和儲存操作看上去可能是在亂序執行(處理器重排)

 

舉例:處理器對記憶體的讀/寫操作的執行順序,不一定與記憶體實際發生的讀/寫操作順序一致,導致重排序導致記憶體可見性問題

  (處理器使用寫緩衝區來臨時儲存向記憶體寫入的資料:避免由於處理器停頓下來等待向記憶體寫入資料而產生的延遲

  以批處理的方式重新整理寫緩衝區,以及合併寫緩衝區中對同一記憶體地址的多次寫,可以減少對記憶體匯流排的佔用)

假設處理器A和處理器B按程式的順序並行執行記憶體訪問,最終卻可能得到x = y = 0的結果

第一步執行A1 B1

第二步執行A2 B2,此時已得到x=b=0 y=a=0

第三步執行A3 B3

執行完A3,A1才算執行完,A1 A2重排序了

 

JMM通過禁止特定型別的編譯器重排序和處理器重排序,為程式設計師提供一致的記憶體可見性保證

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

java編譯器在生成指令序列時,插入特定型別的記憶體屏障指令來禁止特定型別的處理器重排序

happens-before

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

一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在happens-before關係

兩個操作之間具有happens-before關係,並不意味著前一個操作必須要在後一個操作之前執行!happens-before僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前

程式順序規則:

  1)一個執行緒中的每個操作,happens- before 於該執行緒中的任意後續操作。

  2)監視器鎖規則:對一個監視器鎖的解鎖,happens- before 於隨後對這個監視器鎖的加鎖。

  3)volatile變數規則:對一個volatile域的寫,happens- before 於任意後續對這個volatile域的讀。

  4)傳遞性:如果A happens- before B,且B happens- before C,那麼A happens- before C。

既可以是在一個執行緒之內,也可以是在不同執行緒之間

資料依賴性

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

只要重排序兩個操作的執行順序,程式的執行結果將會被改變

編譯器和處理器在重排序時,會遵守資料依賴性,編譯器和處理器不會改變存在資料依賴關係的兩個操作的執行順序

  寫後讀 a = 1;b = a; 寫一個變數之後,再讀這個位置。
  寫後寫 a = 1;a = 2; 寫一個變數之後,再寫這個變數。
  讀後寫 a = b;b = 1; 讀一個變數之後,再寫這個變數。

只針對單執行緒

as-if-serial語義

不管怎麼重排序(編譯器和處理器為了提高並行度),程式的執行結果不能被改變(只針對單執行緒)

編譯器和處理器遵守資料依賴性原因:為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果

遵守as-if-serial語義,單執行緒程式的程式設計師建立了一個幻覺:單執行緒程式是按程式的順序來執行的。as-if-serial語義使單執行緒程式設計師無需擔心重排序會干擾他們,也無需擔心記憶體可見性問題

    // 舉例:可能A-->B--C 也可能B-->A-->C
    double pi  = 3.14;    //A
    double r   = 1.0;     //B
    double area = pi * r * r; //C   

優先於happens-before

資料競爭

java記憶體模型規範對資料競爭的定義:

  • 在一個執行緒中寫一個變數,
  • 在另一個執行緒讀同一個變數,
  • 而且寫和讀沒有通過同步來排序。

順序一致性記憶體模型

順序一致性記憶體模型是一個被電腦科學家理想化了的理論參考模型,它為程式設計師提供了極強的記憶體可見性保證(JMM沒有順序一致性記憶體模型保證)

特性:

  • 一個執行緒中的所有操作必須按照程式的順序來執行。
  • (不管程式是否同步)所有執行緒都只能看到一個單一的操作執行順序。在順序一致性記憶體模型中,每個操作都必須原子執行且立刻對所有執行緒可見。

檢視資訊:

1.順序一致性模型有一個單一的全域性記憶體

2.在任意時間點最多隻能有一個執行緒可以連線到記憶體

3.每一個執行緒必須按程式的順序來執行記憶體讀/寫操作

 

舉例:

執行緒A:A1->A2->A3  執行緒B:B1->B2->B3  併發執行

正確同步:

兩個執行緒沒有做同步:

可以看出:

1.每個執行緒內部執行順序 都是按照程式的順序來執行

2.所有執行緒都只能看到一個一致的整體執行順序(原因:順序一致性記憶體模型中的每個操作必須立即對任意執行緒可見)

順序一致性模型 與JMM區別:

  順序一致性模型保證單執行緒內的操作會按程式的順序執行,JMM不保證單執行緒內的操作會按程式的順序執行(遵守as-if-serial語義)

  順序一致性模型保證所有執行緒只能看到一致的操作執行順序,而JMM不保證所有執行緒能看到一致的操作執行順序

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

正確同步,JMM保證程式的執行結果將與該程式在順序一致性模型中的執行結果相同(但不保證執行順序)

 

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

volatile

1、volatile的特性

把對volatile變數的單個讀/寫,看成是使用同一個監視器鎖對這些單個讀/寫操作做了同步

兩段等價程式碼:

class VolatileFeaturesExample {
    volatile long vl = 0L;  //使用volatile宣告64位的long型變數

    public void set(long l) {
        vl = l;   //單個volatile變數的寫
    }

    public void getAndIncrement () {
        vl++;    //複合(多個)volatile變數的讀/寫
    }


    public long get() {
        return vl;   //單個volatile變數的讀
    }
}
class VolatileFeaturesExample {
    long vl = 0L;               // 64位的long型普通變數

    public synchronized void set(long l) {     //對單個的普通 變數的寫用同一個監視器同步
        vl = l;
    }

    public void getAndIncrement () { //普通方法呼叫
        long temp = get();           //呼叫已同步的讀方法
        temp += 1L;                  //普通寫操作
        set(temp);                   //呼叫已同步的寫方法
    }
    public synchronized long get() { 
    //對單個的普通變數的讀用同一個監視器同步
        return vl;
    }
}

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

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
            ……
        }
    }
}

 

2、volatile寫-讀的記憶體語義

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

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

3、volatile記憶體語義的實現原理

1)JMM把記憶體屏障指令分為下列四類:

StoreLoad Barriers是一個“全能型”的屏障,它同時具有其他三個屏障的效果。現代的多處理器大都支援該屏障(其他型別的屏障不一定被所有處理器支援)。執行該屏障開銷會很昂貴,因為當前處理器通常要把寫緩衝區中的資料全部重新整理到記憶體中(buffer fully flush)。

Store:資料對其他處理器可見(即:重新整理到記憶體)

Load:讓快取中的資料失效,重新從主記憶體載入資料 

2)JMM針對編譯器制定的volatile重排序規則表

是否能重排序 第二個操作
第一個操作 普通讀/寫 volatile讀 volatile寫
普通讀/寫     NO
volatile讀 NO NO NO
volatile寫   NO NO

 

 

 

 

舉例來說,第三行最後一個單元格的意思是:在程式順序中,當第一個操作為普通變數的讀或寫時,如果第二個操作為volatile寫,則編譯器不能重排序這兩個操作。

從上表我們可以看出:

  • 當第二個操作是volatile寫時,不管第一個操作是什麼,都不能重排序。這個規則確保volatile寫之前的操作不會被編譯器重排序到volatile寫之後。
  • 當第一個操作是volatile讀時,不管第二個操作是什麼,都不能重排序。這個規則確保volatile讀之後的操作不會被編譯器重排序到volatile讀之前。
  • 當第一個操作是volatile寫,第二個操作是volatile讀時,不能重排序。

JMM記憶體屏障插入策略(編譯器可以根據具體情況省略不必要的屏障):

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

1、鎖 釋放-獲取的happens before 關係

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

class MonitorExample {
    int a = 0;

    public synchronized void writer() {  //1
        a++;                             //2
    }                                    //3

    public synchronized void reader() {  //4
        int i = a;                       //5
        ……
    }                                    //6
}

 

2、記憶體語義

 釋放鎖時,JMM會把該執行緒對應的本地記憶體中的共享變數重新整理到主記憶體中。

 獲取鎖時,JMM會把該執行緒對應的本地記憶體置為無效。從而使得被監視器保護的臨界區程式碼必須要從主記憶體中去讀取共享變數。

3、鎖記憶體語義的實現原理

釋放鎖:釋放鎖的最後寫volatile變數state

  java的compareAndSet()方法呼叫簡稱為CAS

CAS:如果當前狀態值等於預期值,則以原子方式將同步狀態設定為給定的更新值。此操作具有 volatile 讀和寫的記憶體語義

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;
}
protected final boolean compareAndSetState(int expect, int update) {
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

獲取鎖:加鎖方法首先讀volatile變數state

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)) {
            setExclusiveOwnerThread(current);
            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 CAS(具有volatile的記憶體語義)實現鎖的記憶體語義。

 Final

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

1.在建構函式內對一個final域的寫入,與隨後把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。

  (先寫入final變數,後呼叫該物件引用)

  原因:編譯器會在final域的寫之後,插入一個StoreStore屏障

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

  (先讀物件的引用,後讀final變數)

  編譯器會在讀final域操作的前面插入一個LoadLoad屏障 

處理器記憶體模型

如果完全按照順序一致性模型來實現,那麼很多的處理器和編譯器優化都要被禁止,這對執行效能將會有很大的影響。

根據對不同型別讀/寫操作組合的執行順序的放鬆,可以把常見處理器的記憶體模型劃分為下面幾種型別:

  1. 放鬆程式中寫-讀操作的順序,由此產生了total store ordering記憶體模型(簡稱為TSO)。
  2. 在前面1的基礎上,繼續放鬆程式中寫-寫操作的順序,由此產生了partial store order 記憶體模型(簡稱為PSO)。
  3. 在前面1和2的基礎上,繼續放鬆程式中讀-寫和讀-讀操作的順序,由此產生了relaxed memory order記憶體模型(簡稱為RMO)和PowerPC記憶體模型。

注意,這裡處理器對讀/寫操作的放鬆,是以兩個操作之間不存在資料依賴性為前提的(因為處理器要遵守as-if-serial語義,處理器不會對存在資料依賴性的兩個記憶體操作做重排序)。

從上到下,模型由強變弱。越是追求效能的處理器,記憶體模型設計的會越弱。因為這些處理器希望記憶體模型對它們的束縛越少越好,這樣它們就可以做盡可能多的優化來提高效能。

JMM,處理器記憶體模型,順序一致性記憶體模型 之間的關係

JMM是一個語言級的記憶體模型,處理器記憶體模型是硬體級的記憶體模型,順序一致性記憶體模型是一個理論參考模型。

語言記憶體模型,處理器記憶體模型和順序一致性記憶體模型的強弱對比示意圖:

記憶體模型越強,越容易保證記憶體可見性,易程式設計性就越好。但是重排序就會越少,執行效率就越低。

JMM的設計

1)常見的處理器記憶體模型比JMM要弱,java編譯器在生成位元組碼時,會在執行指令序列的適當位置插入記憶體屏障來限制處理器的重排序。

2)由於各種處理器記憶體模型的強弱並不相同,為了在不同的處理器平臺向程式設計師展示一個一致的記憶體模型,JMM在不同的處理器中需要插入的記憶體屏障的數量和種類也不相同。

程式設計師希望:強記憶體模型程式設計,易於理解,易於程式設計

編譯器和處理器希望:弱記憶體模型,記憶體模型對它們的束縛越少越好,以提高效能

JMM時的核心目標就是找到一個好的平衡點:一方面要為程式設計師提供足夠強的記憶體可見性保證;另一方面,對編譯器和處理器的限制要儘可能的放鬆。

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

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

2)不會改變程式執行結果的重排序,JMM對編譯器和處理器不作要求(JMM允許這種重排序)。

  只要不改變程式的執行結果(指的是單執行緒程式和正確同步的多執行緒程式),編譯器和處理器怎麼優化都行。

  比如,如果編譯器經過細緻的分析後,認定一個鎖只會被單個執行緒訪問,那麼這個鎖可以被消除。

  再比如,如果編譯器經過細緻的分析後,認定一個volatile變數僅僅只會被單個執行緒訪問,那麼編譯器可以把這個volatile變數當作一個普通變數來對待。

  這些優化既不會改變程式的執行結果,又能提高程式的執行效率。

 

 參考資料:

《成神之路-基礎篇》JVM——Java記憶體模型 

細說Java多執行緒之記憶體可見性

Java記憶體模型FAQ

深入理解Java記憶體模型