沒那麼簡單的單例模式

2022-07-14 12:00:15

單例(Singleton)可以說是最簡單的設計模式之一,而且基本上哪怕你沒特別瞭解過,也能夠隨手寫出,但是單例真有這麼簡單嗎?

什麼是單例

單例物件的類必須保證只有一個範例存在,自行提供這個範例,並向整個系統提供這個範例
上述定義總結以下特點大致有3點:

  1. 單例類只有一個範例物件;
  2. 該單例物件必須由單例類自行建立;
  3. 單例類對外提供一個存取該單例的全域性存取點。

單例的應用場景

單例模式的核心精髓其實是** 避免建立不必要的物件 **
**不必要的物件 **一般是:

  1. 頻繁建立的一些類,又頻繁被銷燬
  2. 「昂貴的物件」,有些物件建立的成本比其他物件要高得多,比如佔用資源較多,或範例化耗時較長
  3. 系統要求單一控制邏輯的操作,或者物件需要被共用的情況
  4. ......

常見的使用場合:資料庫的連線池、Spring中的全域性存取點BeanFactory,Spring下的Bean、多執行緒的執行緒池、網路連線池等等
單例模式的優點:

不僅可以減少每次建立物件的時間開銷,還可以節約記憶體空間;
能夠避免由於操作多個範例導致的邏輯錯誤;
如果一個物件有可能貫穿整個應用程式,能夠起到了全域性統一管理控制的作用。

缺點:

單例模式一般沒有介面,沒有抽象層,擴充套件困難。如果要擴充套件,得修改原來的程式碼
單例模式的功能程式碼通常寫在一個類中,其職責過重,如果功能設計不合理,則很容易違背單一職責原則
不適用於變化的物件,如果同一型別的物件總是要在不同的用例場景發生變化,單例就會引起資料的錯誤,不能儲存彼此的狀態。比如單例模式下去將物件轉成json 會出現互相參照的問題 。

單例的實現方式

對單例的實現一般可以分為兩大類——懶漢式和餓漢式
他們的區別在於:
懶漢式:全域性的單例範例,預設不會範例化,直到首次使用時才範例化,通俗點講"一個懶漢, 不願意動彈。等到飯點了,他才開始想辦法搞食物"

餓漢式: 全域性的單例範例在類裝載時就範例化,並且建立單例物件。通俗點講"一個餓漢,很勤快就怕自己餓著。總是先把食物準備好,等啥時候到飯點了,他隨時拿來吃"

1. 懶漢式單例--簡單版本

我們首先來寫一個最簡單的懶漢實現單例的方式:

/**
 * 懶漢 - 最簡單的版本
 */
public class SingletonEasy  {
    private static SingletonEasy instance;

    private SingletonEasy() {}//將構造器 私有化,防止外部呼叫

    public static SingletonEasy getInstance() {
        if (instance == null) {
            instance = new SingletonEasy();
        }
        return instance;
    }
}

使用方式:SingletonEasy singletonEasy = SingletonEasy._getInstance_();
SingletonEasy 的instance 預設為空,直到程式獲取instance時,先進行判斷instance 是否為空,如果instance 為空就new一個,反之直接返回已存在的instance
我們以這種方式實現的單例是執行緒不安全的,在大部分情況下是沒問題的,但是當突然有一天有多個存取者(執行緒)同時去獲取物件範例時,

if (instance == null) {
    instance = new SingletonEasy();
}

他們發現都不存在instance,然後就會導致 建立多個同樣的範例的問題。那怎麼解決這種問題呢?

2. 懶漢式單例 -- synchronized 版

其實遇到上面的問題,我們很容易想到一個解決方案加鎖synchronized

/**
 * 懶漢 - 加鎖synchronized
 */
public class SingleSyn {
    private static SingleSyn instance;

    private SingleSyn() {//將構造器 私有化,防止外部呼叫
    }

    public static synchronized SingleSyn getInstance(){
        if (instance == null) {
            instance = new SingleSyn();
        }
        return instance;
    }
}

