單例(Singleton)可以說是最簡單的設計模式之一,而且基本上哪怕你沒特別瞭解過,也能夠隨手寫出,但是單例真有這麼簡單嗎?
單例物件的類必須保證只有一個範例存在,自行提供這個範例,並向整個系統提供這個範例。
上述定義總結以下特點大致有3點:
- 單例類只有一個範例物件;
- 該單例物件必須由單例類自行建立;
- 單例類對外提供一個存取該單例的全域性存取點。
單例模式的核心精髓其實是** 避免建立不必要的物件 **
**不必要的物件 **一般是:
- 頻繁建立的一些類,又頻繁被銷燬
- 「昂貴的物件」,有些物件建立的成本比其他物件要高得多,比如佔用資源較多,或範例化耗時較長
- 系統要求單一控制邏輯的操作,或者物件需要被共用的情況
- ......
常見的使用場合:資料庫的連線池、Spring中的全域性存取點BeanFactory,Spring下的Bean、多執行緒的執行緒池、網路連線池等等
單例模式的優點:
不僅可以減少每次建立物件的時間開銷,還可以節約記憶體空間;
能夠避免由於操作多個範例導致的邏輯錯誤;
如果一個物件有可能貫穿整個應用程式,能夠起到了全域性統一管理控制的作用。
缺點:
單例模式一般沒有介面,沒有抽象層,擴充套件困難。如果要擴充套件,得修改原來的程式碼
單例模式的功能程式碼通常寫在一個類中,其職責過重,如果功能設計不合理,則很容易違背單一職責原則
不適用於變化的物件,如果同一型別的物件總是要在不同的用例場景發生變化,單例就會引起資料的錯誤,不能儲存彼此的狀態。比如單例模式下去將物件轉成json 會出現互相參照的問題 。
對單例的實現一般可以分為兩大類——懶漢式和餓漢式
他們的區別在於:
懶漢式:全域性的單例範例,預設不會範例化,直到首次使用時才範例化
,通俗點講"一個懶漢, 不願意動彈。等到飯點了,他才開始想辦法搞食物"
餓漢式: 全域性的單例範例在類裝載時就範例化,並且建立單例物件。通俗點講"一個餓漢,很勤快就怕自己餓著。總是先把食物準備好,等啥時候到飯點了,他隨時拿來吃"
我們首先來寫一個最簡單的懶漢實現單例的方式:
/**
* 懶漢 - 最簡單的版本
*/
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,然後就會導致 建立多個同樣的範例的問題。那怎麼解決這種問題呢?
其實遇到上面的問題,我們很容易想到一個解決方案加鎖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()方法,當一個執行緒獲得鎖之後,進行 判空、物件建立、獲得返回值
的操作,其他的執行緒必須等待其完成,才能繼續執行
這樣加鎖之後懶漢模式雖然解決了執行緒並行問題(執行緒安全的),但由於把鎖加到方法上後,所有的存取都因需要鎖佔用導致資源的浪費,這其實非常影響程式的效能,效率很低。那我們可以怎樣優化呢?
/**
* 懶漢 - 雙層校驗鎖
*/
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()時先判斷單例物件是否已經初始化,如果已經初始化,就直接返回單例物件,如果未初始化,就在同步程式碼塊中先進行初始化,然後返回,效率很高。
此處 解決了懶漢式單例 -- synchronized 版
的缺陷,不會影響到其他執行緒的getInstance()方法。
判空(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卻沒有的問題
這種問題我們如何解決呢?
不過好在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指令字首功能如下:
記憶體屏障(Memory Barrier)這裡就不展開說了,再說文章越寫越多了,我們這裡只要知道:
它的幾個作用:
instance宣告為volatile之後,告訴JVM編譯器不允許指令重排優化,告訴CPU不允許亂序執行。這樣就保證new 物件等等過程中,一個寫操作完成之前,不會呼叫讀操作。這樣避免了上面範例3中的說到的問題。
這樣懶漢單例 就比較完美了,即保證了效率也是執行緒安全的。
本文到現在一直介紹懶漢實現單例,我們來看下餓漢是怎麼實現單例的
/**
* 餓漢
*/
public class SingleHungry {
private static SingleHungry instance = new SingleHungry();
private SingleHungry() {
}
public static SingleHungry getInstance() {
return instance;
}
}
這是 餓漢實現單例的標準寫法,沒啥大問題,執行緒安全的,執行效率高
缺點:類載入時instance就初始化了,造成資源的浪費;開發者無法手動控制類範例化的時機
介紹一下 《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_();
我們來看下這種實現方法的巧妙之處:
使用類的靜態內部類實現的單例模式,既保證了執行緒安全有保證了懶載入,同時不會因為加鎖的方式耗費效能。
推薦這種實現方法
最後再介紹一個《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
很感謝你能看到最後,如果喜歡的話,歡迎關注點贊收藏轉發,謝謝!更多精彩的文章