從DCL問題出發認識併發環境下的Java內存訪問

字典

縮寫 全稱
DCL Double-checked locking
JMM Java memory model

1. 什麼是DCL問題

DCL的全稱爲Double-checked locking。Double-checked locking是爲了在併發環境下減少鎖操作,加快計算速度而誕生的“雙檢測鎖”機制。外層檢測機制用於檢測“是否有必要進行鎖操作”,內層檢測機制用於檢測“鎖內部的邏輯是否應該發生”。

這樣講或許還不是很能讓人明白,用幾段實例程序來表達就會清楚很多:


1)(從遠的簡單扯一下)首先先從爲什麼要加鎖開始講起。

 Single-checked without locking示例代碼:

/**
 * Single-checked without locking locking pattern
 * 
 * @author yongqing_wang
 */
public class SingleCheckedNoLocking {

    private static SingleCheckedNoLocking instance = null;

    /**
     * If instance is null, initialize it and then get the new instance
     * 
     * @return
     */
    public static SingleCheckedNoLocking getInstance() {
        if (instance == null) {
            instance = new SingleCheckedNoLocking();
        }

        return instance;
    }

    public SingleCheckedNoLocking() {
        instance = new SingleCheckedNoLocking();
    }
}

來看下這種無鎖模式在併發情況下可能產生的問題:

     

圖1. Single-checked without locking 問題展現

在併發環境下,如果兩個線程對getInstance()的調用及發生關係如圖1(左)所示,那麼線程1在step 4所獲得的實例其實爲線程2創建的實例instance。

又或者,兩個線程對getInstance()的調用及發生關係如圖1(右)所示,那麼線程2在step 4很有可能會獲得一個還未實例化完成的對象(new SingleCheckedNoLocking()正在初始化過程中,但已獲得instance的對象指針,因此instance不爲null,具體一個類的初始化過程請參看其他關於Java的參考資料),此時返回的instance極有可能會發生空指針訪問錯誤。


2)加鎖後

因此在併發環境下需要加入“鎖機制”,在“鎖”的範圍內僅允許相同的操作由一個線程發生,用以防止以上問題的發生。

Single-checked locking示例代碼:

/**
 * Single-checked locking pattern
 * 
 * @author yongqing_wang
 */
public class SingleCheckedLocking {

    private static SingleCheckedLocking instance = null;

    /**
     * If instance is null, initialize it and then get the new instance
     * 
     * @return
     */
    public static SingleCheckedLocking getInstance() {
        synchronized (SingleCheckedLocking.class) {
            if (instance == null) {
                instance = new SingleCheckedLocking();
            }
        }
        return instance;
    }

    public SingleCheckedLocking() {
        instance = new SingleCheckedLocking();
    }
}

加鎖後,所有的線程在調用getInstance()時必須逐一的進行競爭鎖、加鎖,鎖內邏輯判斷及操作、解鎖。這樣會導致併發程序的執行效率大大下降,因爲此時涉及到鎖競爭的操作會使得併發操作串行執行。


3)利用Double-checked locking進行提速

Double-checked locking示例代碼:

/**
 * Double-checked locking pattern
 * 
 * @author yongqing_wang
 */
public class DoubleCheckedLocking {

    private static DoubleCheckedLocking instance = null;

    /**
     * If instance is null, initialize it and then get the new instance
     * 
     * @return
     */
    public static DoubleCheckedLocking getInstance() {
        if (null == instance) {
            synchronized (DoubleCheckedLocking.class) {
                if (null == instance) {
                    instance = new DoubleCheckedLocking();
                }
            }
        }

        return instance;
    }

    public DoubleCheckedLocking() {
        instance = new DoubleCheckedLocking();
    }
}

在double-checked中外層檢測機制用於檢測“是否有必要進行鎖操作”這樣就不會造成併發程序串行執行的問題。內層檢測機制用於檢測“鎖內部的邏輯是否應該發生”,這樣就不會造成重複初始化操作。是不是問題就這樣解決了呢?先留下一個懸念,答案從後面的文字中去一一解開。


2. 併發環境下必須遵守的三條準則

討論併發,必須先從併發環境下需要考慮的問題出發,有需求才有解決的方法,併發環境下必須要遵守三條準則:

1. 原子性(Atomicity)

在這裏,原子性指的是某種操作的最小單位,其在操作過程中不允許被其他同類操作打斷,以保證其“原子”特性。例如,在第一節中提到的synchronized關鍵字就是保證synchronized代碼塊,在多個線程中執行原子性的一種方式。

2. 可見性(Visibility)

可見性指的是當一個線程修改了共享變量的值,其他線程能夠立即得知這個修改。

3. 有序性(Ordering)

(這裏我們特指線程間)有序性指在多個線程間需要有序執行的代碼塊能夠串行執行,不會因爲併發導致類似圖1中的問題。


3. 併發環境下的挑戰

不細細剖析三條準則爲何而來,這三條準則必然是實踐過程中的經驗所得,並非空穴來風和胡亂YY而致。因此,再多做一回“拿來主義者”,且不質疑三條準則認爲已知的三條準則能夠確保併發準確的情況下,所面臨的挑戰。
1. 挑戰:保證有序性
在第一節提到的DCL代碼中,是否就一定能保證有序性呢?