加鎖之後,如果有多個存取者(執行緒)存取getInstance()方法,當一個執行緒獲得鎖之後,進行 判空、物件建立、獲得返回值的操作,其他的執行緒必須等待其完成,才能繼續執行
這樣加鎖之後懶漢模式雖然解決了執行緒並行問題(執行緒安全的),但由於把鎖加到方法上後,所有的存取都因需要鎖佔用導致資源的浪費,這其實非常影響程式的效能,效率很低。那我們可以怎樣優化呢?

3. 懶漢式單例 -- 雙重校驗鎖 synchronized版

/**
 * 懶漢 - 雙層校驗鎖
 */
public class SingleDoubleCheck {
    private static SingleDoubleCheck instance = null;

    private SingleDoubleCheck(){}//將構造器 私有化,防止外部呼叫

    public static SingleDoubleCheck getInstance() {
        if (instance == null) { //part 1
            synchronized (SingleDoubleCheck.class) {
                if (instance == null) { //part 2
                    instance = new SingleDoubleCheck();//part 3
                }
            }
        }
        return instance;
    }
}

我們來仔細看下它的妙處:在多執行緒的環境下,當一個執行緒執行getInstance()時先判斷單例物件是否已經初始化,如果已經初始化,就直接返回單例物件,如果未初始化,就在同步程式碼塊中先進行初始化,然後返回,效率很高。

  1. 在多執行緒的環境下,當一個執行緒執行getInstance()時
  2. 程式到達part 1處的 if (instance == null) 先判斷單例物件是否已經初始化,如果已經初始化,就直接返回單例物件,如果未初始化,則進入後續同步塊邏輯;

此處 解決了懶漢式單例 -- synchronized 版的缺陷,不會影響到其他執行緒的getInstance()方法。

  1. 程式進入同步塊, 當一個執行緒獲得鎖之後,進行 判空(part2處的instance == null)、物件建立、獲得返回值的操作,其他的執行緒必須等待其完成,才能繼續執行。

此處實現了懶漢式單例 -- synchronized 版的功能,保證了執行緒安全。
這種寫法,理論上既執行緒安全又效率高,可惜事實並非如此。
問題出現在了 part 3處 instance = new SingleDoubleCheck();我們來看下整個類的位元組碼(JVM指令集):

$ javap -c SingleDoubleCheck.class
    
Compiled from "SingleDoubleCheck.java"
public class com.zj.ideaprojects.test.SingleDoubleCheck {
  public static com.zj.ideaprojects.test.SingleDoubleCheck getInstance();
    Code:
       0: getstatic     #2                  // Field instance:Lcom/zj/ideaprojects/test/SingleDoubleCheck;
       3: ifnonnull     37
       6: ldc           #3                  // class com/zj/ideaprojects/test/SingleDoubleCheck
       8: dup
       9: astore_0
      10: monitorenter
      11: getstatic     #2                  // Field instance:Lcom/zj/ideaprojects/test/SingleDoubleCheck;
      14: ifnonnull     27
      17: new           #3                  // class com/zj/ideaprojects/test/SingleDoubleCheck
      20: dup
      21: invokespecial #4                  // Method "<init>":()V
      24: putstatic     #2                  // Field instance:Lcom/zj/ideaprojects/test/SingleDoubleCheck;
      27: aload_0
      28: monitorexit
      29: goto          37
      32: astore_1
      33: aload_0
      34: monitorexit
      35: aload_1
      36: athrow
      37: getstatic     #2                  // Field instance:Lcom/zj/ideaprojects/test/SingleDoubleCheck;
      40: areturn
    Exception table:
       from    to  target type
          11    29    32   any
          32    35    32   any

  static {};
    Code:
       0: aconst_null
       1: putstatic     #2                  // Field instance:Lcom/zj/ideaprojects/test/SingleDoubleCheck;
       4: return
}

內容比較多,我們直接看instance = new SingleDoubleCheck()相關的部分,
可以發現在JVM位元組碼中instance = new SingleDoubleCheck() 是有4個操作的

11: getstatic     #2                  //獲取指定類的靜態域instance 索引#2,並將其值壓入棧頂
14: ifnonnull     27                  //不為空
    
