【JVM】JVM系列之記憶體模型(六)

leesf發表於2016-03-21

一、前言

  經過前面的學習,我們終於進入了虛擬機器最後一部分的學習,記憶體模型。理解記憶體模型對我們理解虛擬機器、正確使用多執行緒程式設計提供很大幫助。下面開始正式學習。

二、Java併發基礎

  在併發程式設計中存在兩個關鍵問題①執行緒之間如何通訊 ②執行緒之間如何同步。

  2.1 通訊

  通訊是指執行緒之間以何種機制來交換資訊。在指令式程式設計中,執行緒之間的通訊機制有兩種:共享記憶體和訊息傳遞。

  在共享記憶體的併發模型裡,執行緒之間共享程式的公共狀態,執行緒之間通過寫-讀記憶體中的公共狀態來隱式進行通訊。

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

  2.2 同步

  同步是指程式用於控制不同執行緒之間操作發生相對順序的機制。

  在共享記憶體併發模型裡,同步是顯式進行的。程式設計師必須顯式指定某個方法或某段程式碼需要線上程之間互斥訪問。

  在訊息傳遞的併發模型裡,由於訊息的傳送必須在訊息的接收之前, 因此同步是隱式進行的。

  Java併發採用的是共享記憶體模型,通訊隱式進行;同步顯示指定。

