Java內存模型解惑--觀深刻理解Java內存模型系列文章有感(二)

一、volatile關鍵字修飾的域的特性

  當咱們聲明共享變量爲volatile後,對這個變量的讀/寫將會很特別。理解volatile特性的一個好方法是:把對volatile變量的單個讀/寫,當作是使用同一個鎖對這些單個讀/寫操做作了同步。下面咱們經過具體的示例來講明,請看下面的示例代碼:html

class VolatileFeaturesExample {
    //使用volatile聲明64位的long型變量
    volatile long vl = 0L;

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

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

    public long get() {
        return vl;   //單個volatile變量的讀
    }
}

  假設有多個線程分別調用上面程序的三個方法,這個程序在語義上和下面程序等價:java

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變量的單個讀/寫操做,與對一個普通變量的讀/寫操做使用同一個鎖來同步,它們之間的執行效果相同。(這個synchronize能夠保證同一時間只會有一個線程進行讀或者寫的操做)程序員

  鎖的happens-before規則保證釋放鎖和獲取鎖的兩個線程之間的內存可見性,這意味着對一個volatile變量的讀,老是能看到(任意線程)對這個volatile變量最後的寫入。編程

  鎖的語義決定了臨界區代碼的執行具備原子性。這意味着即便是64位的long型和double型變量,只要它是volatile變量,對該變量的讀寫就將具備原子性。若是是多個volatile操做或相似於volatile++這種複合操做,這些操做總體上不具備原子性。數組

  簡而言之,volatile變量自身具備下列特性:安全

  • 可見性。對一個volatile變量的讀,老是能看到(任意線程)對這個volatile變量最後的寫入。
  • 原子性:對任意單個volatile變量的讀/寫具備原子性,但相似於volatile++這種複合操做不具備原子性。

  對於可見性,JMM是這樣實現的:對於任意一個變量,一旦修改,當即把本地內存更新回主內存。對於任意一個變量,一旦讀取,理解把本地內存中置爲無效,從主內存中獲取該值並更新到本地內存。
併發

  對於原子性,最多見的是long和double型變量的值,原本是分紅兩步來讀寫的,這兩步有可能被不一樣的線程拆開,volatile保證這樣的操做不會被拆開。app

  同時對於一個引用類型的變量,在進行new操做時也保證了他的原子性,先舉例子:jvm

  ====假設線程一執行到instance = new Singleton()這句,這裏看起來是一句話,但實際上它並非一個原子操做(原子操做的意思就是這條語句要麼就被執行完,要麼就沒有被執行過,不能出現執行了一半這種情形)。事實上高級語言裏面非原子操做有不少,咱們只要看看這句話被編譯後在JVM執行的對應彙編代碼就發現,這句話被編譯成8條彙編指令,大體作了3件事情: 
  1.給Singleton的實例分配內存。 
  2.初始化Singleton的構造器 
  3.將instance對象指向分配的內存空間(注意到這步instance就非null了)。 
  可是,因爲Java編譯器容許處理器亂序執行(out-of-order),以及JDK1.5以前JMM(Java Memory Medel)中Cache、寄存器到主內存回寫順序的規定,上面的第二點和第三點的順序是沒法保證的,也就是說,執行順序多是1-2-3也多是1-3-2,若是是後者,而且在3執行完畢、2未執行以前,被切換到線程二上,這時候instance由於已經在線程一內執行過了第三點,instance已是非空了,因此線程二直接拿走instance,而後使用,而後瓜熟蒂落地報錯,並且這種難以跟蹤難以重現的錯誤估計調試上一星期都未必能找得出來。 ====
函數

  在上面這種狀況下,volatile保證了instance變量的原子性,禁止把3重排序到前面,即禁止volatile變量賦值以前的重排序。(也能夠理解爲在給instance設置的set方法上加入了synchronized,而且new的過程是在set中發生的,可是這樣好像有點不合理,就禁止重排序是最好的理解)

  最後說的volatile++操做無效是由於這個操做是複合的,包含了瀆與寫的操做,可是在讀寫的中間過程是沒有進行同步的,有可能被其餘線程插入,這就是在前二篇文章中提到的最後不爲1000的緣由。

