Java 單例真的寫對了么?

登高瞭望 8年前發布 | 29K 次閱讀 設計模式 Java開發 Java

單例模式是最簡單的設計模式,實現也非常“簡單”。一直以為我寫沒有問題,直到被 Coverity 打臉。

1. 暴露問題

前段時間,有段代碼被 Coverity 警告了,簡化一下代碼如下,為了方便后面分析,我在這里標上了一些序號:

private static SettingsDbHelper sInst = null;  
public static SettingsDbHelper getInstance(Context context) {  
    if (sInst == null) {                              // 1
        synchronized (SettingsDbHelper.class) {       // 2
            SettingsDbHelper inst = sInst;            // 3
            if (inst == null) {                       // 4
                inst = new SettingsDbHelper(context); // 5
                sInst = inst;                         // 6
            }
        }
    }
    return sInst;                                     // 7
}

大家知道,這可是高大上的 Double Checked locking 模式,保證多線程安全,而且高性能的單例實現,比下面的單例實現,“逼格”不知道高到哪里去了:

private static SettingsDbHelper sInst = null;  
public static synchronized SettingsDbHelper getInstance(Context context) {  
    if (sInst == null) {
        sInst = new SettingsDbHelper(context);
    }
    return sInst;
}

你一個機器人竟敢警告我代碼寫的不對,我一度懷疑它不認識這種寫法(后面將證明我是多么幼稚,啪。。。)。然后,它認真的給我分析這段代碼為什么有問題,如下圖所示:

Java 單例真的寫對了么?

2. 原因分析

Coverity 是靜態代碼分析工具,它會模擬其實際運行情況。例如這里,假設有兩個線程進入到這段代碼,其中紅色的部分是運行的步驟解析,開頭的標號表示其運行順序。關于 Coverity 的詳細文檔可以參考這里,這里簡單解析一下其運行情況如下:

  1. 線程 1 運行到 1 處,第一次進入,這里肯定是為 true 的;
  2. 線程 1 運行到 2 處,獲得鎖 SettingsDbHelper.class
  3. 線程 1 運行到 3 和 4 處,賦值 inst = sInst,這時 sInst 還是 null,所以繼續往下運行,創建一個新的實例;
  4. 線程 1 運行到 6 處,修改 sInst 的值。這一步非常關鍵,這里的解析是,因為這些修改可能因為和其他賦值操作運行被重新排序(Re-order),這就可能導致先修改了 sInst 的值,而 new SettingsDbHelper(context) 這個構造函數并沒有執行完。而在這個時候,程序切換到線程 2;
  5. 線程 2 運行到 1 處,因為第 4 步的時候,線程 1 已經給 sInst 賦值了,所以 sInst == null 的判斷為 false,線程 2 就直接返回 sInst 了,但是這個時候 sInst 并沒有被初始化完成,直接使用它可能會導致程序崩潰。

上面解析得好像很清楚,但是關鍵在第 4 步,為什么會出現 Re-Order?賦值了,但沒有初始化又是怎么回事?這是由于 Java 的內存模型決定的。問題主要出現在這 5 和 6 兩行,這里的構造函數可能會被編譯成內聯的(inline),在 Java 虛擬機中運行的時候編譯成執行指令以后,可以用如下的偽代碼來表示:

inst = allocat(); // 分配內存  
sInst = inst;  
constructor(inst); // 真正執行構造函數  

說到內存模型,這里就不小心觸及了 Java 中比較復雜的內容——多線程編程和 Java 內存模型。在這里,我們可以簡單的理解就是,構造函數可能會被分為兩塊:先分配內存并賦值,再初始化。關于 Java 內存模型(JMM)的詳解,可以參考這個系列文章 《深入理解Java內存模型》,一共有 7 篇()。

3. 解決方案

上面的問題的解決方法是,在 Java 5 之后,引入擴展關鍵字 volatile 的功能,它能保證:

對 volatile 變量的寫操作,不允許和它之前的讀寫操作打亂順序;對 volatile 變量的讀操作,不允許和它之后的讀寫亂序。

關于 volatile 關鍵字原理詳解請參考上面的 深入理解內存模型(四)

所以,上面的操作,只需要對 sInst 變量添加 volatile 關鍵字修飾即可。但是,我們知道,對 volatile 變量的讀寫操作是一個比較重的操作,所以上面的代碼還可以優化一下,如下:

private static volatile SettingsDbHelper sInst = null;  // <<< 這里添加了 volatile  
public static SettingsDbHelper getInstance(Context context) {  
    SettingsDbHelper inst = sInst;  // <<< 在這里創建臨時變量
    if (inst == null) {
        synchronized (SettingsDbHelper.class) {
            inst = sInst;
            if (inst == null) {
                inst = new SettingsDbHelper(context);
                sInst = inst;
            }
        }
    }
    return inst;  // <<< 注意這里返回的是臨時變量
}

通過這樣修改以后,在運行過程中,除了第一次以外,其他的調用只要訪問 volatile 變量 sInst 一次,這樣能提高 25% 的性能(Wikipedia)。

有讀者提到,這里為什么需要再定義一個臨時變量 inst?通過前面的對 volatile 關鍵字作用解釋可知,訪問 volatile 變量,需要保證一些執行順序,所以的開銷比較大。這里定義一個臨時變量,在 sInst 不為空的時候(這是絕大部分的情況),只要在開始訪問一次 volatile 變量,返回的是臨時變量。如果沒有此臨時變量,則需要訪問兩次,而降低了效率。

最后,關于單例模式,還有一個更有趣的實現,它能夠延遲初始化(lazy initialization),并且多線程安全,還能保證高性能,如下:

class Foo {  
    private static class HelperHolder {
       public static final Helper helper = new Helper();
    }

    public static Helper getHelper() {
        return HelperHolder.helper;
    }
}

延遲初始化,這里是利用了 Java 的語言特性,內部類只有在使用的時候,才回去加載,從而初始化內部靜態變量。關于線程安全,這是 Java 運行環境自動給你保證的,在加載的時候,會自動隱形的同步。在訪問對象的時候,不需要同步 Java 虛擬機又會自動給你取消同步,所以效率非常高。

另外,關于 final 關鍵字的原理,請參考 深入理解Java內存模型(六)

補充一下,有同學提醒有一種更加 Hack 的實現方式--單個成員的枚舉,據稱是最佳的單例實現方法,如下:

public enum Foo {  
    INSTANCE;
}

詳情可以參考 這里

4. 總結

在 Java 中,涉及到多線程編程,問題就會復雜很多,有些 Bug 甚至會超出你的想象。通過上面的介紹,開始對自己的代碼運行情況都不那么自信了。其實大可不必這樣擔心,這種僅僅發生在多線程編程中,遇到有臨界值訪問的時候,直接使用 synchronized 關鍵字能夠解決絕大部分的問題。

對于 Coverity,開始抱著敬畏知心,它是由一流的計算機科學家創建的。Coverity 作為一個程序,本身知道的東西比我們多得多,而且還比我認真,它指出的問題必須認真對待和分析。

參考文章:

  1. https://en.wikipedia.org/wiki/Double-checked_locking
  2. http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
  3. http://www.oracle.com/technetwork/articles/javase/bloch-effective-08-qa-140880.html
  4. http://www.ibm.com/developerworks/java/library/j-dcl/index.html
  5. http://www.infoq.com/cn/articles/java-memory-model-1

 

來源:http://www.race604.com/java-double-checked-singleton/

 本文由用戶 登高瞭望 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!