簡述DCL失效原因,解決方法

DCL單例模式

針對延遲加載法的同步實現所產生的性能低的問題,我們可以採用DCL,即雙重檢查加鎖(Double Check Lock)的方法來避免每次調用getInstance()方法時都同步。實現方式如下:

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)
    }
}

優點:資源利用率高,不執行getInstance就不會被實例,多線程下效率高。
缺點:第一次加載時反應不快,由於Java 內存模型一些原因偶爾會失敗,在高併發環境下也有一定的缺陷,雖然發生概率很小。

  DCL對instance進行了兩次null判斷,第一層判斷主要是爲了避免不必要的同步,第二層的判斷則是爲了在null的情況下創建實例。

這裏得到單一的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() {}

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

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