設(shè)計(jì)模式-單例模式
單例模式在網(wǎng)上已經(jīng)是被寫(xiě)爛的一種設(shè)計(jì)模式了俊戳,筆者也看了不少的有關(guān)單例模式的文章惨奕,但是在實(shí)際生產(chǎn)中使用的并不是很多雪位,如果一個(gè)知識(shí)點(diǎn),你看過(guò)100遍梨撞,但是一次也沒(méi)實(shí)踐過(guò)雹洗,那么它終究不是屬于你的香罐。因此我借助這篇文章來(lái)復(fù)習(xí)下設(shè)計(jì)模式中的單例模式。
單例模式的作用在于保證整個(gè)程序在一次運(yùn)行的過(guò)程中队伟,被單例模式聲明的類(lèi)的對(duì)象要有且只有一個(gè)穴吹。針對(duì)不同的應(yīng)用場(chǎng)景幽勒,單例模式的實(shí)現(xiàn)要求也不同嗜侮。下文將描述幾種單例模式的實(shí)現(xiàn)方案,從性能和實(shí)現(xiàn)上將有所差異啥容,他們?cè)谝欢ǔ潭壬隙寄鼙WC單例的存在锈颗,但是要在生產(chǎn)環(huán)境的角度來(lái)看待哪一種實(shí)現(xiàn)才是最合適的。
最基本的實(shí)現(xiàn)方案
單例模式的從實(shí)現(xiàn)步驟上來(lái)講咪惠,分為三步:
- 構(gòu)造方法私有击吱,保證無(wú)法從外部通過(guò) new 的方式創(chuàng)建對(duì)象。
- 對(duì)外提供獲取該類(lèi)實(shí)例的靜態(tài)方法
- 類(lèi)的內(nèi)部創(chuàng)建該類(lèi)的對(duì)象遥昧,通過(guò)第 2 步的靜態(tài)方法返回
通過(guò)上述三點(diǎn)要求我們可以幾乎就可以寫(xiě)出一個(gè)最最基本的單例實(shí)現(xiàn)方案覆醇,也就是各種資料中所描述的「餓漢式」。
public class BasicSingleTon {
//創(chuàng)建唯一實(shí)例
private static final BasicSingleTon instance = new BasicSingleTon();
//第二部暴露靜態(tài)方法返回唯一實(shí)例
public static BasicSingleTon getInstance() {
return instance;
}
//第一步構(gòu)造方法私有
private BasicSingleTon() {
}
}
該方法實(shí)現(xiàn)簡(jiǎn)單炭臭,也是最常用的一種永脓,在不考慮線程安全的角度來(lái)說(shuō)此實(shí)現(xiàn)也算是較為科學(xué)的,但是存在一個(gè)很大缺點(diǎn)就是鞋仍,在虛擬機(jī)加載改類(lèi)的時(shí)候常摧,將會(huì)在初始化階段為類(lèi)靜態(tài)變量賦值,也就是在虛擬機(jī)加載該類(lèi)的時(shí)候(此時(shí)可能并沒(méi)有調(diào)用 getInstance 方法)就已經(jīng)調(diào)用了 new BasicSingleTon();
創(chuàng)建了改對(duì)象的實(shí)例威创。但是如果追求代碼的效率那么就需要采用下面這種方式落午,即延遲加載的方式。
也許這里看過(guò)看多例子的讀者可能對(duì) Instance 變量的聲明為 static final 有所疑問(wèn)肚豺,因?yàn)橛械奈恼吕镏暶鳛?static溃斋,其實(shí)筆者認(rèn)為在此單例模式的基本應(yīng)用場(chǎng)景下,二者沒(méi)有很大的區(qū)別吸申,聲明為 final 只是為了保證對(duì)象在方法區(qū)中的地址無(wú)法改變梗劫。而對(duì)對(duì)象的初始化時(shí)機(jī)沒(méi)有影響。
延遲加載的單例模式
延遲加載的方式呛谜,是在我們編碼過(guò)程中盡可能晚的實(shí)例化話對(duì)象在跳,也就是避免在類(lèi)的加載過(guò)程中,讓虛擬機(jī)去創(chuàng)建這個(gè)實(shí)例對(duì)象隐岛。這種實(shí)現(xiàn)也就是我們所說(shuō)的「懶漢式」猫妙。他的實(shí)現(xiàn)也很簡(jiǎn)單,將對(duì)象的創(chuàng)建操作后置到 getInstance
方法內(nèi)部聚凹,最初的靜態(tài)變量賦予 null 割坠,而 在第一次調(diào)用 getInstance
的時(shí)候創(chuàng)建對(duì)象齐帚。
public class LazyBasicSingleTon {
private static LazyBasicSingleTon singleTon = null;
public static LazyBasicSingleTon getInstance() {
//延遲初始化 在第一次調(diào)用 getInstance 的時(shí)候創(chuàng)建對(duì)象
if (singleTon == null) {
singleTon = new LazyBasicSingleTon();
}
return singleTon;
}
private LazyBasicSingleTon() {
}
}
多線程模式下的單例實(shí)現(xiàn)
對(duì)于單線程模式上述的延遲加載已經(jīng)算的上是很好的單例實(shí)踐方式了。一方面Java 是一個(gè)多線程的內(nèi)存模型彼哼。而靜態(tài)變量存在于虛擬機(jī)的方法區(qū)中对妄,該內(nèi)存空間被線程共享,上述實(shí)現(xiàn)無(wú)法保證對(duì)單例對(duì)象的修改保證內(nèi)存的可見(jiàn)性敢朱,原子性剪菱。而另一方面,newInstance 方法本身就不是一個(gè)原子類(lèi)操作(分為兩步第一步判空拴签,第二步調(diào)用 new 來(lái)創(chuàng)建對(duì)象)孝常,所以結(jié)論是上述兩種實(shí)現(xiàn)方式不適合多線程的引用場(chǎng)景。
那么對(duì)于多線程環(huán)境下單例實(shí)現(xiàn)模式蚓哩,存在的問(wèn)題构灸,我們可以舉個(gè)簡(jiǎn)單的例子,假設(shè)有兩個(gè)線程都需要這個(gè)單例的對(duì)象岸梨,線程 A 率先進(jìn)入語(yǔ)句 if (singleTon == null)
得到的結(jié)果為 true喜颁,此時(shí) CPU 切換線程 B 去執(zhí)行,由于 A 線程并沒(méi)有進(jìn)行 new LazyBasicSingleTon();
的操作曹阔,那么 B 線程在執(zhí)行語(yǔ)句 singleTon == null
的結(jié)果認(rèn)為 true半开,緊接著 B 線程創(chuàng)建了改類(lèi)的實(shí)例對(duì)象,當(dāng) CPU 重新回到 A 線程去執(zhí)行的時(shí)候次兆,又會(huì)創(chuàng)建一個(gè)類(lèi)的實(shí)例稿茉,這就導(dǎo)致了,所謂的單例并不真正的唯一芥炭,也就會(huì)產(chǎn)生錯(cuò)誤漓库。
為了解決這個(gè)缺點(diǎn),我們能想到方法首先就是加鎖园蝠,使用 synchronized
關(guān)鍵字來(lái)保證渺蒿,在執(zhí)行 getInstance 的時(shí)候不會(huì)發(fā)生線程的切換。
public class SyncSingleTon {
private static SyncSingleTon singleTon = null;
/** 使用 synchronized 保證線程在創(chuàng)建對(duì)象的時(shí)候讓其他線程阻塞*/
public static synchronized SyncSingleTon getInstance() {
if (singleTon == null) {
singleTon = new SyncSingleTon();
}
return singleTon;
}
private SyncSingleTon() {
}
}
其實(shí) synchronized
關(guān)鍵字也可以加在判空操作上,這樣本質(zhì)上并沒(méi)有區(qū)別彪薛,只是別的資料中有這種實(shí)現(xiàn)方式茂装,因此在這里給出實(shí)現(xiàn):
public static SyncSingleTon getInstance() {
synchronized(SyncSingleTon.class){
if (singleTon == null) {
singleTon = new SyncSingleTon();
}
}
return singleTon;
}
雙重判空操作的多線程單例實(shí)現(xiàn)
上面的例子給出的多線程下的單例實(shí)現(xiàn),也可以保證在大多數(shù)情況下善延∩偬可以保證單例的唯一性,但是對(duì)于效率會(huì)產(chǎn)生影響易遣,因?yàn)槿绻覀兛深A(yù)料的線程切換場(chǎng)景并不是那么頻繁彼妻,那么synchronized
為getInstance
方法加鎖,將會(huì)帶來(lái)很大效率丟失,比如單線程的模式下侨歉。
我們繼續(xù)深入思考一下屋摇,可以想到,是因?yàn)樵诘谝淮潍@取該實(shí)例的時(shí)候幽邓,如果剛好發(fā)生了線程的切換將會(huì)早上我們所描述的單例不唯一的結(jié)果炮温,在之后的調(diào)用過(guò)程中將會(huì)不會(huì)造成這樣的結(jié)果。所以我們可以在 synchronized
語(yǔ)句之前牵舵,額外添加一次判空操作柒啤,來(lái)優(yōu)化上述方案帶來(lái)的效率損失。
public class SyncSingleTon {
private static SyncSingleTon singleTon = null;
public static SyncSingleTon getInstance() {
//這次判空是避免了棋枕,保證的多線程只有第一次調(diào)用getInstance 的時(shí)候才會(huì)加鎖初始化
if (singleTon == null) {
synchronized (SyncSingleTon.class) {
if (singleTon == null) {
singleTon = new SyncSingleTon();
}
}
}
return singleTon;
}
private SyncSingleTon() {
}
}
上述方案很好的解決了白修,最開(kāi)始的實(shí)現(xiàn)在效率上的損失妒峦,比如在多個(gè)線程場(chǎng)景中重斑,即使在第一次if (singleTon == null)
判空操作中讓出 CPU 去執(zhí)行,那么在另一個(gè)線程中也會(huì)在同步代碼中初始化改單例對(duì)象肯骇,待 CPU 切換回來(lái)的時(shí)候窥浪,也會(huì)在第二次判空的時(shí)候得到正確結(jié)果。
什么笛丙?指令重排漾脂?
當(dāng)我們都認(rèn)為這一切的看上去很完美的時(shí)候,JVM 又給我提出了個(gè)難題胚鸯,那就是指令重排骨稿。
什么是指令重排,指令重排的用大白話來(lái)簡(jiǎn)單的描述姜钳,就是說(shuō)在我們的代碼運(yùn)行時(shí)坦冠,JVM 并不一定總是按照我們想讓它按照編碼順序去執(zhí)行我們所想象的語(yǔ)義,它會(huì)在 "不改變" 原有代碼語(yǔ)句含義的前提下進(jìn)行代碼哥桥,指令的重排序辙浑。
對(duì)于指令重排Java 語(yǔ)言規(guī)范給出來(lái)了下面的定義:
根據(jù)《The Java Language Specification, Java SE 7 Edition》(簡(jiǎn)稱(chēng)為java語(yǔ)言規(guī)范),所有線程在執(zhí)行java程序時(shí)必須要遵守 intra-thread semantics(譯為 線程內(nèi)語(yǔ)義是一個(gè)單線程程序的基本語(yǔ)義)拟糕。intra-thread semantics 保證重排序不會(huì)改變單線程內(nèi)的程序執(zhí)行結(jié)果送滞。換句話來(lái)說(shuō),intra-thread semantics 允許那些在單線程內(nèi)边涕,不會(huì)改變單線程程序執(zhí)行結(jié)果的重排序奥吩。
那么我們上述雙重檢驗(yàn)鎖的單例實(shí)現(xiàn)問(wèn)題主要出在哪里呢?問(wèn)題出在 singleTon = new SyncSingleTon();
這句話在執(zhí)行的過(guò)程腮介。首先應(yīng)該進(jìn)行對(duì)象的創(chuàng)建操作大體可以分為三步:
(1)分配內(nèi)存空間叠洗。
(2)初始化對(duì)象即執(zhí)行構(gòu)造方法灭抑。
(3)設(shè)置 Instance 引用指向該內(nèi)存空間抵代。
那么如果有指令重排的前提下荤牍,這三部的執(zhí)行順序?qū)⒂锌赡馨l(fā)生變化:
(1)分配內(nèi)存空間劈榨。
⊥薄(2)設(shè)置 Instance 引用指向該內(nèi)存空間惭载。
∽丶妗(3)初始化對(duì)象即執(zhí)行構(gòu)造方法伴挚。
上面類(lèi)初始化描述的步驟 2 和 3 之間雖然被重排序了, 但是這個(gè)重排序在沒(méi)有改變單線程程序的執(zhí)行結(jié)果颅眶。那么再多線程的前提下這將會(huì)造成什么樣的后果呢涛酗?我們假設(shè)有兩個(gè)線程同時(shí)想要初始化這個(gè)類(lèi)商叹, 這兩個(gè)線程的執(zhí)行如下圖所示:
如果按照上述的語(yǔ)義去執(zhí)行剖笙,單看線程 A 中的操作雖然指令重排了弥咪,但是返回結(jié)果并不影響聚至。但是這樣造成的問(wèn)題也顯而易見(jiàn),b 線程將返回一個(gè)空的 Instance脆诉,可怕的是我們認(rèn)為這一切是正常執(zhí)行的库说。
為了解決上述問(wèn)題我們可以從兩個(gè)方面去考慮:
- 避免指令重排
- 讓 A 線程完成對(duì)象初始化后,B 再去判斷
instance == null
通過(guò) Volatile 避免指令重排序
對(duì)于 Volatile 關(guān)鍵字字管,這里不做詳細(xì)的描述嘲叔,讀者需要了解的是硫戈,volatile 作用有以下兩點(diǎn):
可以保證多線程條件下丁逝,內(nèi)存區(qū)域的可見(jiàn)性,即使用 volatile 聲明的變量霜幼,將對(duì)在一個(gè)線程從內(nèi)主內(nèi)存(線程共享的內(nèi)存區(qū)域)讀取變量,并寫(xiě)入后琢感,通知其他線程驹针,改變量被我改變了牌捷,別的線程在使用的時(shí)候暗甥,將會(huì)重新從主內(nèi)存中去讀改變量的最新值撤防。
可以保證再多線程的情況下寄月,指令重排這個(gè)操作將會(huì)被禁止漾肮。
那么改造完成的雙重檢鎖的單例將會(huì)是這樣的:
public class VolatileSingleTon {
//使用 Volatile 保證了指令重排序在這個(gè)對(duì)象創(chuàng)建的時(shí)候不可用
private volatile static VolatileSingleTon singleTon = null;
public static VolatileSingleTon getInstance() {
if (singleTon == null) {
synchronized (VolatileSingleTon.class) {
if (singleTon == null) {
singleTon = new VolatileSingleTon();
}
}
}
return singleTon;
}
private VolatileSingleTon() {}
}
由于 volatile 關(guān)鍵字是在 JDK 1.5 之后被明確了有禁止指令重排的語(yǔ)義的忱辅,那么有沒(méi)有可能不用 volatile 就能解決我們上述描述的指令重排造成的問(wèn)題呢,答案是肯定的损搬。
靜態(tài)內(nèi)部類(lèi)方式的單例實(shí)現(xiàn)
上述我們使用 Volatile 關(guān)鍵字去解決指令重排的方法是從避免指令重排的思路出發(fā)來(lái)解決問(wèn)題的巧勤。那么對(duì)于第二種 讓 A 線程完成對(duì)象初始化后踢关,B 再去判斷 instance == null
思路聽(tīng)起來(lái)好像有一定的加鎖韻味秕脓,那么我們?cè)趺慈ソo一個(gè)對(duì)象的初始化過(guò)程去加鎖呢吠架,看起來(lái)好像沒(méi)思路傍药。
這里我們需要補(bǔ)充一個(gè)知識(shí)點(diǎn)拐辽,是有關(guān) JVM 在類(lèi)的初始化階段期間,將會(huì)去獲取一個(gè)鎖睁搭,這個(gè)鎖的作用是可以同步多個(gè)線程對(duì)同一個(gè)類(lèi)的初始化操作园骆。JVM 在類(lèi)初始化期間會(huì)獲得一個(gè)稱(chēng)做初始化鎖的東西,并且每個(gè)線程至少獲取一次鎖來(lái)確保這個(gè)類(lèi)已經(jīng)被初始化過(guò)了鸠珠。
我們可以理解為:如果一個(gè)線程在初始化一個(gè)類(lèi)的時(shí)候,將會(huì)為這個(gè)初始化過(guò)程上鎖灸蟆,當(dāng)此時(shí)有其他的線程嘗試初始化這個(gè)類(lèi)的時(shí)候,將會(huì)查看這個(gè)鎖的狀態(tài)斋枢,如果這個(gè)鎖沒(méi)有被釋放瓤帚,那么將會(huì)處于等待鎖釋放的狀態(tài)轩勘。這和我們用的 synchronized
機(jī)制很相似绊寻,只是被用在類(lèi)的初始化階段澄步。
對(duì)于靜態(tài)內(nèi)部類(lèi),相信讀者一定清除它不依靠外部類(lèi)的存在而存在王凑。在編譯階段將作為獨(dú)立的一個(gè)類(lèi),生成自己的 .class 文件百姓。并且在初始化階段也是獨(dú)立的垒拢,也就是說(shuō)擁有上述所說(shuō)的初始化鎖。
那么我們可以有如下思路:
- 返回該類(lèi)的對(duì)象依賴(lài)于一個(gè)靜態(tài)內(nèi)部類(lèi)的初始化操作火惊。
- 在這個(gè)靜態(tài)內(nèi)部類(lèi)初始化的時(shí)候求类,生成外部類(lèi)的對(duì)象惶岭,然后在
getInstance
中返回
注意這里的初始化是指在JVM 類(lèi)加載過(guò)程中 加載->鏈接(驗(yàn)證症革,準(zhǔn)備量蕊,解析)->初始化 中的初始化危融。這個(gè)初始化過(guò)程將為類(lèi)的靜態(tài)變量付具體的值楷怒。
對(duì)于一個(gè)類(lèi)的初始化時(shí)機(jī)有一下幾種情況:
1) 使用new關(guān)鍵字實(shí)例化對(duì)象的時(shí)候抱完、讀取或設(shè)置一個(gè)類(lèi)的靜態(tài)字段(被final修飾烘贴、已在編譯期把結(jié)果放入常量池的靜態(tài)字段除外)的時(shí)候老翘,以及調(diào)用一個(gè)類(lèi)的靜態(tài)方法的時(shí)候汽纠。
2)使用java.lang.reflect包的方法對(duì)類(lèi)進(jìn)行反射調(diào)用的時(shí)候永罚,如果類(lèi)沒(méi)有進(jìn)行過(guò)初始化翅敌,則需要先觸發(fā)其初始化治专。
3)當(dāng)初始化一個(gè)類(lèi)的時(shí)候棒旗,如果發(fā)現(xiàn)其父類(lèi)還沒(méi)有進(jìn)行過(guò)初始化,則需要先觸發(fā)其父類(lèi)的初始化。
我們先來(lái)看下這里的具體實(shí)現(xiàn):
public class StaticInnerSingleTon {
private static class InnerStaticClass{
private static StaticInnerSingleTon singleTon = new StaticInnerSingleTon();
}
public StaticInnerSingleTon getInstance(){
// //引用一個(gè)類(lèi)的靜態(tài)成員,將會(huì)觸發(fā)該類(lèi)的初始化 符合1)規(guī)則
return InnerStaticClass.singleTon;
}
private StaticInnerSingleTon() {
}
}
單例的最簡(jiǎn)單實(shí)現(xiàn) Enum
上述講了這么多實(shí)現(xiàn)方法箭窜,也講了各個(gè)實(shí)現(xiàn)的缺點(diǎn)。直到我們說(shuō)了靜態(tài)內(nèi)部類(lèi)的實(shí)現(xiàn)單例的思路后我們仿佛打開(kāi)了新世界的大門(mén)。
為什么說(shuō)枚舉實(shí)現(xiàn)單例的方法最簡(jiǎn)單,這是因?yàn)?Enum 類(lèi)的創(chuàng)建本身是就是線程安全的,這一點(diǎn)和靜態(tài)內(nèi)部類(lèi)相似,因此我們不必去關(guān)心什么 DCL 問(wèn)題蜜氨,而是拿拿起鍵盤(pán)直接干:
public enum EnumSingleTon {
INSTANCE
}
public class SingleTon {
public static void main(String[] args) {
EnumSingleTon instance = EnumSingleTon.INSTANCE;
EnumSingleTon instance1 = EnumSingleTon.INSTANCE;
System.out.println("instance1 == instance = " + (instance1 == instance));//輸出結(jié)果為 true
}
}
枚舉的思想其實(shí)是通過(guò)共有的靜態(tài) final 與為每個(gè)枚舉常量導(dǎo)出實(shí)例的類(lèi)笆豁,由于沒(méi)有可訪問(wèn)的構(gòu)造器扩氢,所以不能調(diào)用枚舉常量的構(gòu)造方法去生成對(duì)應(yīng)的對(duì)象,因此在《Effective Java》 中录豺,枚舉類(lèi)型為類(lèi)型安全的枚舉模式朦肘,枚舉也被稱(chēng)為單例的泛型化。
總結(jié)
一篇行文下來(lái)双饥,對(duì)于單例模式的理解變的更加深刻了媒抠,尤其是 DSL(double checked locking)) 的問(wèn)題的解決思路上,更是涉及到咏花,指令重排和類(lèi)的加載機(jī)制的方面的知識(shí)趴生。面試的時(shí)候,面試官也經(jīng)常由此引出更深的只是昏翰,比如JVM 類(lèi)加載的相關(guān)知識(shí)點(diǎn)苍匆,volatile 關(guān)鍵字的作用,以及多線程方面的知識(shí)點(diǎn)棚菊。其實(shí)對(duì)于面試者來(lái)說(shuō)這也許是個(gè)好事浸踩,畢竟有跡可循了。
筆者最近加班加傻了统求,文章都半個(gè)月沒(méi)跟新了检碗。但是年初定下的目標(biāo)沒(méi)有忘卻。個(gè)人這種層層深入的了解比業(yè)務(wù)代碼更能帶來(lái)快感码邻。但是這都是一些拾人牙慧的東西了折剃,看到別的大佬都在研究 gradle 和插件化組件化,筆者也是眼紅... 精力就那么多像屋,這可如何是好呀微驶。