三、Java記憶體模型

  Java記憶體模型JMM(Java Memory Model)主要目標是定義程式中各個變數(非執行緒私有)的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體取出變數這樣的底層細節。Java中每個執行緒都有自己私有的工作記憶體。工作記憶體儲存了被該執行緒使用的變數的主記憶體副本拷貝,執行緒對變數的讀寫操作都必須在工作記憶體進行,無法直接讀寫主記憶體中的變數。兩個執行緒無法直接訪問對方的工作記憶體。

  3.1 執行緒、工作記憶體、記憶體關係

  理解執行緒、主記憶體、工作記憶體之間的關係時,我們可以類比物理機中CPU、快取記憶體、記憶體之間關係,學過計算機組成原理,我們知道CPU、快取記憶體、記憶體之間的關係如下

  執行緒、主記憶體、工作記憶體的關係圖如下

  說明:執行緒的工作記憶體可以類比快取記憶體,JMM控可以類比快取一致性協議,是工作記憶體與主記憶體進行資訊交換的具體協議。若執行緒A要與執行緒B通訊(訪問變數)。首先,執行緒A把工作執行緒中的共享變數重新整理到主記憶體中。然後,執行緒B從主記憶體讀取更新過的變數。

  3.2 記憶體間通訊的指令

  記憶體見通訊,主要指執行緒私有的工作記憶體與主記憶體之間的通訊,如執行緒間共享變數的傳遞。主要有如下操作。

  說明:①變數從主記憶體放入工作記憶體變數副本中實際是分為兩步的,第一步是先把主記憶體的值放在工作記憶體中,此時還沒有放入變數副本中;第二部把已經放在工作記憶體的值放入變數副本中。相反,變數副本從工作記憶體到主記憶體也是分為兩步,與前面類似,不再累贅。總之,兩個記憶體空間的變數值的傳遞需要兩個操作才能完成,這樣做是為了提高cpu的效率,不等待主記憶體寫入完成。②read、load操作;store、write操作必須按順序執行(並非連續執行)。

  上述的8個操作需要滿足如下規則

  3.3 重排序

  在執行程式時為了提高效能,編譯器和處理器常常會對指令做重排序。重排序會遵守資料的依賴性,編譯器和處理器不會改變存在資料依賴關係的兩個操作的執行順序。重排序分為如下三種型別。

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

  2. 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序。

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

  而我們編寫的Java源程式中的語句順序並不對應指令中的相應順序,如(int a = 0; int b = 0;翻譯成機器指令後並不能保證a = 0操作在b = 0操作之前)。因為編譯器、處理器會對指令進行重排序,通常而言,Java源程式變成最後的機器執行指令會經過如下的重排序。

  說明:①編譯器優化重排序屬於編譯器重排序,指令級並行重排序、記憶體系統重排序屬於處理器重排序。②這些重排序可能會導致多執行緒程式出現記憶體可見性問題。③JMM編譯器重排序規則會禁止特定型別的編譯器重排序。④JMM的處理器重排序會要求Java編譯器在生成指令序列時,插入特定型別的記憶體屏障指令,通過記憶體屏障指令來禁止特定型別的處理器重排序。

  下面一個例子說明了重排序

  下面是Process A與Process B與記憶體之間的互動圖

  對於最後的結果x = y = 0而言,從記憶體的角度看,整個指令序列可能如下,A2 -> B2 -> A1 -> B2 -> A3 -> B3。按照這樣的指令排序,最後得到的結果就時x = y = 0。

  說明:①從記憶體角度看,A1與A3操作一起才算是完成了a變數的寫,A1操作是在處理器A的緩衝區中完成寫,之後A3操作將緩衝區的變數值同步到記憶體中。②在程式中,A1發生在A2之前,然而實際的指令序列中,A2發生在A1之前,這就是發生了重排序,處理器操作記憶體的順序發生了變化。同理,B1與B2指令也發生了重排序。

  3.4 記憶體屏障指令

  為了保證記憶體可見性,Java編譯器在生成指令序列的適當位置會插入記憶體屏障指令來禁止特定型別的處理器重排序。記憶體屏障指令分為如下四種

  

  3.5 先行發生原則(happens before)

  先行發生原則是判斷資料是否存在競爭、執行緒是否安全的主要依據。如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須要存在 happens-before 關係,如如果操作A先行發生與操作B,即A操作產生的結果能夠被操作B觀察到。

  如下圖示例

  執行緒A  執行緒B

  i = 3;  j = i;

  結果:j = 3;

  說明:執行緒A中的i = 3先行發生於j = i;則結果一定是j = 3。

  具體的happens-before原則如下

  1. 程式順序規則:一個執行緒中的每個操作,happens- before 於該執行緒中的任意後續操作(控制流操作而不是程式程式碼順序)。

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

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

  4. 執行緒啟動規則:Thread物件的start()方法happens - before 於此執行緒的每一個動作。

  5. 執行緒終止規則:執行緒中所有操作都happens - before 於對此執行緒的終止檢測。

  6. 執行緒中斷規則:對執行緒interrupt()方法的呼叫happens - before 於被中斷執行緒的程式碼檢測到中斷事件的發生。

  7. 物件終結規則:一個物件的初始化完成(建構函式執行結束)happens - before 於它的finalize()方法的開始。

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

  說明:happens-before 僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前(可能會發生指令重排)。時間先後順序與happens - before原則之間沒有太大的關係。

  3.6 as-if-serial語義

  as-if-serial的語義是:不管怎麼重排序(編譯器和處理器為了提高並行度),(單執行緒)程式的執行結果不能被改變。編譯器,runtime 和處理器都必須遵守 as-if-serial 語義。為了遵守 as-if-serial 語義,編譯器和處理器不會對存在資料依賴關係的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在資料依賴關係,這些操作就可能被編譯器和處理器重排序。如如下程式碼片段

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

  其中,操作A、B、C之間的依賴性如下

  說明:A和C之間存在資料依賴關係,同時B和C之間也存在資料依賴關係。因此在最終執行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程式的結果將會被改變)。但A和B之間沒有資料依賴關係,編譯器和 處理器可以重排序A和B之間的執行順序。因此,最後的指令序列可能是:

  A -> B -> C;

  B -> A -> C;  // 重排序了

  套用happens - before規則我們可以知道:

  A happens - before B;  // 程式順序規則

  B happens - before C;  // 程式順序規則

  A happens - before C;  // 傳遞性

  說明:A happens- before B,但實際執行時 B 卻可以排在 A 之前執行(第二種指令執行順序)。在前面我們講到,如果 A happens- before B,JMM 並不要求 A 一定要在 B 之前執行。JMM 僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前。這裡操作 A 的執行結果不需要對操作 B 可見;而且重排序操作 A 和操作 B 後的執行結果,與操作 A 和操作 B 按 happens- before 順序執行的結果一致。在這種情況下,JMM 會認為這種重排序並不非法,JMM 允許這種重排序。

  對於單執行緒,JMM可以進行指令重排序,但是一定要遵守as-if-serial語義,這樣才能保證單執行緒的正確性。

  對於多執行緒而言,JMM的指令重排序可能會影響多執行緒程式的正確性。下面為多執行緒示例。

