Hi,我們再來聊一聊 Java 的單例吧

本文轉載自 http://www.barryzhang.com/archives/521
感謝原創作者 BarryZhang

1. 前言

單例(Singleton)應該是開發者們最熟悉的設計模式了,並且好像也是最容易實現的——基本上每個開發者都能夠隨手寫出——但是,真的是這樣嗎?

作爲一個 Java 開發者,也許你覺得自己對單例模式的瞭解已經足夠多了。我並不想危言聳聽說一定還有你不知道的——畢竟我自己的瞭解也的確有限,但究竟你自己瞭解的程度到底怎樣呢?往下看,我們一起來聊聊看~

2. 什麼是單例?

單例對象的類必須保證只有一個實例存在——這是維基百科上對單例的定義,這也可以作爲對意圖實現單例模式的代碼進行檢驗的標準。

對單例的實現可以分爲兩大類——懶漢式餓漢式,他們的區別在於:
- 懶漢式:指全局的單例實例在第一次被使用時構建。
- 餓漢式:指全局的單例實例在類裝載時構建。

從它們的區別也能看出來,日常我們使用的較多的應該是懶漢式的單例,畢竟按需加載才能做到資源的最大化利用嘛~

3. 懶漢式單例

先來看一下懶漢式單例的實現方式。

3.1 簡單版本

看最簡單的寫法 Version 1:

// Version 1
public class Single1 {
    private static Single1 instance;
    public static Single1 getInstance() {
        if (instance == null) {
            instance = new Single1();
        }
        return instance;
    }
}

或者再進一步,把構造器改爲私有的,這樣能夠防止被外部的類調用。

// Version 1.1
public class Single1 {
    private static Single1 instance;
    private Single1() {}
    public static Single1 getInstance() {
        if (instance == null) {
            instance = new Single1();
        }
        return instance;
    }
}

我彷彿記得當初學校的教科書就是這麼教的?—— 每次獲取 instance
之前先進行判斷,如果 instance 爲空就 new 一個出來,否則就直接返回已存在的 instance。

這種寫法在大多數的時候也是沒問題的。問題在於,當多線程工作的時候,如果有多個線程同時運行到 if (instance == null),都判斷爲 null,那麼兩個線程就各自會創建一個實例——這樣一來,就不是單例了。

3.2 synchronized版本

那既然可能會因爲多線程導致問題,那麼加上一個同步鎖吧!
修改後的代碼如下,相對於 Version1.1,只是在方法簽名上多加了一個 synchronized:

// Version 2 
public class Single2 {
    private static Single2 instance;
    private Single2() {}
    public static synchronized Single2 getInstance() {
        if (instance == null) {
            instance = new Single2();
        }
        return instance;
    }
}

OK,加上 synchronized 關鍵字之後,getInstance 方法就會鎖上了。如果有兩個線程(T1、T2)同時執行到這個方法時,會有其中一個線程 T1 獲得同步鎖,得以繼續執行,而另一個線程 T2 則需要等待,當第 T1 執行完畢 getInstance 之後(完成了 null 判斷、對象創建、獲得返回值之後),T2 線程纔會執行執行。——所以這端代碼也就避免了 Version1 中,可能出現因爲多線程導致多個實例的情況。

但是,這種寫法也有一個問題:給 getInstance 方法加鎖,雖然會避免了可能會出現的多個實例問題,但是會強制除 T1 之外的所有線程等待,實際上會對程序的執行效率造成負面影響。

3.3 雙重檢查(Double-Check)版本

Version2 代碼相對於 Version1 的代碼的效率問題,其實是爲了解決
1% 機率的問題,而使用了一個 100% 出現的防護盾。那有一個優化的思路,就是把 100% 出現的防護盾,也改爲 1% 的機率出現,使之只出現在可能會導致多個實例出現的地方。

——有沒有這樣的方法呢?當然是有的,改進後的代碼 Vsersion3 如下:

// Version 3 
public class Single3 {
    private static Single3 instance;
    private Single3() {}
    public static Single3 getInstance() {
        if (instance == null) {
            synchronized (Single3.class) {
                if (instance == null) {
                    instance = new Single3();
                }
            }
        }
        return instance;
    }
}

這個版本的代碼看起來有點複雜,注意其中有兩次 if (instance == null) 的判斷,這個叫做『雙重檢查 Double-Check』。

第一個 if (instance == null),其實是爲了解決 Version2 中的效率問題,只有 instance 爲 null 的時候,才進入 synchronized 的代碼段——大大減少了機率。
第二個 if (instance == null),則是跟 Version2 一樣,是爲了防止可能出現多個實例的情況。

—— 這段代碼看起來已經完美無瑕了。
……
……
……
—— 當然,只是『看起來』,還是有小概率出現問題的。

這弄清楚爲什麼這裏可能出現問題,首先,我們需要弄清楚幾個概念:原子操作指令重排

知識點:什麼是原子操作?

