目錄
- 什么是單例
- 單例的應用場景
- 單例的實作方式
- 1. 懶漢式單例--簡單版本
- 2. 懶漢式單例 -- synchronized 版
- 3. 懶漢式單例 -- 雙重校驗鎖 synchronized版
- 4. 懶漢式單例 -- 雙重校驗鎖 volatile版
- 5. 餓漢式單例
- 6. 懶漢式單例--靜態工廠版
- 7. 列舉 實作單例
- 尾語
單例(Singleton)可以說是最簡單的設計模式之一,而且基本上哪怕你沒特別了解過,也能夠隨手寫出,但是單例真有這么簡單嗎?
什么是單例
單例物件的類必須保證只有一個實體存在,自行提供這個實體,并向整個系統提供這個實體,
上述定義總結以下特點大致有3點:
- 單例類只有一個實體物件;
- 該單例物件必須由單例類自行創建;
- 單例類對外提供一個訪問該單例的全域訪問點,
單例的應用場景
單例模式的核心精髓其實是** 避免創建不必要的物件 **
**不必要的物件 **一般是:
- 頻繁創建的一些類,又頻繁被銷毀
- “昂貴的物件”,有些物件創建的成本比其他物件要高得多,比如占用資源較多,或實體化耗時較長
- 系統要求單一控制邏輯的操作,或者物件需要被共享的情況
- ......
常見的使用場合:資料庫的連接池、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()時先判斷單例物件是否已經初始化,如果已經初始化,就直接回傳單例物件,如果未初始化,就在同步代碼塊中先進行初始化,然后回傳,效率很高,
- 在多執行緒的環境下,當一個執行緒執行getInstance()時
- 程式到達part 1處的 if (instance == null) 先判斷單例物件是否已經初始化,如果已經初始化,就直接回傳單例物件,如果未初始化,則進入后續同步塊邏輯;
此處 解決了懶漢式單例 -- 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卻沒有的問題
這種問題我們如何解決呢?
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)這里就不展開說了,再說文章越寫越多了,我們這里只要知道:
它的幾個作用:
- 確保一些特定操作執行的順序,讓cpu必須按照順序執行指令
- 另一個作用是強制更新一次不同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
很感謝你能看到最后,如果喜歡的話,歡迎關注點贊收藏轉發,謝謝!更多精彩的文章

轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/499250.html
標籤:其他