class ReorderExample { 
    int a = 0;
    boolean flag = false;

    public void writer() {
        a = 1;    //操作A
        flag = true;    //操作B
    }


    public void reader() {
        if (flag) {    //操作C
            int i =    a * a;    //操作D
        }
    }
}

  說明:變數flag用於標識變數a是否已經寫入。若執行緒A先執行writer函式,然後執行緒B執行reader函式。由happens - before 規則(程式順序規則)我們知道,操作A happens - before B,但是由於操作A與操作B之間沒有資料依賴,所以可以進行重排序。同理,操作C與操作D之間也無資料依賴關係(但存在控制依賴關係,JMM允許對存在資料依賴的指令進行重排序),也可進行重排序。

  下圖展示了重排序操作A、操作B所可能產生的結果。

  說明:假設重排序操作A、操作B,且操作C、操作D在操作A、操作B的中間,那麼最後執行緒B的變數i的結果為0;flag為true,則對i進行寫入。然而,此時的a還未寫入,此時,重排序破壞了多執行緒的語義,最後寫入的i值是不正確的。

  下圖展示了重排序操作C、操作D可能產生的執行結果。

 

  說明:存在控制依賴的指令也會被重排序,控制依賴會影響並行度。temp變數的出現是因為編譯器和處理器會採用猜測執行來克服控制相關性對並行度的影響,從圖中我們可以知道重排序破壞了多執行緒的語義,最後寫入i的值是不正確的。

  在單執行緒程式中,對存在控制依賴的操作重排序,不會改變執行結果(這也是 as- if-serial 語義允許對存在控制依賴的操作做重排序的原因);但在多執行緒程式中,對存在控制依賴的操作重排序,可能會改變程式的執行結果。

  在多執行緒中為了保證程式的正確性,我們需要進行適當的同步,以保證正確的結果。

四、順序一致性記憶體模型

  順序一致性記憶體模型時JMM的參考模型,它提供了很強的記憶體一致性與可見性,是一個被電腦科學家理想化了的理論參考模型,它為程式設計師提供了極強的記憶體可見性保證。JMM對正確同步的多執行緒程式的記憶體一致性做了如下保證

  如果程式是正確同步的,程式的執行將具有順序一致性(sequentially consistent)-- 即程式的執行結果與該程式在順序一致性記憶體模型中的執行結果相同。馬上我們將會看到,這對於程式設計師來說是一個極強的保證。這裡的同步是指廣義上的同步,包括對常用同步原語(synchronized,volatile 和 final)的正確使用。

  順序一致性記憶體模型包括如下兩個特性:

  1. 一個執行緒中的所有操作必須按照程式的順序來執行。

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

 

  順序一致性記憶體模型為程式設計師提供的檢視如下:

 

  說明:每一時刻只有一個執行緒可以訪問記憶體,當多個執行緒併發執行時,圖中的開關裝置能把所有執行緒的所有記憶體讀/寫操作序列化(即在順序一致性模型中,所有操作之間具有全序關係)。下面這個例子更進一步闡明瞭在順序一致性記憶體模型中各執行緒操作之間的關係。

  假設執行緒A有A1、A2、A3三個操作,執行緒B有B1、B2、B3三個操作。

  ① 若使用監視器鎖來進行同步,在A的三個操作完成後,釋放監視器鎖,之後B獲得監視器鎖,那麼整個操作序列為:A1 -> A2 -> A3 -> B1 -> B2 -> B3。

  ② 若不使用監視器鎖來進行同步,那麼整個序列可能為:A1 -> B1 -> A2 -> B2 -> B3 -> A3。其中執行緒A、B的三個操作是有序的,並且執行緒A、B看到的操作序列都是同一操作序列,每個操作都必須原子執行且立刻對所有執行緒可見,但是整體的操作無序。

  未同步程式在 JMM 中不但整體的執行順序是無序的,而且所有執行緒看到的操作執行順序也可能不一致。比如,在當前執行緒把寫過的資料快取在本地記憶體中,在還沒有重新整理到主記憶體之前,這個寫操作僅對當前執行緒可見;從其他執行緒的角度來觀察,會認為這個寫操作根本還沒有被當前執行緒執行。只有當前執行緒把本地記憶體中寫過的資料重新整理到主記憶體之後,這個寫操作才能對其他執行緒可見。在這種情況下,當前執行緒和其它執行緒看到的操作執行順序將不一致,當前執行緒認為寫資料到緩衝區就完成了寫操作,其他執行緒認為只有資料重新整理到主記憶體才算完成了寫操作,所以就導致了執行緒之間看到的操作序列不相同。

  順序一致性記憶體模型是通過記憶體規則保證順序一致性,順序一致性是JMM追求的目標,但是JMM模型本身並不進行保證,必須通過適當的同步保證。

  4.1 同步程式的執行特性

  下面例子展示一個正確同步的程式在JMM和順序一致性記憶體模型的操作序列。