17: new           #3                  //1. 建立物件SingleDoubleCheck,並將物件參照壓入棧
20: dup                               //2. 將運算元棧頂的資料複製一份,並將其壓入棧,此時棧中有兩個參照值
21: invokespecial #4                  //3. pop出棧參照值,呼叫SingleDoubleCheck其建構函式,完成物件的初始化
24: putstatic     #2                  //4. SingleDoubleCheck物件指向指定類的靜態域instance 索引#2

new指令並不能完全建立一個物件,物件只有在呼叫初始化方法完成後(即呼叫了invokespecial指令之後),物件才建立成功。
所以instance = new SingleDoubleCheck()並非一個原子操作(atomic)

原子操作就是不可分割的操作,在計算機中,就是指不會因為執行緒排程被打斷的操作。

而在我們現代的計算機中CPU是亂序執行。CPU的速度是超級快的,但同時其價格也是非常昂貴的。為了"充分"壓榨CPU, 我們要把CPU的時間進行分片,讓各個程式在CPU上輪轉,造成一種多個程式同時在執行的假象,即並行

並行是針對單核 CPU 提出的,而並行則是針對多核 CPU 提出的。和單核 CPU 不同,多核 CPU 真正實現了「同時執行多個任務」

在CPU中為了能夠讓指令的執行儘可能地同時執行起來,採用了指令流水線。一個 CPU 指令的執行過程可以分成 4 個階段:取指、譯碼、執行、寫回。這 4 個階段分別由 4 個獨立物理執行單元來完成。理想的情況是:指令之間無依賴,可以使流水線的並行度最大化
但是如果兩條指令的前後存在依賴關係,比如資料依賴,控制依賴等,此時後一條語句就必需等到前一條指令完成後,才能開始。所以CPU為了提高流水線的執行效率,對無依賴的前後指令做適當的亂序和排程

接著上面的內容, 在生成位元組碼後,JVM 的編譯器同樣也會對其指令進行重排序的優化(指令重排)。

所謂指令重排是指在不改變原語意的情況下,通過調整指令的執行順序讓程式執行的更快。JVM中並沒有規定編譯器優化相關的內容,也就是說JVM可以自由的進行指令重排序的優化。

無論是編譯期的指令重排還是** CPU 的亂序執行**,主要都是為了讓 CPU 內部的指令流水線可以「填滿」,提高指令執行的並行度。

指令重排對於非原子性的操作,在不影響最終結果的情況下,其拆分成的原子操作可能會被重新排列執行順序。instance = new SingleDoubleCheck()的操作1234可能變成1243。這樣會存在一個instance已經不為null但是SingleDoubleCheck仍沒有完成初始化的狀態這個時候其他的執行緒過來,走到part 1 if (instance == null)處時會產生:明明instance不為空,但是SingleDoubleCheck卻沒有的問題
這種問題我們如何解決呢?

4. 懶漢式單例 -- 雙重校驗鎖 volatile版

不過好在JDK1.5及之後版本增加了volatile關鍵字。volatile保證該變數對所有執行緒的可見性,還有一個語意是禁止指令重排序優化,這樣可以保證instance變數被賦值的時候物件已經是初始化完成的,從而避免了上面說到的問題。

/**
 * 懶漢 - 雙層校驗鎖2
 */
public class SingleVolatile {
    private static volatile SingleVolatile instance;// 加上volatile關鍵字

    private SingleVolatile() {}//將構造器 私有化,防止外部呼叫

    public static SingleVolatile getInstance() {
        if (instance == null) {
            synchronized (SingleVolatile.class) {
                if (instance == null) {
                    instance = new SingleVolatile();
                }
            }
        }
        return instance;
    }
}

我們檢視一下 這個檔案的位元組碼:

