深入Java內存模型:happen-before規則及其對DCL的分析(含代碼)

happen—before規則介紹

Java語言中有一個“先行發生”(happen—before)的規則,它是Java內存模型中定義的兩項操作之間的偏序關係,如果操作A先行發生於操作B,其意思就是說,在發生操作B之前,操作A產生的影響都能被操作B觀察到,“影響”包括修改了內存中共享變量的值、發送了消息、調用了方法等,它與時間上的先後發生基本沒有太大關係。這個原則特別重要,它是判斷數據是否存在競爭、線程是否安全的主要依據。

    

舉例來說,假設存在如下三個線程,分別執行對應的操作:

線程A中執行如下操作:i=1
線程B中執行如下操作:j=i
線程C中執行如下操作:i=2


假設線程A中的操作”i=1“ happen—before線程B中的操作“j=i”,那麼就可以保證在線程B的操作執行後,變量j的值一定爲1,即線程B觀察到了線程A中操作“i=1”所產生的影響;現在,我們依然保持線程A和線程B之間的happen—before關係,同時線程C出現在了線程A和線程B的操作之間,但是C與B並沒有happen—before關係,那麼j的值就不確定了,線程C對變量i的影響可能會被線程B觀察到,也可能不會,這時線程B就存在讀取到不是最新數據的風險,不具備線程安全性。

下面是Java內存模型中的八條可保證happen—before的規則,它們無需任何同步器協助就已經存在,可以在編碼中直接使用。如果兩個操作之間的關係不在此列,並且無法從下列規則推導出來的話,它們就沒有順序性保障,虛擬機可以對它們進行隨機地重排序。


    1、程序次序規則:在一個單獨的線程中,按照程序代碼的執行流順序,(時間上)先執行的操作happen—before(時間上)後執行的操作。

    2、管理鎖定規則:一個unlock操作happen—before後面(時間上的先後順序,下同)對同一個鎖的lock操作。

    3、volatile變量規則:對一個volatile變量的寫操作happen—before後面對該變量的讀操作。

    4、線程啓動規則:Thread對象的start()方法happen—before此線程的每一個動作。

    5、線程終止規則:線程的所有操作都happen—before對此線程的終止檢測,可以通過Thread.join()方法結束、Thread.isAlive()的返回值等手段檢測到線程已經終止執行。

    6、線程中斷規則:對線程interrupt()方法的調用happen—before發生於被中斷線程的代碼檢測到中斷時事件的發生。

    7、對象終結規則:一個對象的初始化完成(構造函數執行結束)happen—before它的finalize()方法的開始。

    8、傳遞性:如果操作A happen—before操作B,操作B happen—before操作C,那麼可以得出A happen—before操作C。


時間上先後順序和happen—before原則

”時間上執行的先後順序“與”happen—before“之間有何不同呢?

    1、首先來看操作A在時間上先與操作B發生,是否意味着操作A happen—before操作B?

    

一個常用來分析的例子如下:

private int value = 0;
public int get(){
    return value;
}

public void set(int value){
    this.value = value;
}


假設存在線程A和線程B,線程A先(時間上的先)調用了setValue(3)操作,然後(時間上的後)線程B調用了同一對象的getValue()方法,那麼線程B得到的返回值一定是3嗎?


對照以上八條happen—before規則,發現沒有一條規則適合於這裏的value變量,從而我們可以判定線程A中的setValue(3)操作與線程B中的getValue()操作不存在happen—before關係。因此,儘管線程A的setValue(3)在操作時間上先於操作B的getvalue(),但無法保證線程B的getValue()操作一定觀察到了線程A的setValue(3)操作所產生的結果,也即是getValue()的返回值不一定爲3(有可能是之前setValue所設置的值)。這裏的操作不是線程安全的。


因此,”一個操作時間上先發生於另一個操作“並不代表”一個操作happen—before另一個操作“。

    

解決方法:可以將setValue(int)方法和getValue()方法均定義爲synchronized方法,也可以把value定義爲volatile變量(value的修改並不依賴value的原值,符合volatile的使用場景),分別對應happen—before規則的第2和第3條。注意,只將setValue(int)方法和getvalue()方法中的一個定義爲synchronized方法是不行的,必須對同一個變量的所有讀寫同步,才能保證不讀取到陳舊的數據,僅僅同步讀或寫是不夠的 。


    2、其次來看,操作A happen—before操作B,是否意味着操作A在時間上先與操作B發生?


