Java 單例真的寫對了麼?

單例模式是最簡單的設計模式,實現也很是「簡單」。一直覺得我寫沒有問題,直到被 Coverity 打臉。 java

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

你一個機器人竟敢警告我代碼寫的不對,我一度懷疑它不認識這種寫法(後面將證實我是多麼幼稚,啪。。。)。而後,它認真的給我分析這段代碼爲何有問題,以下圖所示: 安全

coverity-report

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 關鍵字原理詳解請參考上面的 深刻理解內存模型(四)spa

因此,上面的操做,只須要對 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 做爲一個程序,自己知道的東西比咱們多得多,並且還比我認真,它指出的問題必須認真對待和分析。

相關文章
相關標籤/搜索