透徹講解 單例模式

透徹講解 單例模式


單例模式

單實例Singleton設計模式可能是被討論和使用的最廣泛的一個設計模式了,這可能也是面試中問得最多的一個設計模式了。這個設計模式主要目的是想在整個系統中只能出現一個類的實例。這樣做當然是有必要的,比如你的軟件的全局配置信息,或者是一個Factory,或是一個主控類,等等。你希望這個類在整個系統中只能出現一個實例。

實現單例的核心在於private私有化類中的構造方法。

單例的反面教材(線程不安全的懶漢式)

// version 1.0

public class Singleton {

private static Singleton singleton = null;

private Singleton() { }

public static Singleton getInstance() {

if (singleton== null) {

singleton= new Singleton();

}

return singleton;

}

}

在上面的實例中,我想說明下面幾個Singleton的特點:(下面這些東西可能是盡人皆知的,沒有什麼新鮮的)

  • 私有(private)的構造函數,表明這個類是不可能形成實例了。這主要是怕這個類會有多個實例。
  • 即然這個類是不可能形成實例,那麼,我們需要一個靜態的方式讓其形成實例:getInstance()。注意這個方法是在new自己,因為其可以訪問私有的構造函數,所以他是可以保證實例被創建出來的。
  • 在getInstance()中,先做判斷是否已形成實例,如果已形成則直接返回,否則創建實例。
  • 所形成的實例保存在自己類中的私有成員中。
  • 我們取實例時,只需要使用Singleton.getInstance()就行了。

這段代碼簡單明瞭,而且使用了懶加載模式,但是卻存在致命的問題。當有多個線程並行調用 getInstance() 的時候,同時進入singleton== null判斷,就會創建多個實例。也就是說在多線程下不能正常工作。

線程安全的懶漢式

為了解決併發問題,加鎖。

public static synchronized Singleton getInstance()

{

if (instance == null) {

instance = new Singleton();

}

return instance;

}

這樣雖然可以解決併發問題,但是效率比較低。因為我們本來只需要在第一次創建的時候進行加鎖判斷,現在卻搞成了每次判斷都要加鎖。

所以進一步改進。

雙重檢查鎖 DCL

雙重檢驗鎖模式(double checked locking pattern),是一種使用同步塊加鎖的方法。程序員稱其為雙重檢查鎖,因為會有兩次檢查 instance == null,一次是在同步塊外,一次是在同步塊內。為什麼在同步塊內還要再檢驗一次?因為可能會有多個線程一起進入同步塊外的 if,如果在同步塊內不進行二次檢驗的話就會生成多個實例了。

public static Singleton getSingleton() {

if (instance == null) { //Single Checked

synchronized (Singleton.class) {

if (instance == null) { //Double Checked

instance = new Singleton();

}

}

}

return instance ;

}

這段代碼看起來很完美,很可惜,它是有問題。主要在於instance = new Singleton()這句,這並非是一個原子操作,事實上在 JVM 中這句話大概做了下面 3 件事情。

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

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

解決:我們只需要將 instance 變量聲明成 volatile 就可以了。

public class Singleton {

private volatile static Singleton instance; //聲明成 volatile

private Singleton (){}

public static Singleton getSingleton() {

if (instance == null) {

synchronized (Singleton.class) {

if (instance == null) {

instance = new Singleton();

}

}

}

return instance;

}

}

有些人認為使用 volatile 的原因是可見性,也就是可以保證線程在本地不會存有 instance 的副本,每次都是去主內存中讀取。但其實是不對的。使用 volatile 的主要原因是其另一個特性:禁止指令重排序優化。也就是說,在 volatile 變量的賦值操作後面會有一個內存屏障(生成的彙編代碼上),讀操作不會被重排序到內存屏障之前。比如上面的例子,取操作必須在執行完 1-2-3 之後或者 1-3-2 之後,不存在執行到 1-3 然後取到值的情況。從「先行發生原則」的角度理解的話,就是對於一個 volatile 變量的寫操作都先行發生於後面對這個變量的讀操作(這裡的“後面”是時間上的先後順序)。