圖2. DCL中對於原子性的挑戰

圖2 是第一節中所提到的DCL代碼塊被兩個線程執行的可能順序。當線程1執行至step 2時,因爲線程1極有可能還未真正初始化完畢,但此時instance已有其對象指針。因此,在step 3中,線程2認爲instance非空,並立刻跳轉至step 4返回一個未被初始化完全的instance實例,從而造成空指針訪問異常。可見,此時多線程間的代碼塊並未按照編程者的意圖有序執行,JVM無法保證在類的初始化過程中能夠先初始化完畢類的實例對象再返回對象指針。

那麼,要解決這個問題,需要藉助一個輔助對象“指引”JVM有序執行代碼塊。經過改造後的DCL代碼爲:

解決DCL中的有序性問題代碼:

/**
 * <h5>Double-checked locking pattern</h5>
 * 
 * <pre>
 * To deal with the ordering problem
 * </pre>
 * 
 * @author yongqing_wang
 */
public class Singleton {

    private static Singleton instance;

    public static Singleton getInstance() {
        Singleton helper = instance;
        if (null == helper) {
            synchronized (Singleton.class) {
                if (null == helper) {
                    instance = new Singleton();
                    helper = instance;
                }
            }
        }
        return instance;
    }
}


helper對象是instance對象“判斷替身”,所有的檢測過程由instance對象的替身helper對象完成,instance看上去也只能在初始化完畢後才能將其“託付”給helper對象。因此,其初始化的有序過程似乎就被保證了。

2. 挑戰:保證原子性

剛纔的代碼內其解決問題的基礎是假定helper=instance必定發生在instance=new Singleton()之後,那麼事實是否如此呢?不是!計算機爲了保證指令執行的高效,會將部分指令重排後執行。也就是說盡管JVM規定了程序代碼按照書寫的先後順序按控制流順序發生(考慮分支、循環等操作),但具體到CPU執行時並不是這樣的。計算機會在允許的範圍內進行指令重排,從而導致helper=instance可能會先於instance的實例化完畢後發生。即,指令的執行無法保證instance=new Singleton()的原子性。

因此,JVM提供了volatile關鍵詞用於解決指令重排而導致的原子性無法保證的問題:

解決原子性問題代碼:

/**
 * <h5>Double-checked locking pattern</h5>
 * 
 * <pre>
 * To deal with the atomicity problem
 * </pre>
 * 
 * @author yongqing_wang
 */
public class Singleton {

    private volatile static Singleton instance;

    public static Singleton getInstance() throws InterruptedException {
        Singleton helper = instance;
        if (null == helper) {
            synchronized (Singleton.class) {
                if (null == helper) {
                    instance = new Singleton();
                    helper = instance;
                }
            }
        }
        return instance;
    }
}

volatile的語義定義在《Java Virtual Machine Specification--Second Edition》中:

1)A use operation by T on V is permitted only if the previous operation by T on V was load, and a load operation by T on V is permitted only if the next operation by T on V is use. The use operation is said to be "associated" with the read operation that corresponds to the load.
2)A store operation by T on V is permitted only if the previous operation by T on V was assign, and an assign operation by T on V is permitted only if the next operation by T on V is store. The assign operation is said to be "associated" with the write operation that corresponds to the store.
3)Let action A be a use or assign by thread T on variable V, let action F be the load or store associated with A, and let action P be the read or write of V that corresponds to F. Similarly, let action B be a use or assign by thread T on variable W, let action G be the load or store associated with B, and let action Q be the read or write of W that corresponds to G. If A precedes B, then P must precede Q. (Less formally: operations on the master copies of volatile variables on behalf of a thread are performed by the main memory in exactly the order that the thread requested.)

(懶得翻譯了)其大致含義總結歸納並對應我們的問題就是:保證了helper=instance只能發生於instance=new Singleton()後,所有被標註爲volatile的變量,就像是一堵“屏障”保證了其前、後代碼無法顛倒執行。


4. 總結

至此,DCL的問題大抵就結束了。但是,似乎我們還沒有提過任何關於可見性的問題。那是因爲,可見性的問題在JMM中已經利用Happen-Before原則進行保證,在DCL問題中很難表現出來。Happen-Before原則規定了JMM中八種操作的執行順序,即lock, unlock, read, load, use, assign, store, write的執行順序。在文獻4中會有一定的涉及,可做擴展閱讀。

任何一個簡單的問題,在併發環境下其問題一般會變得複雜,並且對所考慮問題也需要更加的深入、細緻。路漫漫兮,唯能hold住者笑傲江湖。共勉共勉~


參考文獻:

[1] Double-checked locking問題介紹 from wiki -- http://en.wikipedia.org/wiki/Double-checked_locking

[2] 《深入理解Java虛擬機》

[3] JVM Specification(JVM規範) -- http://java.sun.com/docs/books/jvms/second_edition/html/VMSpecTOC.doc.html

[4] 三問JMM by Xiaorong -- http://download.csdn.net/detail/casia_wyq/3937957





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