看有如下代碼:

x = 1

y = 2;


假設同一個線程執行上面兩個操作:操作A:x=1和操作B:y=2。根據happen—before規則的第1條,操作A happen—before 操作B,但是由於編譯器的指令重排序(Java語言規範規定了JVM線程內部維持順序化語義,也就是說只要程序的最終結果等同於它在嚴格的順序化環境下的結果,那麼指令的執行順序就可能與代碼的順序不一致。這個過程通過叫做指令的重排序。指令重排序存在的意義在於:JVM能夠根據處理器的特性(CPU的多級緩存系統、多核處理器等)適當的重新排序機器指令,使機器指令更符合CPU的執行特點,最大限度的發揮機器的性能。在沒有同步的情況下,編譯器、處理器以及運行時等都可能對操作的執行順序進行一些意想不到的調整)等原因,操作A在時間上有可能後於操作B被處理器執行,但這並不影響happen—before原則的正確性。


 因此,”一個操作happen—before另一個操作“並不代表”一個操作時間上先發生於另一個操作“。


最後,一個操作和另一個操作必定存在某個順序,要麼一個操作或者是先於或者是後於另一個操作,或者與兩個操作同時發生。同時發生是完全可能存在的,特別是在多CPU的情況下。而兩個操作之間卻可能沒有happen-before關係,也就是說有可能發生這樣的情況,操作A不happen-before操作B,操作B也不happen-before操作A,用數學上的術語happen-before關係是個偏序關係。兩個存在happen-before關係的操作不可能同時發生,一個操作A happen-before操作B,它們必定在時間上是完全錯開的,這實際上也是同步的語義之一(獨佔訪問)。


利用happen—before規則分析DCL

DCL即雙重檢查加鎖。下面是一個典型的在單例模式中使用DCL的例子:

public class LazySingleton {
    private int someField;
    private static LazySingleton instance;
    private LazySingleton() {
        this.someField = new Random().nextInt(200)+1;         // (1)    
    }

    public static LazySingleton getInstance() {
        if (instance == null) {                               // (2)
            synchronized(LazySingleton.class) {               // (3)
                if (instance == null) {                       // (4)
                    instance = new LazySingleton();           // (5)
                }
            }
        }
        return instance;                                      // (6)
    }

    public int getSomeField() {
        return this.someField;                                // (7)
    }
}


這裏得到單一的instance實例是沒有問題的,問題的關鍵在於儘管得到了Singleton的正確引用,但是卻有可能訪問到其成員變量的不正確值。具體來說Singleton.getInstance().getSomeField()有可能返回someField的默認值0。如果程序行爲正確的話,這應當是不可能發生的事,因爲在構造函數裏設置的someField的值不可能爲0。爲也說明這種情況理論上有可能發生,我們只需要說明語句(1)和語句(7)並不存在happen-before關係。


假設線程Ⅰ是初次調用getInstance()方法,緊接着線程Ⅱ也調用了getInstance()方法和getSomeField()方法,我們要說明的是線程Ⅰ的語句(1)並不happen-before線程Ⅱ的語句(7)。線程Ⅱ在執行getInstance()方法的語句(2)時,由於對instance的訪問並沒有處於同步塊中,因此線程Ⅱ可能觀察到也可能觀察不到線程Ⅰ在語句(5)時對instance的寫入,也就是說instance的值可能爲空也可能爲非空。我們先假設instance的值非空,也就觀察到了線程Ⅰ對instance的寫入,這時線程Ⅱ就會執行語句(6)直接返回這個instance的值,然後對這個instance調用getSomeField()方法,該方法也是在沒有任何同步情況被調用,因此整個線程Ⅱ的操作都是在沒有同步的情況下調用 ,這時我們便無法利用上述8條happen-before規則得到線程Ⅰ的操作和線程Ⅱ的操作之間的任何有效的happen-before關係(主要考慮規則的第2條,但由於線程Ⅱ沒有在進入synchronized塊,因此不存在lock與unlock鎖的問題),這說明線程Ⅰ的語句(1)和線程Ⅱ的語句(7)之間並不存在happen-before關係,這就意味着線程Ⅱ在執行語句(7)完全有可能觀測不到線程Ⅰ在語句(1)處對someFiled寫入的值,這就是DCL的問題所在。很荒謬,是吧?DCL原本是爲了逃避同步,它達到了這個目的,也正是因爲如此,它最終受到懲罰,這樣的程序存在嚴重的bug,雖然這種bug被發現的概率絕對比中×××的概率還要低得多,而且是轉瞬即逝,更可怕的是,即使發生了你也不會想到是DCL所引起的。