但是特別注意在 Java 5 以前的版本使用了 volatile 的雙檢鎖還是有問題的。其原因是 Java 5 以前的 JMM (Java 內存模型)是存在缺陷的,即時將變量聲明成 volatile 也不能完全避免重排序,主要是 volatile 變量前後的代碼仍然存在重排序問題。這個 volatile 屏蔽重排序的問題在 Java 5 中才得以修復,所以在這之後才可以放心使用 volatile。

相信你不會喜歡這種複雜又隱含問題的方式,當然我們有更好的實現線程安全的單例模式的辦法。

餓漢式 static final field(我們正在用的)

這種方法非常簡單,因為單例的實例被聲明成 static 和 final 變量了,在第一次加載類到內存中時就會初始化,所以創建實例本身是線程安全的。

public class Singleton{

//類加載時就初始化

private static final Singleton instance = new Singleton();

private Singleton(){}

public static Singleton getInstance(){

return instance;

}

}

這種寫法如果完美的話,就沒必要在囉嗦那麼多雙檢鎖的問題了。缺點是它不是一種懶加載模式(lazy initialization),單例會在加載類後一開始就被初始化,即使客戶端沒有調用 getInstance()方法。餓漢式的創建方式在一些場景中將無法使用:譬如 Singleton 實例的創建是依賴參數或者配置文件的,在 getInstance() 之前必須調用某個方法設置參數給它,那樣這種單例寫法就無法使用了。

靜態內部類 static nested class

我比較傾向於使用靜態內部類的方法,這種方法也是《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;

}

}

這種寫法仍然使用JVM本身機制保證了線程安全問題;由於 SingletonHolder 是私有的,除了 getInstance() 之外沒有辦法訪問它,因此它是懶漢式的;同時讀取實例的時候不會進行同步,沒有性能缺陷;也不依賴 JDK 版本。

枚舉 Enum

實現單例的核心在於private私有化類中的構造方法,在枚舉中的構造方法必須是私有的,這就為枚舉來實現單例奠定了基礎。

有這麼幾個原因可以用來說服你使用枚舉單例:

  1. 安全性。上面的寫法貌似已經沒有問題了,但是,還是存在一點點安全風險的,因為我們可以通過反射,通過設置訪問權限,來執行私有的構造器,從而獲得更多對象,打破單例。
  2. 枚舉寫法沒有這個問題,原因見番外篇。
  3. 而且用枚舉寫單例實在太簡單了!這也是它最大的優點。下面這段代碼就是聲明枚舉實例的通常做法。
  4. public enum EasySingleton{ INSTANCE; } 我們可以通過EasySingleton.INSTANCE來訪問實例,這比調用getInstance()方法簡單多了。
  5. 創建枚舉默認就是線程安全的,所以不需要擔心double checked locking
  6. 枚舉自己處理序列化。傳統單例存在的另外一個問題是一旦你實現了序列化接口,那麼它們不再保持單例了,因為readObject()方法一直返回一個新的對象就像java的構造方法一樣,你可以通過使用readResolve()方法來避免此事發生,看下面的例子:
  7. //readResolve to prevent another instance of Singleton private Object readResolve(){ return INSTANCE; } 這樣甚至還可以更復雜,如果你的單例類維持了其他對象的狀態的話,因此你需要使他們成為transient的對象。但是枚舉單例,JVM對序列化有保證。所以可以防止反序列化導致重新創建新的對象。

總結

一般情況下直接使用餓漢式就好了,如果明確要求要懶加載(lazy initialization)會傾向於使用靜態內部類,如果涉及到反序列化創建對象時會試著使用枚舉的方式來實現單例。


分享到:


相關文章: