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


一、前言

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

二、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底層的知識,讀完后感覺受益匪淺,整個學習筆記有點厚,方便自己以后再精讀。還有一個很深的感觸就是,只有記下來的知識才是自己的,養成記錄的好習慣,一步步變成高手。謝謝各位園友的觀看~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM