【設計模式自習室】透徹理解單例模式
- 2019 年 12 月 31 日
- 筆記
《設計模式自習室》系列,顧名思義,本系列文章帶你溫習常見的設計模式。主要內容有:
- 該模式的介紹,包括:
- 引子、意圖(大白話解釋)
- 類圖、時序圖(理論規範)
- 該模式的代碼示例:熟悉該模式的代碼長什麼樣子
- 該模式的優缺點:模式不是萬金油,不可以濫用模式
- 該模式的實際使用案例:了解它在哪些重要的源碼中被使用
該系列會逐步更新於我的博客和公眾號(博客見文章底部)
也希望各位觀眾老爺能夠關注我的個人公眾號:後端技術漫談,不會錯過精彩好看的文章。
系列文章回顧
創建型——單例模式
引子
《HEAD FIRST設計模式》中「單例模式」又稱為「單件模式」
對於系統中的某些類來說,只有一個實例很重要。比如大家熟悉的Spring框架中,Controller和Service都默認是單例模式。
如果用生活中的例子舉例,一個系統中可以存在多個打印任務,但是只能有一個正在工作的任務;一個系統只能有一個窗口管理器或文件系統;一個系統只能有一個計時工具或ID(序號)生成器。
如何保證一個類只有一個實例並且這個實例易於被訪問呢?
答:定義一個全局變量可以確保對象隨時都可以被訪問,但不能防止我們實例化多個對象。一個更好的解決辦法是讓類自身負責保存它的唯一實例。這個類可以保證沒有其他實例被創建,並且它可以提供一個訪問該實例的方法。這就是單例模式的模式動機。
意圖
確保一個類只有一個實例,並提供該實例的全局訪問點。
單例模式的要點有三個:
- 一是某個類只能有一個實例;
- 二是它必須自行創建這個實例;
- 三是它必須自行向整個系統提供這個實例。
使用一個私有構造函數、一個私有靜態變量以及一個公有靜態函數來實現。
私有構造函數保證了不能通過構造函數來創建對象實例,只能通過公有靜態函數返回唯一的私有靜態變量。
類圖
如果看不懂UML類圖,可以先粗略瀏覽下該圖,想深入了解的話,可以繼續谷歌,深入學習:

單例模式的類圖:

時序圖
時序圖(Sequence Diagram)是顯示對象之間交互的圖,這些對象是按時間順序排列的。時序圖中顯示的是參與交互的對象及其對象之間消息交互的順序。
我們可以大致瀏覽下時序圖,如果感興趣的小夥伴可以去深究一下:

