Hi,我們再來聊一聊Java的單例吧
1. 前言
單例(Singleton)
應該是開發者們最熟悉的設計模式了,並且好像也是最容易實現的——基本上每個開發者都能夠隨手寫出——但是,真的是這樣嗎?
作為一個Java開發者,也許你覺得自己對單例模式的瞭解已經足夠多了。我並不想危言聳聽說一定還有你不知道的——畢竟我自己的瞭解也的確有限,但究竟你自己瞭解的程度到底怎樣呢?往下看,我們一起來聊聊看~
2. 什麼是單例?
單例物件的類必須保證只有一個例項存在
——這是維基百科上對單例的定義,這也可以作為對意圖實現單例模式的程式碼進行檢驗的標準。
對單例的實現可以分為兩大類——懶漢式
和餓漢式
,他們的區別在於:
懶漢式
:指全域性的單例例項在第一次被使用時構建。餓漢式
:指全域性的單例例項在類裝載時構建。
從它們的區別也能看出來,日常我們使用的較多的應該是懶漢式
的單例,畢竟按需載入才能做到資源的最大化利用嘛。
3. 懶漢式單例
先來看一下懶漢式單例的實現方式。
3.1 簡單版本
看最簡單的寫法Version 1:
// Version 1
public class Single1 {
private static Single1 instance;
public static Single1 getInstance() {
if (instance == null) {
instance = new Single1();
}
return instance;
}
}
或者再進一步,把構造器改為私有的,這樣能夠防止被外部的類呼叫。
// Version 1.1
public class Single1 {
private static Single1 instance;
private Single1() {}
public static Single1 getInstance() {
if (instance == null) {
instance = new Single1();
}
return instance;
}
}
我彷彿記得當初學校的教科書就是這麼教的?—— 每次獲取instance之前先進行判斷,如果instance為空就new一個出來,否則就直接返回已存在的instance。
這種寫法在大多數的時候也是沒問題的。問題在於,當多執行緒工作的時候,如果有多個執行緒同時執行到if (instance == null)
,都判斷為null,那麼兩個執行緒就各自會建立一個例項——這樣一來,就不是單例了。
3.2 synchronized版本
那既然可能會因為多執行緒導致問題,那麼加上一個同步鎖吧!
修改後的程式碼如下,相對於Version1.1,只是在方法簽名上多加了一個synchronized
:
// Version 2
public class Single2 {
private static Single2 instance;
private Single2() {}
public static synchronized Single2 getInstance() {
if (instance == null) {
instance = new Single2();
}
return instance;
}
}
OK,加上synchronized
關鍵字之後,getInstance方法就會鎖上了。如果有兩個執行緒(T1、T2)同時執行到這個方法時,會有其中一個執行緒T1獲得同步鎖,得以繼續執行,而另一個執行緒T2則需要等待,當第T1執行完畢getInstance之後(完成了null判斷、物件建立、獲得返回值之後),T2執行緒才會執行執行。——所以這端程式碼也就避免了Version1中,可能出現因為多執行緒導致多個例項的情況。
但是,這種寫法也有一個問題:給gitInstance方法加鎖,雖然會避免了可能會出現的多個例項問題,但是會強制除T1之外的所有執行緒等待,實際上會對程式的執行效率造成負面影響。
3.3 雙重檢查(Double-Check)版本
Version2程式碼相對於Version1d程式碼的效率問題,其實是為了解決1%機率的問題,而使用了一個100%出現的防護盾。那有一個優化的思路,就是把100%出現的防護盾,也改為1%的機率出現,使之只出現在可能會導致多個例項出現的地方。
——有沒有這樣的方法呢?當然是有的,改進後的程式碼Vsersion3如下:
// Version 3
public class Single3 {
private static Single3 instance;
private Single3() {}
public static Single3 getInstance() {
if (instance == null) {
synchronized (Single3.class) {
if (instance == null) {
instance = new Single3();
}
}
}
return instance;
}
}
這個版本的程式碼看起來有點複雜,注意其中有兩次if (instance == null)
的判斷,這個叫做『雙重檢查 Double-Check』。
第一個
if (instance == null)
,其實是為了解決Version2中的效率問題,只有instance為null的時候,才進入synchronized
的程式碼段——大大減少了機率。第二個
if (instance == null)
,則是跟Version2一樣,是為了防止可能出現多個例項的情況。
—— 這段程式碼看起來已經完美無瑕了。
……
……
……
—— 當然,只是『看起來』,還是有小概率出現問題的。
這弄清楚為什麼這裡可能出現問題,首先,我們需要弄清楚幾個概念:原子操作
、指令重排
。
知識點:什麼是原子操作?
簡單來說,原子操作(atomic)
就是不可分割的操作,在計算機中,就是指不會因為執行緒排程被打斷的操作。
比如,簡單的賦值是一個原子操作:
m = 6; // 這是個原子操作
假如m原先的值為0,那麼對於這個操作,要麼執行成功m變成了6,要麼是沒執行m還是0,而不會出現諸如m=3這種中間態——即使是在併發的執行緒中。
而,宣告並賦值就不是一個原子操作:
int n = 6; // 這不是一個原子操作
對於這個語句,至少有兩個操作:
①宣告一個變數n
②給n賦值為6
——這樣就會有一箇中間狀態:變數n已經被宣告瞭但是還沒有被賦值的狀態。
——這樣,在多執行緒中,由於執行緒執行順序的不確定性,如果兩個執行緒都使用m,就可能會導致不穩定的結果出現。
知識點:什麼是指令重排?
簡單來說,就是計算機為了提高執行效率,會做的一些優化,在不影響最終結果的情況下,可能會對一些語句的執行順序進行調整。
比如,這一段程式碼:
int a ; // 語句1
a = 8 ; // 語句2
int b = 9 ; // 語句3
int c = a + b ; // 語句4
正常來說,對於順序結構,執行的順序是自上到下,也即1234。
但是,由於指令重排
的原因,因為不影響最終的結果,所以,實際執行的順序可能會變成3124或者1324。
由於語句3和4沒有原子性的問題,語句3和語句4也可能會拆分成原子操作,再重排。
——也就是說,對於非原子性的操作,在不影響最終結果的情況下,其拆分成的原子操作可能會被重新排列執行順序。
OK,瞭解了原子操作
和指令重排
的概念之後,我們再繼續看Version3程式碼的問題。
下面這段話直接從陳皓的文章(深入淺出單例項SINGLETON設計模式)中複製而來:
主要在於singleton = new Singleton()這句,這並非是一個原子操作,事實上在 JVM 中這句話大概做了下面 3 件事情。
1. 給 singleton 分配記憶體
2. 呼叫 Singleton 的建構函式來初始化成員變數,形成例項
3. 將singleton物件指向分配的記憶體空間(執行完這步 singleton才是非 null 了)
但是在 JVM 的即時編譯器中存在指令重排序的優化。也就是說上面的第二步和第三步的順序是不能保證的,最終的執行順序可能是 1-2-3 也可能是 1-3-2。如果是後者,則在 3 執行完畢、2 未執行之前,被執行緒二搶佔了,這時 instance 已經是非 null 了(但卻沒有初始化),所以執行緒二會直接返回 instance,然後使用,然後順理成章地報錯。
再稍微解釋一下,就是說,由於有一個『instance已經不為null但是仍沒有完成初始化』的中間狀態,而這個時候,如果有其他執行緒剛好執行到第一層if (instance == null)
這裡,這裡讀取到的instance已經不為null了,所以就直接把這個中間狀態的instance拿去用了,就會產生問題。
這裡的關鍵在於——執行緒T1對instance的寫操作沒有完成,執行緒T2就執行了讀操作。
3.4 終極版本:volatile
對於Version3中可能出現的問題(當然這種概率已經非常小了,但畢竟還是有的嘛~),解決方案是:只需要給instance的宣告加上volatile
關鍵字即可,Version4版本:
// Version 4
public class Single4 {
private static volatile Single4 instance;
private Single4() {}
public static Single4 getInstance() {
if (instance == null) {
synchronized (Single4.class) {
if (instance == null) {
instance = new Single4();
}
}
}
return instance;
}
}
volatile
關鍵字的一個作用是禁止指令重排
,把instance宣告為volatile
之後,對它的寫操作就會有一個記憶體屏障
(什麼是記憶體屏障?),這樣,在它的賦值完成之前,就不用會呼叫讀操作。
注意:volatile阻止的不singleton = new Singleton()這句話內部[1-2-3]的指令重排,而是保證了在一個寫操作([1-2-3])完成之前,不會呼叫讀操作(
if (instance == null)
)。
——也就徹底防止了Version3中的問題發生。
——好了,現在徹底沒什麼問題了吧?
……
……
……
好了,別緊張,的確沒問題了。大名鼎鼎的EventBus中,其入口方法EventBus.getDefault()
就是用這種方法來實現的。
……
……
……
不過,非要挑點刺的話還是能挑出來的,就是這個寫法有些複雜了,不夠優雅、簡潔。
(傲嬌臉)(  ̄ー ̄)
4. 餓漢式單例
下面再聊瞭解一下餓漢式的單例。
如上所說,餓漢式
單例是指:指全域性的單例例項在類裝載時構建的實現方式。
由於類裝載的過程是由類載入器(ClassLoader)來執行的,這個過程也是由JVM來保證同步的,所以這種方式先天就有一個優勢——能夠免疫許多由多執行緒引起的問題。
4.1 餓漢式單例的實現方式
餓漢式
單例的實現如下:
//餓漢式實現
public class SingleB {
private static final SingleB INSTANCE = new SingleB();
private SingleB() {}
public static SingleB getInstance() {
return INSTANCE;
}
}
對於一個餓漢式單例的寫法來說,它基本上是完美的了。
所以它的缺點也就只是餓漢式單例本身的缺點所在了——由於INSTANCE的初始化是在類載入時進行的,而類的載入是由ClassLoader來做的,所以開發者本來對於它初始化的時機就很難去準確把握:
可能由於初始化的太早,造成資源的浪費
如果初始化本身依賴於一些其他資料,那麼也就很難保證其他資料會在它初始化之前準備好。
當然,如果所需的單例佔用的資源很少,並且也不依賴於其他資料,那麼這種實現方式也是很好的。
知識點:什麼時候是類裝載時?
前面提到了單例在類裝載時被例項化,那究竟什麼時候才是『類裝載時』呢?
不嚴格的說,大致有這麼幾個條件會觸發一個類被載入:
1. new一個物件時
2. 使用反射建立它的例項時
3. 子類被載入時,如果父類還沒被載入,就先載入父類
4. jvm啟動時執行的主類會首先被載入
(類在什麼時候載入和初始化?)
5. 一些其他的實現方式
5.1 Effective Java 1 —— 靜態內部類
《Effective Java》一書的第一版中推薦了一箇中寫法:
// Effective Java 第一版推薦寫法
public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
這種寫法非常巧妙:
對於內部類SingletonHolder,它是一個餓漢式的單例實現,在SingletonHolder初始化的時候會由ClassLoader來保證同步,使INSTANCE是一個真·單例。
同時,由於SingletonHolder是一個內部類,只在外部類的Singleton的getInstance()中被使用,所以它被載入的時機也就是在getInstance()方法第一次被呼叫的時候。
——它利用了ClassLoader來保證了同步,同時又能讓開發者控制類載入的時機。從內部看是一個餓漢式的單例,但是從外部看來,又的確是懶漢式的實現。
簡直是神乎其技。
5.2 Effective Java 2 —— 列舉
你以為到這就算完了?不,並沒有,因為厲害的大神又發現了其他的方法。
《Effective Java》的作者在這本書的第二版又推薦了另外一種方法,來直接看程式碼:
// Effective Java 第二版推薦寫法
public enum SingleInstance {
INSTANCE;
public void fun1() {
// do something
}
}
// 使用
SingleInstance.INSTANCE.fun1();
看到了麼?這是一個列舉型別……連class都不用了,極簡。
由於建立列舉例項的過程是執行緒安全的,所以這種寫法也沒有同步的問題。
作者對這個方法的評價:
這種寫法在功能上與共有域方法相近,但是它更簡潔,無償地提供了序列化機制,絕對防止對此例項化,即使是在面對複雜的序列化或者反射攻擊的時候。雖然這中方法還沒有廣泛採用,但是單元素的列舉型別已經成為實現Singleton的最佳方法。
列舉單例這種方法問世一些,許多分析文章都稱它是實現單例的最完美方法——寫法超級簡單,而且又能解決大部分的問題。
不過我個人認為這種方法雖然很優秀,但是它仍然不是完美的——比如,在需要繼承的場景,它就不適用了。
6. 總結
OK,看到這裡,你還會覺得單例模式是最簡單的設計模式了麼?再回頭看一下你之前程式碼中的單例實現,覺得是無懈可擊的麼?
可能我們在實際的開發中,對單例的實現並沒有那麼嚴格的要求。比如,我如果能保證所有的getInstance都是在一個執行緒的話,那其實第一種最簡單的教科書方式就夠用了。再比如,有時候,我的單例變成了多例也可能對程式沒什麼太大影響……
但是,如果我們能瞭解更多其中的細節,那麼如果哪天程式出了些問題,我們起碼能多一個排查問題的點。早點解決問題,就能早點回家吃飯……:-D
—— 還有,完美的方案是不存在,任何方式都會有一個『度』的問題。比如,你的覺得程式碼已經無懈可擊了,但是因為你用的是JAVA語言,可能ClassLoader有些BUG啊……你的程式碼誰執行在JVM上的,可能JVM本身有BUG啊……你的程式碼執行在手機上,可能手機系統有問題啊……你生活在這個宇宙裡,可能宇宙本身有些BUG啊……o(╯□╰)o
所以,盡力做到能做到的最好就行了。
—— 感謝你花費了不少時間看到這裡,但願你沒有覺得虛度。
PS:如果覺得我的分享不錯,歡迎大家隨手點贊、轉發。
Java團長
專注於Java乾貨分享
掃描上方二維碼獲取更多Java乾貨
相關文章
- 今天我們來聊一聊Java中的SemaphoreJava
- 面試官:我們來聊一聊Redis吧,你瞭解多少就答多少面試Redis
- 聊一聊我在 B 站自學 Java 的經歷吧Java
- 面試官:我們們來聊一聊mysql主從延遲面試MySql
- 單例模式(下) – 聊一聊單例模式的幾種寫法單例模式
- 單例模式(下)---聊一聊單例模式的幾種寫法單例模式
- 單例模式(下) - 聊一聊單例模式的幾種寫法單例模式
- 面試官:聊一聊索引吧面試索引
- 面向未來,我們來聊一聊什麼是現代化資料架構架構
- 當我們聊kubernetes operator時,我們在聊些什麼
- 日誌?聊一聊slf4j吧
- 簡單聊一聊Vuex的原理Vue
- 聊一聊Java的列舉enumJava
- 來聊一道前端面試題吧前端面試題
- 當我們在聊 RN 時,我們在聊什麼 | 技術點評
- 這次我們聊一下CookieCookie
- 聊一聊如何使用Crank給我們的類庫做基準測試
- [gRPC]來聊一聊gRPC的認證RPC
- 聊一聊我認識的Linux系統Linux
- 天天聊爬蟲,今天我們來聊聊反爬爬蟲
- 聊一聊我對測試開發的看法
- 今天來聊Java ClassLoaderJava
- 聊一聊那個曾登上《我們愛科學》的國產武俠網遊
- 簡單地聊一聊Spring Boot的構架Spring Boot
- 簡單聊一聊Javascript中的模組化JavaScript
- 簡單聊一聊 Android App Bundle 的話題AndroidAPP
- 今天我們來聊Java IO模型,BIO、NIO、AIO三種常見IO模型Java模型AI
- 簡單聊一聊Flex佈局常用的屬性Flex
- 聊一聊 JVM 的 GCJVMGC
- 聊一聊Java8中map的putIfAbsent,computeIfAbsent 方法Java
- 聊一聊讓我矇蔽一晚上的各種常量池
- 聊一聊 RestTemplateREST
- 聊一聊HTTPS雙向認證的簡單應用HTTP
- 聊一聊在阿里做了 8 年研發後,我對打造大型工程研發團隊的再思考阿里
- 相比買買買,我們更想在618聊一聊雲廠商的能力象限價值幾何
- 面試官:要不我們聊一下“心跳”的設計?面試
- 聊一聊 Javascript 中的 ASTJavaScriptAST
- 前面有一個Redux,我們去撩(聊)一下它。Redux