class SynchronizedExample { 
    int a = 0;
    boolean flag = false;

    public synchronized void writer() { // 獲取鎖
        a = 1;    //操作A
        flag = true;    //操作B
    } // 釋放鎖

    public synchronized void reader() { // 獲取鎖
        if (flag) {    //操作C
            int i = a;    //操作D
        }
    } // 釋放鎖
}

 

  說明:執行緒A先執行writer方法,執行緒B執行reader方法。這是一個正確同步的程式,在JMM的操作序列與在順序一致性模型的操作序列是相同的。

 

  說明:JMM模型中允許臨界區的操作重排序(即使有控制依賴),而順序一致性記憶體模型中則按照程式順序執行。執行緒 A 在臨界區內做了重排序,但由於監視器的互斥執行的特性,這裡的執行緒 B 根本無法“觀察”到執行緒 A 在臨界區內的重排序。這種重排序既提高了執行效率,又沒有改變程式的執行結果。同時,JMM 會在退出臨界區和進入臨界區這兩個關鍵時間點做一些特別處理,使得執行緒在這兩個時間點具有與順序一致性模型相同的記憶體視圖。

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

  4.2 未同步程式的執行特性

  對於未同步或未正確同步的多執行緒程式,JMM只提供最小安全性:執行緒執行時讀取到的值,要麼是之前某個執行緒寫入的值,要麼是預設值(0,null,false),JMM保證執行緒讀操作讀取到的值不會無中生有的冒出來。為了 實現最小安全性,JVM在堆上分配物件時,首先會清零記憶體空間,然後才會在上面分配物件(JVM內部會同步這兩個操作)。因此,在已清零的記憶體空間分配物件時,域的預設初始化已經完成了。

  JMM 不保證未同步程式的執行結果與該程式在順序一致性模型中的執行結果一致。因為如果想要保證執行結果一致,JMM需要禁止大量的處理器和編譯器的優化,這對程式的執行效能會產生很大的影響。而且未同步程式在順序一致性模型中執行時,整體是無序的,其執行結果往往無法預知。保證未同步程式在這兩個模型中的執行結果一致沒什麼意義。

  未同步程式在 JMM 中的執行時,整體上是無序的,其執行結果無法預知。未同步程式在兩個模型中的執行特性有下面幾個差異:

  ① 順序一致性模型保證單執行緒內的操作會按程式的順序執行,而 JMM 不保證單執行緒內的操作會按程式的順序執行(會進行重排序)。

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

  ③ JMM不保證對 64 位的 long 型和 double 型變數的讀/寫操作具有原子性(JDK5之後的讀具有原子性,寫不具有),而順序一致性模型保證對所有的記憶體讀/寫操作都具有原子性。