實現
單例模式有非常多的實現方式,這裡我們從最差的實現方式逐漸過渡到優雅的實現方式(劍指offer的方式),包括:
- 懶漢式-線程不安全
- 餓漢式-線程安全
- 懶漢式-線程安全
- 懶漢式(延遲實例化)—— 線程安全/雙重校驗 (重要,牢記)
- 靜態內部類實現
- 枚舉實現 (重要,牢記)
每個方式也會詳細解釋下面試可能會問到的問題,方便小夥伴複習。
1. 懶漢式-線程不安全
以下實現中,私有靜態變量 uniqueInstance 被延遲實例化,這樣做的好處是,如果沒有用到該類,那麼就不會實例化 uniqueInstance,從而節約資源。
這個實現在多線程環境下是不安全的,如果多個線程能夠同時進入 if (uniqueInstance == null) ,並且此時 uniqueInstance 為 null,那麼會有多個線程執行 uniqueInstance = new Singleton(); 語句,這將導致實例化多次 uniqueInstance。
public class Singleton { private static Singleton uniqueInstance; private Singleton() { } public static Singleton getUniqueInstance() { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } return uniqueInstance; } }
2. 餓漢式-線程安全
如此一來,只會實例化一次,作為靜態變量
private static Singleton uniqueInstance = new Singleton();
3. 懶漢式(延遲實例化)—— 線程安全
只需要對 getUniqueInstance() 方法加鎖,那麼在一個時間點只能有一個線程能夠進入該方法,從而避免了實例化多次 uniqueInstance。
但是當一個線程進入該方法之後,其它試圖進入該方法的線程都必須等待,即使 uniqueInstance 已經被實例化了。這會讓線程阻塞時間過長,因此該方法有性能問題,不推薦使用。
public static synchronized Singleton getUniqueInstance() { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } return uniqueInstance; }
4. 懶漢式(延遲實例化)—— 線程安全/雙重校驗
一.私有化構造函數
二.聲明靜態單例對象
三.構造單例對象之前要加鎖(lock一個靜態的object對象)或者方法上加synchronized。
四.需要兩次檢測單例實例是否已經被構造,分別在鎖之前和鎖之後
使用lock(obj)
public class Singleton { private Singleton() {} //關鍵點0:構造函數是私有的 private volatile static Singleton single; //關鍵點1:聲明單例對象是靜態的 private static object obj= new object(); public static Singleton GetInstance() //通過靜態方法來構造對象 { if (single == null) //關鍵點2:判斷單例對象是否已經被構造 { lock(obj) //關鍵點3:加線程鎖 { if(single == null) //關鍵點4:二次判斷單例是否已經被構造 { single = new Singleton(); } } } return single; } }
使用synchronized (Singleton.class)
public class Singleton { private Singleton() {} private volatile static Singleton uniqueInstance; public static Singleton getUniqueInstance() { if (uniqueInstance == null) { synchronized (Singleton.class) { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } } } return uniqueInstance; } }
面試時可能的提問
0.為何要檢測兩次?
答:如果兩個線程同時執行 if 語句,那麼兩個線程就會同時進入 if 語句塊內。雖然在if語句塊內有加鎖操作,但是兩個線程都會執行 uniqueInstance = new Singleton(); 這條語句,只是先後的問題,也就是說會進行兩次實例化,從而產生了兩個實例。因此必須使用雙重校驗鎖,也就是需要使用兩個 if 語句。
1.構造函數能否公有化?
答:不行,單例類的構造函數必須私有化,單例類不能被實例化,單例實例只能靜態調用。
2.lock住的對象為什麼要是object對象,可以是int嗎?
答:不行,鎖住的必須是個引用類型。如果鎖值類型,每個不同的線程在聲明的時候值類型變量的地址都不一樣,那麼上個線程鎖住的東西下個線程進來會認為根本沒鎖。
3.uniqueInstance 採用 volatile 關鍵字修飾
uniqueInstance = new Singleton(); 這段代碼其實是分為三步執行。
分配內存空間 初始化對象 將 uniqueInstance 指向分配的內存地址
但是由於 JVM 具有指令重排的特性,有可能執行順序變為了 1–>3–>2
public class Singleton { private volatile static Singleton uniqueInstance; private Singleton(){} public static Singleton getInstance(){ if(uniqueInstance == null){ // B線程檢測到uniqueInstance不為空 synchronized(Singleton.class){ if(uniqueInstance == null){ uniqueInstance = new Singleton(); // A線程被指令重排了,剛好先賦值了;但還沒執行完構造函數。 } } } return uniqueInstance;// 後面B線程執行時將引發:對象尚未初始化錯誤。 } }
所以B線程檢測到不為null後,直接出去調用該單例,而A還沒有運行完構造函數,導致該單例還沒創建完畢,B調用會報錯!所以必須用volatile防止JVM重排指令
5. 靜態內部類實現
當 Singleton 類加載時,靜態內部類 SingletonHolder 沒有被加載進內存。只有當調用 getUniqueInstance()
方法從而觸發 SingletonHolder.INSTANCE
時 SingletonHolder 才會被加載,此時初始化 INSTANCE 實例。
這種方式不僅具有延遲初始化的好處,而且由虛擬機提供了對線程安全的支持。
public class Singleton { private Singleton() {} private static class SingletonHolder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getUniqueInstance() { return SingletonHolder.INSTANCE; } }
6. 枚舉實現
這是單例模式的最佳實踐,它實現簡單,並且在面對複雜的序列化或者反射攻擊的時候,能夠防止實例化多次。
public enum Singleton { INSTANCE; private String objName; public String getObjName() { return objName; } public void setObjName(String objName) { this.objName = objName; } public static void main(String[] args) { // 單例測試 Singleton firstSingleton = Singleton.INSTANCE; firstSingleton.setObjName("firstName"); System.out.println(firstSingleton.getObjName()); Singleton secondSingleton = Singleton.INSTANCE; secondSingleton.setObjName("secondName"); System.out.println(firstSingleton.getObjName()); System.out.println(secondSingleton.getObjName()); // 反射獲取實例測試 try { Singleton[] enumConstants = Singleton.class.getEnumConstants(); for (Singleton enumConstant : enumConstants) { System.out.println(enumConstant.getObjName()); } } catch (Exception e) { e.printStackTrace(); } } }
為什麼枚舉是單例模式的最好方式?
考慮以下單例模式的實現,該 Singleton 在每次序列化的時候都會創建一個新的實例,為了保證只創建一個實例,必須聲明所有字段都是 transient,並且提供一個 readResolve() 方法。
public class Singleton implements Serializable { private static Singleton uniqueInstance; private Singleton() { } public static synchronized Singleton getUniqueInstance() { if (uniqueInstance == null) { uniqueInstance = new Singleton(); } return uniqueInstance; } }
如果不使用枚舉來實現單例模式,會出現反射攻擊,因為通過反射的setAccessible() 方法可以將私有構造函數的訪問級別設置為 public,然後調用構造函數從而實例化對象。
枚舉實現是由 JVM 保證只會實例化一次,因此不會出現上述的反射攻擊。
從上面的討論可以看出,解決序列化和反射攻擊很麻煩,而枚舉實現不會出現這兩種問題,所以說枚舉實現單例模式是最佳實踐。
使用場景舉例
- Logger類,全局唯一,保證你能在每個類里調用為一個Logger輸出日誌
- Spring:Spring里很多類都是單例的,也是你理解單例最合適的地方,比如Controller和Service類,默認都是單例的。
- 數據庫連接池對象:你從代碼的任何地方都需要拿到連接池裡的資源。
參考
- http://blog.jobbole.com/109449/
- https://github.com/CyC2018/CS-Notes/blob/master/notes/%E8%AE%BE%E8%AE%A1%E6%A8%A1%E5%BC%8F%20%20-%20%E5%8D%95%E4%BE%8B.md
- 《HEAD FIRST 設計模式》
- 《劍指offer》