1. 程式人生 > >【Java併發程式設計】之十六:深入Java記憶體模型——happen-before規則及其對DCL的分析(含程式碼)

【Java併發程式設計】之十六:深入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,那麼可以得出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

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,即 privatevolatilestatic 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中就能夠正確運行了。

                 《深入理解Java虛擬機器——JVM高階特性與最佳實踐》第12章