五、volatile型變數說明

  關鍵字volatile是Java虛擬機器提供的最輕量級的同步機制,當一個變數定義為volatile時,它將具備兩種特性,可見性與禁止指令重排序優化。volatile通常會與synchronize關鍵字做對比。

  ① 可見性。當一條執行緒修改了這個變數的值,新值對於其他執行緒來說是可以立即獲得的,但是基於volatile變數的操作並不是安全的(如自增操作),下面兩種情況就不太適合使用volatile,而需要使用加鎖(synchronize、原子類)來保證原子性。

  1. 運算結果並不依賴變數的當前值,或者能夠確保只有單一的執行緒修改變數的值。

  2. 變數不需要與其他的狀態變數共同參與不變約束。

  ② 禁止指令重排序優化。不允許對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 變數的讀
    }
}

  說明:上述使用volatile關鍵字的程式與下面使用synchronize關鍵字的程式效果等效。 

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

  volatile變數的讀寫與鎖的釋放與獲取相對應。讀對應著鎖的釋放,寫對應鎖的獲取。

  5.1 volatile的happens - before關係

  前面我們知道happens - before 關係是保證記憶體可見性的重要依據。那麼在volatile變數與happens - before 之間是什麼關係呢,我們通過一個示例說明  

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關係,我們可以知道:

  1. 根據程式順序規則,1 happens before 2; 3 happens before 4。

  2. 根據 volatile變數規則,2 happens before 3。

  3. 根據 happens before 的傳遞性,1 happens before 4。

  具體的happens - before圖形化如下
  說明:上述圖中存在箭頭表示兩者之間存在happens - before關係。

  5.2 volatile讀寫記憶體語義

  1. 讀記憶體語義。當讀一個 volatile 變數時,JMM 會把該執行緒對應的本地記憶體置為無效。執行緒之後將從主記憶體中讀取共享變數。

  2. 寫記憶體語義。當寫一個 volatile 變數時,JMM 會把該執行緒對應的本地記憶體中的共享變數值重新整理到主記憶體。這樣就保證了volatile的記憶體可見性。

  volatile讀寫記憶體語義總結為如下三條:

  1. 執行緒 A 寫一個 volatile 變數,實質上是執行緒 A 向接下來將要讀這個 volatile 變數的某個執行緒發出了(其對共享變數所在修改的)訊息。

  2. 執行緒 B 讀一個 volatile 變數,實質上是執行緒 B 接收了之前某個執行緒發出的(在寫這個 volatile 變數之前對共享變數所做修改的)訊息。

  3. 執行緒 A 寫一個 volatile 變數,隨後執行緒 B 讀這個 volatile 變數,這個過程實質上是執行緒 A 通過主記憶體向執行緒 B 傳送訊息。

  5.3 volatile記憶體語義的實現

  前面講到,volatile變數會禁止編譯器、處理器重排序。下面是volatile具體的排序規則表

  說明:從圖中可以知道當第一個操作為volatile讀時,無論第二個操作為何種操作,都不允許重排序;當第二個操作為volatile寫時,無論第一個操作為何種操作,都不允許重排序;當第一個操作為volatile寫時,第二個操作為volatile讀時,不允許重排序。

  為了實現 volatile 的記憶體語義,編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序。對於編譯器來說,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,為此,JMM 採取保守策略。下面是基於保守策略的 JMM 記憶體屏障插入策略:

  1. 在每個 volatile 寫操作的前面插入一個 StoreStore 屏障。

  2. 在每個 volatile 寫操作的後面插入一個 StoreLoad 屏障(對volatile寫、普通讀寫實現為不允許重排序,可能會影響效能)。

  3. 在每個 volatile 讀操作的後面插入一個 LoadLoad 屏障。

  4. 在每個 volatile 讀操作的後面插入一個 LoadStore 屏障(普通讀寫、volatile讀實現為不允許重排序,可能會影響效能)。

  下面通過一個示例展示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 寫
    }
}

   根據程式,最後的指令序列如下圖所示

  說明:編譯器、處理器會根據上下文進行優化,並不是完全按照保守策略進行插入相應的屏障指令。

