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;
}
你一個機器人竟敢警告我代碼寫的不對,我一度懷疑它不認識這種寫法(后面將證明我是多么幼稚,啪。。。)。然后,它認真的給我分析這段代碼為什么有問題,如下圖所示:
2. 原因分析
Coverity 是靜態代碼分析工具,它會模擬其實際運行情況。例如這里,假設有兩個線程進入到這段代碼,其中紅色的部分是運行的步驟解析,開頭的標號表示其運行順序。關于 Coverity 的詳細文檔可以參考這里,這里簡單解析一下其運行情況如下:
- 線程 1 運行到 1 處,第一次進入,這里肯定是為
true
的; - 線程 1 運行到 2 處,獲得鎖
SettingsDbHelper.class
; - 線程 1 運行到 3 和 4 處,賦值
inst = sInst
,這時 sInst 還是 null,所以繼續往下運行,創建一個新的實例; - 線程 1 運行到 6 處,修改 sInst 的值。這一步非常關鍵,這里的解析是,因為這些修改可能因為和其他賦值操作運行被重新排序(Re-order),這就可能導致先修改了 sInst 的值,而
new SettingsDbHelper(context)
這個構造函數并沒有執行完。而在這個時候,程序切換到線程 2; - 線程 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 作為一個程序,本身知道的東西比我們多得多,而且還比我認真,它指出的問題必須認真對待和分析。
參考文章:
- https://en.wikipedia.org/wiki/Double-checked_locking
- http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html
- http://www.oracle.com/technetwork/articles/javase/bloch-effective-08-qa-140880.html
- http://www.ibm.com/developerworks/java/library/j-dcl/index.html
- http://www.infoq.com/cn/articles/java-memory-model-1
來源:http://www.race604.com/java-double-checked-singleton/