【JVM】JVM系列以內存模型(六)

1、前言程序員

  通過前面的學習,咱們終於進入了虛擬機最後一部分的學習,內存模型。理解內存模型對咱們理解虛擬機、正確使用多線程編程提供很大幫助。下面開始正式學習。編程

2、Java併發基礎數組

  在併發編程中存在兩個關鍵問題①線程之間如何通訊 ②線程之間如何同步。緩存

  2.1 通訊安全

  通訊是指線程之間以何種機制來交換信息。在命令式編程中,線程之間的通訊機制有兩種:共享內存和消息傳遞。多線程

  在共享內存的併發模型裏,線程之間共享程序的公共狀態,線程之間經過寫-讀內存中的公共狀態來隱式進行通訊。併發

  在消息傳遞的併發模型裏,線程之間沒有公共狀態,線程之間必須經過明確的發送消息來顯式進行通訊。app

  2.2 同步函數

  同步是指程序用於控制不一樣線程之間操做發生相對順序的機制。性能

  在共享內存併發模型裏,同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼須要在線程之間互斥訪問。

  在消息傳遞的併發模型裏,因爲消息的發送必須在消息的接收以前, 所以同步是隱式進行的。

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

3、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 語義容許對存在控制依賴的操做作重排序的緣由);但在多線程程序中,對存在控制依賴的操做重排序,可能會改變程序的執行結果。

  在多線程中爲了保證程序的正確性,咱們須要進行適當的同步,以保證正確的結果。

4、順序一致性內存模型

  順序一致性內存模型時JMM的參考模型,它提供了很強的內存一致性與可見性,是一個被計算機科學家理想化了的理論參考模型,它爲程序員提供了極強的內存可見性保證。JMM對正確同步的多線程程序的內存一致性作了以下保證

  若是程序是正確同步的,程序的執行將具備順序一致性(sequentially consistent)-- 即程序的執行結果與該程序在順序一致性內存模型中的執行結果相同。立刻咱們將會看到,這對於程序員來講是一個極強的保證。這裏的同步是指廣義上的同步,包括對經常使用同步原語(synchronized,volatile 和 final)的正確使用。

  順序一致性內存模型包括以下兩個特性:

  1. 一個線程中的全部操做必須按照程序的順序來執行。

  2. (無論程序是否同步)全部線程都只能看到一個單一的操做執行順序。在順序一致性內存模型中,每一個操做都必須原子執行且馬上對全部線程可見(JMM中並不保證)。

 

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

 

  說明:每一時刻只有一個線程能夠訪問內存,當多個線程併發執行時,圖中的開關裝置能把全部線程的全部內存讀/寫操做串行化(即在順序一致性模型中,全部操做之間具備全序關係)。下面這個例子更進一步闡明瞭在順序一致性內存模型中各線程操做之間的關係。

  假設線程A有A一、A二、A3三個操做,線程B有B一、B二、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以後的讀具備原子性,寫不具備),而順序一致性模型保證對全部的內存讀/寫操做都具備原子性。

5、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 寫
    }
}

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

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

6、鎖

  鎖是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變量的內存語義的實現。

7、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操做中一、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 域正確初始化以後的值。

8、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 中與在順序一致性內存模型中的執行結果的異同以下

9、總結

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

相關文章
相關標籤/搜索