簡單來說,原子操作(atomic)就是不可分割的操作,在計算機中,就是指不會因爲線程調度被打斷的操作。

比如,簡單的賦值是一個原子操作:

m = 6; // 這是個原子操作

假如 m 原先的值爲 0,那麼對於這個操作,要麼執行成功 m 變成了
6,要麼是沒執行 m 還是 0,而不會出現諸如 m=3 這種中間態——即使是在併發的線程中。

而,聲明並賦值就不是一個原子操作:

int n = 6; // 這不是一個原子操作

對於這個語句,至少有兩個操作:
① 聲明一個變量 n
② 給n賦值爲 6
——這樣就會有一箇中間狀態:變量 n 已經被聲明瞭但是還沒有被賦值的狀態。
——這樣,在多線程中,由於線程執行順序的不確定性,如果兩個線程都使用 m,就可能會導致不穩定的結果出現。

知識點:什麼是指令重排?

簡單來說,就是計算機爲了提高執行效率,會做的一些優化,在不影響最終結果的情況下,可能會對一些語句的執行順序進行調整。
比如,這一段代碼:

int a ;   // 語句1 
a = 8 ;   // 語句2
int b = 9 ;     // 語句3
int c = a + b ; // 語句4

正常來說,對於順序結構,執行的順序是自上到下,也即 1234。但是,由於指令重排的原因,因爲不影響最終的結果,所以,實際執行的順序可能會變成 3124 或者 1324。由於語句 3 和 4 沒有原子性的問題,語句 3 和語句 4 也可能會拆分成原子操作,再重排。——也就是說,對於非原子性的操作,在不影響最終結果的情況下,其拆分成的原子操作可能會被重新排列執行順序。

OK,瞭解了原子操作和指令重排的概念之後,我們再繼續看 Version3 代碼的問題。下面這段話直接從陳皓的文章 (深入淺出單實例SINGLETON設計模式) 中複製而來:

主要在於 singleton = new Singleton() 這句,這並非是一個原子操作,事實上在 JVM 中這句話大概做了下面 3 件事情。

1.給 Singleton 分配內存
2.調用 Singleton 的構造函數來初始化成員變量,形成實例
3.將 Singleton 對象指向分配的內存空間(執行完這步 Singleton 纔是非 null 了)

但是在 JVM 的即時編譯器中存在指令重排序的優化。也就是說上面的第二步和第三步的順序是不能保證的,最終的執行順序可能是 1-2-3 也可能是 1-3-2。如果是後者,則在 3 執行完畢、2 未執行之前,被線程二搶佔了,這時 instance 已經是非 null 了(但卻沒有初始化),所以線程二會直接返回 instance,然後使用,然後順理成章地報錯。

再稍微解釋一下,就是說,由於有一個『instance 已經不爲 null 但是仍沒有完成初始化』的中間狀態,而這個時候,如果有其他線程剛好運行到第一層 if (instance == null) 這裏,這裏讀取到的 instance 已經不爲 null 了,所以就直接把這個中間狀態的 instance 拿去用了,就會產生問題。

這裏的關鍵在於——線程 T1 對 instance 的寫操作沒有完成,線程 T2
就執行了讀操作。

3.4 終極版本:volatile

對於 Version3 中可能出現的問題(當然這種概率已經非常小了,但畢竟還是有的嘛~),解決方案是:只需要給 instance 的聲明加上 volatile 關鍵字即可,Version4 版本:

// Version 4 
public class Single4 {
    private static volatile Single4 instance;
    private Single4() {}
    public static Single4 getInstance() {
        if (instance == null) {
            synchronized (Single4.class) {
                if (instance == null) {
                    instance = new Single4();
                }
            }
        }
        return instance;
    }
}

volatile 關鍵字的一個作用是禁止指令重排,把 instance 聲明爲 volatile 之後,對它的寫操作就會有一個內存屏障(什麼是內存屏障?),這樣,在它的賦值完成之前,就不用會調用讀操作。

注意:volatile 阻止的不 singleton = new Singleton() 這句話內部 [1-2-3] 的指令重排,而是保證了在一個寫操作([1-2-3])完成之前,不會調用讀操作(if (instance == null))。

——也就徹底防止了Version3中的問題發生。
——好了,現在徹底沒什麼問題了吧?
……
……
……
好了,別緊張,的確沒問題了。
大名鼎鼎的 EventBus 中,其入口方法 EventBus.getDefault() 就是用這種方法來實現的。
……
……
……
不過,非要挑點刺的話還是能挑出來的,就是這個寫法有些複雜了,不夠優雅、簡潔。(傲嬌臉)(  ̄ー ̄)

4. 餓漢式單例

下面再聊瞭解一下餓漢式的單例。

如上所說,餓漢式單例是指:指全局的單例實例在類裝載時構建的實現方式。