1.一、volatile的寫-讀創建的happens before關係

  上面講的是volatile變量自身的特性,對程序員來講,volatile對線程的內存可見性的影響比volatile自身的特性更爲重要,也更須要咱們去關注。

  從JSR-133開始,volatile變量的寫-讀能夠實現線程之間的通訊。

  從內存語義的角度來講,volatile與鎖有相同的效果:volatile寫和鎖的釋放有相同的內存語義;volatile讀與鎖的獲取有相同的內存語義。

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規則,這個過程創建的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 關係。黑色箭頭表示程序順序規則;橙色箭頭表示volatile規則;藍色箭頭表示組合這些規則後提供的happens before保證。

  這裏A線程寫一個volatile變量後,B線程讀同一個volatile變量。A線程在寫volatile變量以前全部可見的共享變量,在B線程讀同一個volatile變量後,將當即變得對B線程可見。

二、volatile寫-讀的內存語義

  volatile寫的內存語義以下:

  當寫一個volatile變量時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存。

  以上面示例程序VolatileExample爲例,假設線程A首先執行writer()方法,隨後線程B執行reader()方法,初始時兩個線程的本地內存中的flag和a都是初始狀態。下圖是線程A執行volatile寫後,共享變量的狀態示意圖:

  如上圖所示,線程A在寫flag變量後,本地內存A中被線程A更新過的兩個共享變量的值被刷新到主內存中。此時,本地內存A和主內存中的共享變量的值是一致的。

  volatile讀的內存語義以下:

  當讀一個volatile變量時,JMM會把該線程對應的本地內存置爲無效。線程接下來將從主內存中讀取共享變量。

  下面是線程B讀同一個volatile變量後,共享變量的狀態示意圖:

  如上圖所示,在讀flag變量後,本地內存B已經被置爲無效。此時,線程B必須從主內存中讀取共享變量。線程B的讀取操做將致使本地內存B與主內存中的共享變量的值也變成一致的了。

  若是咱們把volatile寫和volatile讀這兩個步驟綜合起來看的話,在讀線程B讀一個volatile變量後,寫線程A在寫這個volatile變量以前全部可見的共享變量的值都將當即變得對讀線程B可見。

  下面對volatile寫和volatile讀的內存語義作個總結:

  • 線程A寫一個volatile變量,實質上是線程A向接下來將要讀這個volatile變量的某個線程發出了(其對共享變量所在修改的)消息。
  • 線程B讀一個volatile變量,實質上是線程B接收了以前某個線程發出的(在寫這個volatile變量以前對共享變量所作修改的)消息。
  • 線程A寫一個volatile變量,隨後線程B讀這個volatile變量,這個過程實質上是線程A經過主內存向線程B發送消息。

三、volatile內存語義的實現(不只實時同步主內存,並且要限制重排序已達成內存語義)

  下面,讓咱們來看看JMM如何實現volatile寫/讀的內存語義。

  前文咱們提到太重排序分爲編譯器重排序和處理器重排序。爲了實現volatile內存語義,JMM會分別限制這兩種類型的重排序類型。下面是JMM針對編譯器制定的volatile重排序規則表:

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

  這裏的第一個操做是普通讀/寫,第二個操做是volatile寫是禁止重排序的,這就是上面保證引用類型的原子性裏的禁止狀況。

  舉例來講,第三行最後一個單元格的意思是:在程序順序中,當第一個操做爲普通變量的讀或寫時,若是第二個操做爲volatile寫,則編譯器不能重排序這兩個操做。

  從上表咱們能夠看出:

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

  爲了實現volatile的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。對於編譯器來講,發現一個最優佈置來最小化插入屏障的總數幾乎不可能,爲此,JMM採起保守策略。下面是基於保守策略的JMM內存屏障插入策略:

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

  上述內存屏障插入策略很是保守,但它能夠保證在任意處理器平臺,任意的程序中都能獲得正確的volatile內存語義。

  下面是保守策略下,volatile寫插入內存屏障後生成的指令序列示意圖:

  上圖中的StoreStore屏障能夠保證在volatile寫以前,其前面的全部普通寫操做已經對任意處理器可見了。這是由於StoreStore屏障將保障上面全部的普通寫在volatile寫以前刷新到主內存。

  這裏比較有意思的是volatile寫後面的StoreLoad屏障。這個屏障的做用是避免volatile寫與後面可能有的volatile讀/寫操做重排序。由於編譯器經常沒法準確判斷在一個volatile寫的後面,是否須要插入一個StoreLoad屏障(好比,一個volatile寫以後方法當即return)。爲了保證能正確實現volatile的內存語義,JMM在這裏採起了保守策略:在每一個volatile寫的後面或在每一個volatile讀的前面插入一個StoreLoad屏障。從總體執行效率的角度考慮,JMM選擇了在每一個volatile寫的後面插入一個StoreLoad屏障。由於volatile寫-讀內存語義的常見使用模式是:一個寫線程寫volatile變量,多個讀線程讀同一個volatile變量。當讀線程的數量大大超過寫線程時,選擇在volatile寫以後插入StoreLoad屏障將帶來可觀的執行效率的提高。從這裏咱們能夠看到JMM在實現上的一個特色:首先確保正確性,而後再去追求執行效率。

  下面是在保守策略下,volatile讀插入內存屏障後生成的指令序列示意圖:

  

  上圖中的LoadLoad屏障用來禁止處理器把上面的volatile讀與下面的普通讀重排序。LoadStore屏障用來禁止處理器把上面的volatile讀與下面的普通寫重排序。

  上述volatile寫和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寫
    }

    …                    //其餘方法
}

  針對readAndWrite()方法,編譯器在生成字節碼時能夠作以下的優化:

  注意,最後的StoreLoad屏障不能省略。由於第二個volatile寫以後,方法當即return。此時編譯器可能沒法準確判定後面是否會有volatile讀或寫,爲了安全起見,編譯器經常會在這裏插入一個StoreLoad屏障。

  上面的優化是針對任意處理器平臺,因爲不一樣的處理器有不一樣「鬆緊度」的處理器內存模型,內存屏障的插入還能夠根據具體的處理器內存模型繼續優化。以x86處理器爲例,上圖中除最後的StoreLoad屏障外,其它的屏障都會被省略。

  前面保守策略下的volatile讀和寫,在 x86處理器平臺能夠優化成:

  前文提到過,x86處理器僅會對寫-讀操做作重排序。X86不會對讀-讀,讀-寫和寫-寫操做作重排序,所以在x86處理器中會省略掉這三種操做類型對應的內存屏障。在x86中,JMM僅需在volatile寫後面插入一個StoreLoad屏障便可正確實現volatile寫-讀的內存語義。這意味着在x86處理器中,volatile寫的開銷比volatile讀的開銷會大不少(由於執行StoreLoad屏障開銷會比較大)。

