單例模式是最簡單的設計模式,實現也很是「簡單」。一直覺得我寫沒有問題,直到被 Coverity 打臉。 java
前段時間,有段代碼被 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 是靜態代碼分析工具,它會模擬其實際運行狀況。例如這裏,假設有兩個線程進入到這段代碼,其中紅色的部分是運行的步驟解析,開頭的標號表示其運行順序。關於 Coverity 的詳細文檔能夠參考這裏,這裏簡單解析一下其運行狀況以下: 多線程
上面解析得好像很清楚,可是關鍵在第 4 步,爲何會出現 Re-Order?賦值了,但沒有初始化又是怎麼回事?這是因爲 Java 的內存模型決定的。問題主要出如今這 5 和 6 兩行,這裏的構造函數可能會被編譯成內聯的(inline),在 Java 虛擬機中運行的時候編譯成執行指令之後,能夠用以下的僞代碼來表示: 函數
inst = allocat(); // 分配內存 sInst = inst; constructor(inst); // 真正執行構造函數
說到內存模型,這裏就不當心觸及了 Java 中比較複雜的內容——多線程編程和 Java 內存模型。在這裏,咱們能夠簡單的理解就是,構造函數可能會被分爲兩塊:先分配內存並賦值,再初始化。關於 Java 內存模型(JMM)的詳解,能夠參考這個系列文章 《深刻理解Java內存模型》,一共有 7 篇(一,二,三,四,五,六,七)。 工具
上面的問題的解決方法是,在 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; }
詳情能夠參考 這裏。
在 Java 中,涉及到多線程編程,問題就會複雜不少,有些 Bug 甚至會超出你的想象。經過上面的介紹,開始對本身的代碼運行狀況都不那麼自信了。其實大可沒必要這樣擔憂,這種僅僅發生在多線程編程中,遇到有臨界值訪問的時候,直接使用 synchronized 關鍵字可以解決絕大部分的問題。
對於 Coverity,開始抱着敬畏知心,它是由一流的計算機科學家建立的。Coverity 做爲一個程序,自己知道的東西比咱們多得多,並且還比我認真,它指出的問題必須認真對待和分析。