$ javap -c SingleVolatile.class
Compiled from "SingleVolatile.java"
public class test.SingleVolatile {
  public static test.SingleVolatile getInstance();
    Code:
       0: getstatic     #2                  // Field instance:Ltest/SingleVolatile;
       3: ifnonnull     37
       6: ldc           #3                  // class test/SingleVolatile
       8: dup
       9: astore_0
      10: monitorenter
      11: getstatic     #2                  // Field instance:Ltest/SingleVolatile;
      14: ifnonnull     27
      17: new           #3                  // class test/SingleVolatile
      20: dup
      21: invokespecial #4                  // Method "<init>":()V
      24: putstatic     #2                  // Field instance:Ltest/SingleVolatile;
      27: aload_0
      28: monitorexit
      29: goto          37
      32: astore_1
      33: aload_0
      34: monitorexit
      35: aload_1
      36: athrow
      37: getstatic     #2                  // Field instance:Ltest/SingleVolatile;
      40: areturn
    Exception table:
       from    to  target type
          11    29    32   any
          32    35    32   any

  public static void main(java.lang.String[]);
    Code:
       0: invokestatic  #5                  // Method getInstance:()Ltest/SingleVolatile;
       3: pop
       4: return
}

可以看出和SingleDoubleCheck.class的位元組碼基本一模一樣,看不出啥區別

那我們繼續對SingleVolatile.class檔案反組合一下:

-server
-Xcomp
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-XX:CompileCommand=compileonly,*SingleVolatile.getInstance
VM引數我貼了一下,大家感興趣可以去試試

...
0x000001cdb13c7313: mov     dword ptr [r11+68h],r10d
  0x000001cdb13c7317: mov     r10,76bf9bc68h    ;   {oop(a 'java/lang/Class' = 'test/SingleVolatile')}
  0x000001cdb13c7321: shr     r10,9h
  0x000001cdb13c7325: mov     r11,1cdbd065000h
  0x000001cdb13c732f: mov     byte ptr [r11+r10],r12l
  0x000001cdb13c7333: lock add dword ptr [rsp],0h  ;*putstatic instance
                                                ; - test.SingleVolatile::getInstance@24 (line 13)

  0x000001cdb13c7338: jmp     1cdb13c71e4h
  0x000001cdb13c733d: mov     rdx,7c0060828h    ;   {metadata('test/SingleVolatile')}
  ...

組合程式碼比較長,省略了很多,根據'putstatic'
我們定位到第7行 0x000001cdb13c7333: lock add dword ptr [rsp],0h ;*putstatic instance

我們再對SingleDoubleCheck.class 反組合一下:
VM引數:

-server
-Xcomp
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintAssembly
-XX:CompileCommand=compileonly,*SingleDoubleCheck.getInstance

它的組合程式碼,我們根據'putstatic'同樣擷取一段:

...
  0x00000209690592e4: mov     rax,76bf9bd90h    ;   {oop(a 'java/lang/Class' = 'test/SingleDoubleCheck')}
  0x00000209690592ee: mov     rsi,qword ptr [rsp+20h]
  0x00000209690592f3: mov     r10,rsi
  0x00000209690592f6: shr     r10,3h
  0x00000209690592fa: mov     dword ptr [rax+68h],r10d
  0x00000209690592fe: shr     rax,9h
  0x0000020969059302: mov     rsi,20974cf5000h
  0x000002096905930c: mov     byte ptr [rax+rsi],0h  ;*putstatic instance
                                                ; - test.SingleDoubleCheck::getInstance@24 (line 18)

  0x0000020969059310: mov     rax,76bf9bd90h    ;   {oop(a 'java/lang/Class' = 'test/SingleDoubleCheck')}
  0x000002096905931a: lea     rax,[rsp+28h]
  0x000002096905931f: mov     rdi,qword ptr [rax+8h]
  ...

我們發現第9行 0x000002096905930c: mov byte ptr [rax+rsi],0h ;*putstatic instance
這個時候我們發現了區別 ,加了 "Volatile"關鍵字後,組合程式碼中 多了一個lock,其他的都是正常賦值的組合語句
我們知道在組合中 LOCK指令字首功能如下:

  • 被修飾的組合指令成為「原子的」
  • 與被修飾的組合指令一起提供記憶體屏障效果(LOCK指令可不是記憶體屏障,不能畫等號哦)

