【設計模式自習室】透徹理解單例模式

  • 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》