3.一、JSR-133爲何要加強volatile的內存語義

  在JSR-133以前的舊Java內存模型中,雖然不容許volatile變量之間重排序,但舊的Java內存模型容許volatile變量與普通變量之間重排序。在舊的內存模型中,VolatileExample示例程序可能被重排序成下列時序來執行:

  在舊的內存模型中,當1和2之間沒有數據依賴關係時,1和2之間就可能被重排序(3和4相似)。其結果就是:讀線程B執行4時,不必定能看到寫線程A在執行1時對共享變量的修改。

  所以在舊的內存模型中 ,volatile的寫-讀沒有鎖的釋放-獲所具備的內存語義。爲了提供一種比鎖更輕量級的線程之間通訊的機制,JSR-133專家組決定加強volatile的內存語義:嚴格限制編譯器和處理器對volatile變量與普通變量的重排序,確保volatile的寫-讀和鎖的釋放-獲取同樣,具備相同的內存語義。從編譯器重排序規則和處理器內存屏障插入策略來看,只要volatile變量與普通變量之間的重排序可能會破壞volatile的內存語意,這種重排序就會被編譯器重排序規則和處理器內存屏障插入策略禁止。

  因爲volatile僅僅保證對單個volatile變量的讀/寫具備原子性,而鎖的互斥執行的特性能夠確保對整個臨界區代碼的執行具備原子性。在功能上,鎖比volatile更強大;在可伸縮性和執行性能上,volatile更有優點。若是讀者想在程序中用volatile代替監視器鎖,請必定謹慎.(http://www.ibm.com/developerworks/cn/java/j-jtp06197.html)

  PS:在java1.6以後,jvm優化了synchronize的性能,因此能使用synchronize的狀況下,儘可能少使用volatile。(http://ifeve.com/java-synchronized/)

  1.6以後,volatile變量與普通變量的界限也變得模糊了,以下:

public class VolatileExample extends Thread{
      //設置類靜態變量,各線程訪問這同一共享變量
      private static boolean flag = false;
      
      //無限循環,等待flag變爲true時才跳出循環
      public void run() {while (!flag){};}
      
      public static void main(String[] args) throws Exception {
          new VolatileExample().start();
          //sleep的目的是等待線程啓動完畢,也就是說進入run的無限循環體了
          Thread.sleep(100);
          flag = true;
      }
 }

  這種狀況下,這個程序是不會終止的,若是把flag聲明爲volatile,則能夠終止。可是若是保持沒有volatile聲明,在while裏面加上一個println(1),則發現又能夠終止了。這是怎麼回事呢??

  原來只有在對變量讀取頻率很高的狀況下,虛擬機纔不會及時回寫主內存,而當頻率沒有達到虛擬機認爲的高頻率時,普通變量和volatile是一樣的處理邏輯。如在每一個循環中執行System.out.println(1)加大了讀取變量的時間間隔,使虛擬機認爲讀取頻率並不那麼高,因此實現了和volatile的效果(本文開頭的例子只在HotSpot24上測試過,沒有在JRockit之類其他版本JDK上測過)。volatile的效果在jdk1.2及以前很容易重現,但隨着虛擬機的不斷優化,現在的普通變量的可見性已經不是那麼嚴重的問題了,這也是volatile現在確實不太有使用場景的緣由吧。

四、synchronize鎖的釋放-獲取創建的happens before 關係

  鎖是java併發編程中最重要的同步機制。鎖除了讓臨界區(synchronize的區域)互斥執行外,還可讓釋放鎖的線程向獲取同一個鎖的線程發送消息。下面是鎖釋放-獲取的示例代碼:(方法上的鎖是鎖的this對象)

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規則,這個過程包含的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. 根據happens before 的傳遞性,2 happens before 5。

  上述happens before 關係的圖形化表現形式以下:

  在上圖中,每個箭頭連接的兩個節點,表明了一個happens before 關係。黑色箭頭表示程序順序規則;橙色箭頭表示監視器鎖規則;藍色箭頭表示組合這些規則後提供的happens before保證。

  上圖表示在線程A釋放了鎖以後,隨後線程B獲取同一個鎖。在上圖中,2 happens before 5。所以,線程A在釋放鎖以前全部可見的共享變量,在線程B獲取同一個鎖以後,將馬上變得對B線程可見。

五、鎖釋放和獲取的內存語義

  當線程釋放鎖時,JMM會把該線程對應的本地內存中的共享變量刷新到主內存中。以上面的MonitorExample程序爲例,A線程釋放鎖後,共享數據的狀態示意圖以下:

當線程獲取鎖時,JMM會把該線程對應的本地內存置爲無效。從而使得被監視器保護的臨界區代碼必需要從主內存中去讀取共享變量。下面是鎖獲取的狀態示意圖:

  對比鎖釋放-獲取的內存語義與volatile寫-讀的內存語義,能夠看出:鎖釋放與volatile寫有相同的內存語義;鎖獲取與volatile讀有相同的內存語義。

  下面對鎖釋放和鎖獲取的內存語義作個總結:

  • 線程A釋放一個鎖,實質上是線程A向接下來將要獲取這個鎖的某個線程發出了(線程A對共享變量所作修改的)消息。
  • 線程B獲取一個鎖,實質上是線程B接收了以前某個線程發出的(在釋放這個鎖以前對共享變量所作修改的)消息。
  • 線程A釋放鎖,隨後線程B獲取這個鎖,這個過程實質上是線程A經過主內存向線程B發送消息。

六、鎖內存語義的實現

  本文將藉助ReentrantLock的源代碼,來分析鎖內存語義的具體實現機制。

  太複雜了,不抄過來了。。。http://www.infoq.com/cn/articles/java-memory-model-5

  其中涉及到CAS(compareAndSwapInt())

 七、final域的特殊性

  與前面介紹的鎖和volatile相比較,對final域的讀和寫更像是普通的變量訪問。對於final域,編譯器和處理器要遵照兩個重排序規則:

  1. 在構造函數內對一個final域的寫入,與隨後把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。
  2. 初次讀一個包含final域的對象的引用,與隨後初次讀這個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.一、寫final域的重排序規則

  寫final域的重排序規則禁止把final域的寫重排序到構造函數以外。這個規則的實現包含下面2個方面:

  • JMM禁止編譯器把final域的寫重排序到構造函數以外。
  • 編譯器會在final域的寫以後,構造函數return以前,插入一個StoreStore屏障。這個屏障禁止處理器把final域的寫重排序到構造函數以外。

  如今讓咱們分析writer ()方法。writer ()方法只包含一行代碼:finalExample = new FinalExample ()。這行代碼包含兩個步驟:

  1. 構造一個FinalExample類型的對象;
  2. 把這個對象的引用賦值給引用變量obj。

  假設線程B讀對象引用與讀對象的成員域之間沒有重排序(立刻會說明爲何須要這個假設),下圖是一種可能的執行時序:

  在上圖中,寫普通域的操做被編譯器重排序到了構造函數以外,讀線程B錯誤的讀取了普通變量i初始化以前的值。而寫final域的操做,被寫final域的重排序規則「限定」在了構造函數以內,讀線程B正確的讀取了final變量初始化以後的值。

  寫final域的重排序規則能夠確保:在對象引用爲任意線程可見以前,對象的final域已經被正確初始化過了,而普通域不具備這個保障。以上圖爲例,在讀線程B「看到」對象引用obj時,極可能obj對象尚未構造完成(對普通域i的寫操做被重排序到構造函數外,此時初始值2尚未寫入普通域i)。

7.二、讀final域的重排序規則

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

  在一個線程中,初次讀對象引用與初次讀該對象包含的final域,JMM禁止處理器重排序這兩個操做(注意,這個規則僅僅針對處理器)。編譯器會在讀final域操做的前面插入一個LoadLoad屏障。

  初次讀對象引用與初次讀該對象包含的final域,這兩個操做之間存在間接依賴關係。因爲編譯器遵照間接依賴關係,所以編譯器不會重排序這兩個操做。大多數處理器也會遵照間接依賴,大多數處理器也不會重排序這兩個操做。但有少數處理器容許對存在間接依賴關係的操做作重排序(好比alpha處理器),這個規則就是專門用來針對這種處理器。

  reader()方法包含三個操做:

  1. 初次讀引用變量obj;
  2. 初次讀引用變量obj指向對象的普通域j。
  3. 初次讀引用變量obj指向對象的final域i。

  如今咱們假設寫線程A沒有發生任何重排序,同時程序在不遵照間接依賴的處理器上執行,下面是一種可能的執行時序:

  

  在上圖中,讀對象的普通域的操做被處理器重排序到讀對象引用以前。讀普通域時,該域尚未被寫線程A寫入,這是一個錯誤的讀取操做。而讀final域的重排序規則會把讀對象final域的操做「限定」在讀對象引用以後,此時該final域已經被A線程初始化過了,這是一個正確的讀取操做。

  讀final域的重排序規則能夠確保:在讀一個對象的final域以前,必定會先讀包含這個final域的對象的引用。在這個示例程序中,若是該引用不爲null,那麼引用對象的final域必定已經被A線程初始化過了。

7.三、若是final域是引用類型

  上面咱們看到的final域是基礎數據類型,下面讓咱們看看若是final域是引用類型,將會有什麼效果?

  請看下列示例代碼:

public class FinalReferenceExample {
final int[] intArray;                     //final是引用類型
static FinalReferenceExample obj;

public FinalReferenceExample () {        //構造函數
    intArray = new int[1];              //1
    intArray[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
    }
}
}

  這裏final域爲一個引用類型,它引用一個int型的數組對象。對於引用類型,寫final域的重排序規則對編譯器和處理器增長了以下約束:

  在構造函數內對一個final引用的對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操做之間不能重排序。

  對上面的示例程序,咱們假設首先線程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.四、爲何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域正確初始化以後的值。

7.五、final語義在處理器中的實現

  如今咱們以x86處理器爲例,說明final語義在處理器中的具體實現。

  上面咱們提到,寫final域的重排序規則會要求譯編器在final域的寫以後,構造函數return以前,插入一個StoreStore障屏。讀final域的重排序規則要求編譯器在讀final域的操做前面插入一個LoadLoad屏障。

  因爲x86處理器不會對寫-寫操做作重排序,因此在x86處理器中,寫final域須要的StoreStore障屏會被省略掉。一樣,因爲x86處理器不會對存在間接依賴關係的操做作重排序,因此在x86處理器中,讀final域須要的LoadLoad屏障也會被省略掉。也就是說在x86處理器中,final域的讀/寫不會插入任何內存屏障!

7.六、JSR-133爲何要加強final的語義

  在舊的Java內存模型中 ,最嚴重的一個缺陷就是線程可能看到final域的值會改變。好比,一個線程當前看到一個整形final域的值爲0(還未初始化以前的默認值),過一段時間以後這個線程再去讀這個final域的值時,卻發現值變爲了1(被某個線程初始化以後的值)。最多見的例子就是在舊的Java內存模型中,String的值可能會改變(參考文獻2中有一個具體的例子,感興趣的讀者能夠自行參考,這裏就不贅述了)。

  爲了修補這個漏洞,JSR-133專家組加強了final的語義。經過爲final域增長寫和讀重排序規則,能夠爲java程序員提供初始化安全保證:只要對象是正確構造的(被構造對象的引用在構造函數中沒有「逸出」),那麼不須要使用同步(指lock和volatile的使用),就能夠保證任意線程都能看到這個final域在構造函數中被初始化以後的值。

 

補充內容:不得不提的volatile及指令重排序(happen-before)

相關文章
相關標籤/搜索