六、鎖

  鎖是Java併發程式設計中最重要的同步機制。鎖除了讓臨界區互斥執行外,還可以讓釋放鎖的執行緒向獲取同一個鎖的執行緒傳送訊息。

  6.1 鎖的happens - before 關係

  下面一個示例展示了鎖的使用

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. 根據程式順序規則,1 happens before 2, 2 happens before 3; 4 happens before 5, 5 happens before 6。

  2. 根據監視器鎖規則,3 happens before 4。

  3. 根據傳遞性,2 happens before 5。

  圖形化表示如下:

  

  6.2 鎖釋放獲取的記憶體語義

  1. 當執行緒釋放鎖時,JMM會把該執行緒對應的工作記憶體中的共享變數重新整理到主記憶體中,以確保之後的執行緒可以獲取到最新的值。

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

  鎖釋放與獲取總結為如下三條

  1. 執行緒 A 釋放一個鎖,實質上是執行緒 A 向接下來將要獲取這個鎖的某個執行緒發出 了(執行緒 A 對共享變數所做修改的)訊息。

  2. 執行緒 B 獲取一個鎖,實質上是執行緒 B 接收了之前某個執行緒發出的(在釋放這個 鎖之前對共享變數所做修改的)訊息。

  3. 執行緒 A 釋放鎖,隨後執行緒 B 獲取這個鎖,這個過程實質上是執行緒 A 通過主記憶體 向執行緒 B 傳送訊息。

  6.3 鎖記憶體語義的實現

  鎖的記憶體語義的具體實現藉助了volatile變數的記憶體語義的實現。

七、final

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

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

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

  如下面示例展示了final兩種重排序規則。

public final class FinalExample {
    final int i;
    public FinalExample() {
        i = 3; // 1
    }
    
    public static void main(String[] args) {
        FinalExample fe = new FinalExample(); // 2
        int ele = fe.i; // 3
    }
}

  說明: 操作1與操作2符合重排序規則1,不能重排,操作2與操作3符合重排序規則2,不能重排。  

  由下面的示例我們來具體理解final域的重排序規則。

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

    public void 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()方法。下面我們通過這兩個執行緒的互動來說明這兩個規則。

  7.1 寫final域重排序規則

  寫 final 域的重排序規則禁止把 final 域的寫重排序到建構函式之外。這個規則的實 現包含下面兩個方面:

  1. JMM 禁止編譯器把 final 域的寫重排序到建構函式之外。

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

  writer方法的obj = new FinalExample();其實包括兩步,首先是在堆上分配一塊記憶體空間簡歷FinalExample物件,然後將這個物件的地址賦值給obj引用。假設執行緒 B 讀物件引用與讀物件的成員域之間沒有重排序,則可能的時序圖如下

 

  說明:寫普通域的操作被編譯器重排序到了建構函式之外,讀執行緒 B 錯誤的讀取了普通變數 i 初始化之前的值。而寫 final 域的操作,被寫 final 域的重排序規則 “限定”在了建構函式之內,讀執行緒 B 正確的讀取了 final 變數初始化之後的值。寫 final 域的重排序規則可以確保:在物件引用為任意執行緒可見之前,物件的 final 域已經被正確初始化過了,而普通域不具有這個保障。以上圖為例,在讀執行緒 B “看到”物件引用 obj 時,很可能 obj 物件還沒有構造完成(對普通域 i 的寫操作被重排序到建構函式外,此時初始值 2 還沒有寫入普通域 i)。

  7.2 讀final域重排序規則

  讀 final 域的重排序規則如下:

  在一個執行緒中,初次讀物件引用與初次讀該物件包含的 final 域,JMM 禁止處理器重排序這兩個操作(注意,這個規則僅僅針對處理器)。編譯器會在讀 final 域操作的前面插入一個 LoadLoad 屏障。初次讀物件引用與初次讀該物件包含的 final 域,這兩個操作之間存在間接依賴關係。由於編譯器遵守間接依賴關係,因此編譯器不會重排序這兩個操作。大多數處理器也會遵守間接依賴,大多數處理器也不會重排序這兩個操作。但有少數處理器允許對存在間接依賴關係的操作做重排序(比如 alpha 處理器),這個規則就是專門用來針對這種處理器。

   reader方法包含三個操作:① 初次讀引用變數 obj。② 初次讀引用變數 obj 指向物件的普通域 i。③ 初次讀引用變數 obj 指向物件的 final 域 j。假設寫執行緒 A 沒有發生任何重排序,同時程式在不遵守間接依賴的處理器上執行,下面是一種可能的執行時序:

  說明:reader操作中1、2操作重排了,即讀物件的普通域的操作被處理器重排序到讀物件引用之前。讀普通域時,該域還沒有被寫執行緒 A 寫入,這是一個錯誤的讀取操作。而讀 final 域的重排序規則會把讀物件 final 域的操作“限定”在讀物件引用之後,此時該 final 域已經被 A 執行緒初始化過了,這是一個正確的讀取操作。讀 final 域的重排序規則可以確保:在讀一個物件的 final 域之前,一定會先讀包含這個 final 域的物件的引用。在這個示例程式中,如果該引用不為 null,那麼引用物件的 final 域一定已經被 A 執行緒初始化過了。

  7.3 final域是引用型別

  上面我們的例子中,final域是基本資料型別,如果final與為引用型別的話情況會稍微不同。對於引用型別,寫 final 域的重排序規則對編譯器和處理器增加了如下約束
  1. 在建構函式內對一個 final 引用的物件的成員域的寫入,與隨後在建構函式外把這個被構造物件的引用賦值給一個引用變數,這兩個操作之間不能重排序。