由於類裝載的過程是由類加載器(ClassLoader)來執行的,這個過程也是由 JVM 來保證同步的,所以這種方式先天就有一個優勢——能夠免疫許多由多線程引起的問題。

4.1 餓漢式單例的實現方式

餓漢式單例的實現如下:

//餓漢式實現
public class SingleB {
    private static final SingleB INSTANCE = new SingleB();
    private SingleB() {}
    public static SingleB getInstance() {
        return INSTANCE;
    }
}

對於一個餓漢式單例的寫法來說,它基本上是完美的了。

所以它的缺點也就只是餓漢式單例本身的缺點所在了——由於 INSTANCE 的初始化是在類加載時進行的,而類的加載是由
ClassLoader 來做的,所以開發者本來對於它初始化的時機就很難去準確把握:

  1. 可能由於初始化的太早,造成資源的浪費
  2. 如果初始化本身依賴於一些其他數據,那麼也就很難保證其他數據會在它初始化之前準備好。

當然,如果所需的單例佔用的資源很少,並且也不依賴於其他數據,那麼這種實現方式也是很好的。

知識點:什麼時候是類裝載時?

前面提到了單例在類裝載時被實例化,那究竟什麼時候纔是『類裝載時』呢?

不嚴格的說,大致有這麼幾個條件會觸發一個類被加載:

  1. new 一個對象時
  2. 使用反射創建它的實例時
  3. 子類被加載時,如果父類還沒被加載,就先加載父類
  4. jvm 啓動時執行的主類會首先被加載

類在什麼時候加載和初始化?

5. 一些其他的實現方式

5.1 Effective Java 1 —— 靜態內部類

《Effective Java》一書的第一版中推薦了一箇中寫法:

// Effective Java 第一版推薦寫法
public class Singleton {
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
    private Singleton (){}
    public static final Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

這種寫法非常巧妙:

對於內部類 SingletonHolder,它是一個餓漢式的單例實現,在SingletonHolder 初始化的時候會由 ClassLoader 來保證同步,使 INSTANCE 是一個真·單例。

同時,由於 SingletonHolder 是一個內部類,只在外部類的 Singleton 的 getInstance() 中被使用,所以它被加載的時機也就是在
getInstance() 方法第一次被調用的時候。

——它利用了 ClassLoader 來保證了同步,同時又能讓開發者控制類加載的時機。從內部看是一個餓漢式的單例,但是從外部看來,又的確是懶漢式的實現。

簡直是神乎其技。

5.2 Effective Java 2 —— 枚舉

你以爲到這就算完了?不,並沒有,因爲厲害的大神又發現了其他的方法。
《Effective Java》的作者在這本書的第二版又推薦了另外一種方法,來直接看代碼:

// Effective Java 第二版推薦寫法
public enum SingleInstance {
    INSTANCE;
    public void fun1() { 
        // do something
    }
}

// 使用
SingleInstance.INSTANCE.fun1();

看到了麼?這是一個枚舉類型……連 class 都不用了,極簡。
由於創建枚舉實例的過程是線程安全的,所以這種寫法也沒有同步的問題。

作者對這個方法的評價:

這種寫法在功能上與共有域方法相近,但是它更簡潔,無償地提供了序列化機制,絕對防止對此實例化,即使是在面對複雜的序列化或者反射攻擊的時候。雖然這中方法還沒有廣泛採用,但是單元素的枚舉類型已經成爲實現 Singleton 的最佳方法。

枚舉單例這種方法問世一些,許多分析文章都稱它是實現單例的最完美方法——寫法超級簡單,而且又能解決大部分的問題。

不過我個人認爲這種方法雖然很優秀,但是它仍然不是完美的——比如,在需要繼承的場景,它就不適用了。

6. 總結

OK,看到這裏,你還會覺得單例模式是最簡單的設計模式了麼?再回頭看一下你之前代碼中的單例實現,覺得是無懈可擊的麼?

可能我們在實際的開發中,對單例的實現並沒有那麼嚴格的要求。

比如,我如果能保證所有的 getInstance 都是在一個線程的話,那其實第一種最簡單的教科書方式就夠用了。

再比如,有時候,我的單例變成了多例也可能對程序沒什麼太大影響……

但是,如果我們能瞭解更多其中的細節,那麼如果哪天程序出了些問題,我們起碼能多一個排查問題的點。早點解決問題,就能早點回家吃飯……:-D

—— 還有,完美的方案是不存在,任何方式都會有一個『度』的問題。比如,你的覺得代碼已經無懈可擊了,但是因爲你用的是 JAVA 語言,可能 ClassLoader 有些 BUG 啊……你的代碼誰運行在 JVM 上的,可能 JVM 本身有 BUG 啊……你的代碼運行在手機上,可能手機系統有問題啊……你生活在這個宇宙裏,可能宇宙本身有些 BUG 啊……o(╯□╰)o

所以,盡力做到能做到的最好就行了。

—— 感謝你花費了不少時間看到這裏,但願你沒有覺得虛度。

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