記憶體屏障(Memory Barrier)這裡就不展開說了,再說文章越寫越多了,我們這裡只要知道:
它的幾個作用:

  1. 確保一些特定操作執行的順序,讓cpu必須按照順序執行指令
  2. 另一個作用是強制更新一次不同CPU的快取,保證任何試圖讀取該資料的執行緒將得到會是最新值

instance宣告為volatile之後,告訴JVM編譯器不允許指令重排優化,告訴CPU不允許亂序執行。這樣就保證new 物件等等過程中,一個寫操作完成之前,不會呼叫讀操作。這樣避免了上面範例3中的說到的問題。

這樣懶漢單例 就比較完美了,即保證了效率也是執行緒安全的。

5. 餓漢式單例

本文到現在一直介紹懶漢實現單例,我們來看下餓漢是怎麼實現單例的

/**
 * 餓漢
 */
public class SingleHungry {
    private static SingleHungry instance = new SingleHungry();

    private SingleHungry() {
    }

    public static SingleHungry getInstance() {
        return instance;
    }
}

這是 餓漢實現單例的標準寫法,沒啥大問題,執行緒安全的,執行效率高
缺點:類載入時instance就初始化了,造成資源的浪費;開發者無法手動控制類範例化的時機

6. 懶漢式單例--靜態工廠版

介紹一下 《Effective Java》第3版 給出的方法:

/**
 * 單例 -靜態工廠 
 */
public class SingleStatic {
    private static class SingletonHolder{
        public static SingleStatic instance = new SingleStatic();
    }
    private SingleStatic(){}
    
    public static SingleStatic newInstance(){
        return SingletonHolder.instance;
    }
}

使用方式: SingleStatic singleStatic = SingleStatic._newInstance_();
我們來看下這種實現方法的巧妙之處:

  • 從內部來看 對於靜態內部類SingletonHolder,它是一個餓漢式的單例實現,在SingletonHolder初始化的時候會由ClassLoader來保證同步,使INSTANCE是一個單例。
  • 同時,由於SingletonHolder是一個內部類,只在外部類的Singleton的getInstance()中被使用,所以它被載入的時機也就是在getInstance()方法第一次被呼叫的時候。從外部看來,又的確是懶漢式的實現

使用類的靜態內部類實現的單例模式,既保證了執行緒安全有保證了懶載入,同時不會因為加鎖的方式耗費效能。
推薦這種實現方法

7. 列舉 實現單例

最後再介紹一個《Effective Java》第3版推薦的寫法

public enum SingleInstance {
    INSTANCE;
    public void funDo() {
          System.out.println("doSomething");
    }
}

使用方式:SingleInstance.INSTANCE.funDo()
這種方法充分 利用列舉的特性,讓JVM來幫我們保證執行緒安全和單一範例的問題。除此之外,寫法極其簡潔。
分外優雅!

尾語

雖然本文核心通篇是:單例可以 避免建立不必要的物件,減少每次建立物件的時間開銷,還可以節約記憶體空間
這樣可能會讓一些人誤以為: 「JAVA建立物件的代價非常昂貴, 應該要 儘可能地避免建立物件」
事實恰恰相反,由於小物件的構造器只做很少量的顯式工作,所以小物件 的建立和回收動作是非常廉價的,特別是在現代的 JVM 實現上更是如此 通過建立附加的 物件,提升程式的清晰性、簡潔性和功能性,所以通常是件好事 。

單例模式真的是最簡單的設計模式嗎?當我們去看其位元組碼、組合是如何實現的原理時,往往發現其中細節無數充滿前人的智慧結晶,平時我們日常學習中不能過於功利只盯著面試題去刷,也要深入底層去挖掘實現的細節和設計原理。感謝您看到最後。

參考資料:

《深入理解計算機系統》
《Effective Java》
《Java虛擬機器器規範》
《組合語言》王爽
https://www.cnblogs.com/xrq730/p/7048693.html
https://www.cnblogs.com/Mainz/p/3556430.html
https://coolshell.cn/articles/265.html
https://zhuanlan.zhihu.com/p/413889872


很感謝你能看到最後,如果喜歡的話,歡迎關注點贊收藏轉發,謝謝!更多精彩的文章