public class FinalReferenceExample {
    final int[] intArray; // final 是引用型別 
    static FinalReferenceExample obj;

    public FinalReferenceExample () { // 建構函式 
        int Array = new int[1]; // 1
        int    Array[0] = 1; // 2
    }

    public static void writerOne () { // 寫執行緒 A 執行 
        obj = new FinalReferenceExample (); // 3
    }

    public static void writerTwo () { // 寫執行緒 B 執行 
        obj.intArray[0] = 2; // 4
    }

    public static void reader () { // 讀執行緒 C 執行 
        if (obj != null) {    //5
            int temp1 = obj.intArray[0]; // 6
        }
    }
}

  說明:假設首先執行緒 A 執行 writerOne()方法,執行完後執行緒 B 執行 writerTwo()方法,執行完後執行緒 C 執行 reader ()方法。下面是一種可能的執行緒執行時序:


  說明:1 是對 final 域的寫入,2 是對這個 final 域引用的物件的成員域的寫入,3 是把被構造的物件的引用賦值給某個引用變數。這裡除了前面提到的 1 不能 和 3 重排序外,2 和 3 也不能重排序。JMM 可以確保讀執行緒 C 至少能看到寫執行緒 A 在建構函式中對 final 引用物件的成員域的寫入。即 C 至少能看到陣列下標 0 的值為 1。而寫執行緒 B 對陣列元素的寫入,讀執行緒 C 可能看的到,也可能看不到。JMM 不保證執行緒 B 的寫入對讀執行緒 C 可見,因為寫執行緒 B 和讀執行緒 C 之間存在資料競爭,此時的執行結果不可預知。

  如果想要確保讀執行緒 C 看到寫執行緒 B 對陣列元素的寫入,寫執行緒 B 和讀執行緒 C 之間需要使用同步原語(lock 或 volatile)來確保記憶體可見性。

  7.4 final逸出

  寫 final 域的重排序規則可以確保:在引用變數為任意執行緒可見 之前,該引用變數指向的物件的 final 域已經在建構函式中被正確初始化過了。其 實要得到這個效果,還需要一個保證:在建構函式內部,不能讓這個被構造物件的 引用為其他執行緒可見,也就是物件引用不能在建構函式中“逸出”。我們來看下面示例程式碼:  

public class FinalReferenceEscapeExample { 
    final int i;
    static FinalReferenceEscapeExample obj;