前面我們說了,線程Ⅱ在執行語句(2)時也有可能觀察空值,如果是種情況,那麼它需要進入同步塊,並執行語句(4)。在語句(4)處線程Ⅱ還能夠讀到instance的空值嗎?不可能。這裏因爲這時對instance的寫和讀都是發生在同一個鎖確定的同步塊中,這時讀到的數據是最新的數據。爲也加深印象,我再用happen-before規則分析一遍。線程Ⅱ在語句(3)處會執行一個lock操作,而線程Ⅰ在語句(5)後會執行一個unlock操作,這兩個操作都是針對同一個鎖--Singleton.class,因此根據第2條happen-before規則,線程Ⅰ的unlock操作happen-before線程Ⅱ的lock操作,再利用單線程規則,線程Ⅰ的語句(5) -> 線程Ⅰ的unlock操作,線程Ⅱ的lock操作 -> 線程Ⅱ的語句(4),再根據傳遞規則,就有線程Ⅰ的語句(5) -> 線程Ⅱ的語句(4),也就是說線程Ⅱ在執行語句(4)時能夠觀測到線程Ⅰ在語句(5)時對Singleton的寫入值。接着對返回的instance調用getSomeField()方法時,我們也能得到線程Ⅰ的語句(1) -> 線程Ⅱ的語句(7)(由於線程Ⅱ有進入synchronized塊,根據規則2可得),這表明這時getSomeField能夠得到正確的值。但是僅僅是這種情況的正確性並不妨礙DCL的不正確性,一個程序的正確性必須在所有的情況下的行爲都是正確的,而不能有時正確,有時不正確。


 對DCL的分析也告訴我們一條經驗原則:對引用(包括對象引用和數組引用)的非同步訪問,即使得到該引用的最新值,卻並不能保證也能得到其成員變量(對數組而言就是每個數組元素)的最新值。


解決方案:

    1、最簡單而且安全的解決方法是使用static內部類的思想,它利用的思想是:一個類直到被使用時才被初始化,而類初始化的過程是非並行的,這些都有JLS保證。

如下述代碼:

public class Singleton {
   private Singleton() {}

   // Lazy initialization holder class idiom for static fields
   private static class InstanceHolder {
     private static final Singleton instance = new Singleton();
   }

   public static Singleton getSingleton() {
     return InstanceHolder.instance;
   }
}


    2、另外,可以將instance聲明爲volatile,即

private volatile static LazySingleton instance;


這樣我們便可以得到,線程Ⅰ的語句(5) -> 語線程Ⅱ的句(2),根據單線程規則,線程Ⅰ的語句(1) -> 線程Ⅰ的語句(5)和語線程Ⅱ的句(2) -> 語線程Ⅱ的句(7),再根據傳遞規則就有線程Ⅰ的語句(1) -> 語線程Ⅱ的句(7),這表示線程Ⅱ能夠觀察到線程Ⅰ在語句(1)時對someFiled的寫入值,程序能夠得到正確的行爲。


   注:


1、volatile屏蔽指令重排序的語義在JDK1.5中才被完全修復,此前的JDK中及時將變量聲明爲volatile,也仍然不能完全避免重排序所導致的問題(主要是volatile變量前後的代碼仍然存在重排序問題),這點也是在JDK1.5之前的Java中無法安全使用DCL來實現單例模式的原因。


2、把volatile寫和volatile讀這兩個操作綜合起來看,在讀線程B讀一個volatile變量後,寫線程A在寫這個volatile變量之前,所有可見的共享變量的值都將立即變得對讀線程B可見。


3、 在java5之前對final字段的同步語義和其它變量沒有什麼區別,在java5中,final變量一旦在構造函數中設置完成(前提是在構造函數中沒有泄露this引用),其它線程必定會看到在構造函數中設置的值。而DCL的問題正好在於看到對象的成員變量的默認值,因此我們可以將LazySingleton的someField變量設置成final,這樣在java5中就能夠正確運行了。


來源:https://blog.csdn.net/ns_code/article/details/17348313 


微信圖片_20171210074204.jpg


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章