    public FinalReferenceEscapeExample () {
        i = 1;    //1 寫 final 域
        obj = this;    //2 this 引用在此“逸出”
    }

    public static void writer() {
        new FinalReferenceEscapeExample ();
    }

    public static void reader {
        if (obj != null) {    //3
        int temp = obj.i;    //4
        }
    }
}

  說明:假設一個執行緒 A 執行 writer()方法,另一個執行緒 B 執行 reader()方法。這裡的操作 2 使得物件還未完成構造前就為執行緒 B 可見。即使這裡的操作 2 是建構函式的最後一步,且即使在程式中操作 2 排在操作 1 後面,執行 read()方法的執行緒仍然可能無 法看到 final 域被初始化後的值,因為這裡的操作 1 和操作 2 之間可能被重排序。實際的執行時序可能如下圖所示:

  說明:在建構函式返回前,被構造物件的引用不能為其他執行緒可見,因為此時的 final 域可能還沒有被初始化。在建構函式返回後,任意執行緒都將保證能看到 final 域正確初始化之後的值。

八、JMM總結

  順序一致性記憶體模型是一個理論參考模型,JMM 和處理器記憶體模型在設計時通常 會把順序一致性記憶體模型作為參照。JMM和處理器記憶體模型在設計時會對順序一 致性模型做一些放鬆,因為如果完全按照順序一致性模型來實現處理器和 JMM, 那麼很多的處理器和編譯器優化都要被禁止,這對執行效能將會有很大的影響。

  8.1 JMM的happens- before規則

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

  1. 會改變程式執行結果的重排序。

  2. 不會改變程式執行結果的重排序。

  JMM 對這兩種不同性質的重排序,採取了不同的策略:

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

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

  JMM的happens - before設計示意圖如下

  說明:從上圖可知

  1. JMM 向程式設計師提供的 happens- before 規則能滿足程式設計師的需求。JMM 的 happens- before 規則不但簡單易懂,而且也向程式設計師提供了足夠強的記憶體可 見性保證(有些記憶體可見性保證其實並不一定真實存在,比如上面的 A happens- before B)。

  2. JMM 對編譯器和處理器的束縛已經儘可能的少。從上面的分析我們可以看出, JMM 其實是在遵循一個基本原則:只要不改變程式的執行結果(指的是單執行緒 程式和正確同步的多執行緒程式),編譯器和處理器怎麼優化都行。比如,如果編譯器經過細緻的分析後,認定一個鎖只會被單個執行緒訪問,那麼這個鎖可以被消除。再比如,如果編譯器經過細緻的分析後,認定一個 volatile 變數僅僅只會被單個執行緒訪問,那麼編譯器可以把這個 volatile 變數當作一個普通變數來對待。這些優化既不會改變程式的執行結果,又能提高程式的執行效率。

   8.2 JMM的記憶體可見性

  Java 程式的記憶體可見性保證按程式型別可以分為下列三類:

  1. 單執行緒程式。單執行緒程式不會出現記憶體可見性問題。編譯器,runtime 和處理器會共同確保單執行緒程式的執行結果與該程式在順序一致性模型中的執行結果相同。

  2. 正確同步的多執行緒程式。正確同步的多執行緒程式的執行將具有順序一致性(程式的執行結果與該程式在順序一致性記憶體模型中的執行結果相同)。這是 JMM 關注的重點,JMM 通過限制編譯器和處理器的重排序來為程式設計師提供記憶體可見性保證。

  3. 未同步/未正確同步的多執行緒程式。JMM 為它們提供了最小安全性保障:執行緒執行時讀取到的值,要麼是之前某個執行緒寫入的值,要麼是預設值(0,null, false)。

  這三類程式在 JMM 中與在順序一致性記憶體模型中的執行結果的異同如下

九、總結

  這一篇的完結也意味著看完了整個JVM,明白了很多JVM底層的知識,讀完後感覺受益匪淺,整個學習筆記有點厚,方便自己以後再精讀。還有一個很深的感觸就是,只有記下來的知識才是自己的,養成記錄的好習慣,一步步變成高手。謝謝各位園友的觀看~

相關文章