什么是單例模式
什么是單例模式呢? 我們引用一下維基百科:
單例模式,也叫單子模式,是一種常用的軟件設(shè)計模式。在應用這個模式時,單例對象的類必須保證只有一個實例存在。許多時候整個系統(tǒng)只需要擁有一個的全局對象,這樣有利于我們協(xié)調(diào)系統(tǒng)整體的行為。比如在某個服務(wù)器程序中,該服務(wù)器的配置信息存放在一個文件中,這些配置數(shù)據(jù)由一個單例對象統(tǒng)一讀取,然后服務(wù)進程中的其他對象再通過這個單例對象獲取這些配置信息。這種方式簡化了在復雜環(huán)境下的配置管理。還有就是我們經(jīng)常使用的servlet就是單例多線程的。使用單例能夠節(jié)省很多內(nèi)存。
如何實現(xiàn)單例模式呢?
我們引用一下維基百科:
實現(xiàn)單例模式的思路是:一個類能返回對象一個引用(永遠是同一個)和一個獲得該實例的方法(必須是靜態(tài)方法,通常使用getInstance這個名稱);當我們調(diào)用這個方法時,如果類持有的引用不為空就返回這個引用,如果類保持的引用為空就創(chuàng)建該類的實例并將實例的引用賦予該類保持的引用;同時我們還將該類的構(gòu)造函數(shù)定義為私有方法,這樣其他處的代碼就無法通過調(diào)用該類的構(gòu)造函數(shù)來實例化該類的對象,只有通過該類提供的靜態(tài)方法來得到該類的唯一實例。
好了,我們知道了單例模式的定義和如何使用單例的描述,接下來,就引用Linux Torvalds 的話:
Talk is cheap. Show me the code
讓我們來看看單例模式的7種實現(xiàn)方式
單例模式的七種實現(xiàn)
第一種:懶漢式加載
懶漢式加載:最簡單的單例模式:2步,1.把自己的構(gòu)造方法設(shè)置為私有的,不讓別人訪問你的實例,2.提供一個static方法給別人獲取你的實例.

我們可以看到,這是一個簡單的獲取單例的一個類,首先我們定義一個靜態(tài)實例 single, 如何將構(gòu)造方法變成私有的。并且給外界一個靜態(tài)獲取實例的方法。如果對象不是null,就直接返回實例,從而保證實例。也可以保證不浪費內(nèi)存。這是我們的第一個實現(xiàn)單例模式的例子。很簡單。但是有問題,我們后面再講。
第二種:餓漢式加載

我們看到第二種單例模式,代碼量比第一個少了很多,而為什么叫餓漢式呢?我們看代碼,我們定義了一個靜態(tài)的final的實例,并且直接new了一個對象,這樣就會導致Single2 類在加載字節(jié)碼到虛擬機的時候就會實例化這個實例,當你調(diào)用getInstance方法的時候,就會直接返回,不必做任何判斷,這樣做的好處是代碼量明顯減少了,壞處是,在你沒有使用該單例的時候,該單例卻被加載了,如果該單例很大的話,將會浪費很多的內(nèi)存。
我們停下來思考一下
我們?nèi)绾芜x擇這兩種實現(xiàn)方式呢?如果你的項目對性能沒有要求,那么請直接使用餓漢式方法實現(xiàn)單例模式,既簡單又方便。但是,大部分程序員都是有追求的,豈能不追求性能。那么我們看第一種方式,就是懶漢式,我們剛剛說過,懶漢式既保證了單例,又保證了性能。但是,他真的能保證單例嗎?可以確定的是:在單線程模式下,毫無問題,但在復雜的多線程模式下,會怎么樣呢?show me code .
測試用例:我們測試一下
測試用例

我們分析一下上面的代碼,首先,我們驗證的是什么呢?我們想驗證多線程下獲取懶漢式單例會不會出現(xiàn)錯誤。也就是出現(xiàn)一個以上的單例,我們?nèi)绾巫瞿??首先我們定義一個Set對實例進行去重,然后創(chuàng)建1000個線程(Windows每個進程最多1000個線程,Linux每個進程最多2000個線程),每個線程都去獲取實例,并添加到set中,實際上,我們應該使用Collections.synchronizedSet(set)獲取一個線程安全的set,但是,這里為了方便,就直接使用HashSet了,然后main線程等待10秒,讓1000個線程盡量都執(zhí)行完畢。最后循環(huán)打印set的內(nèi)容。在某些情況下,會出現(xiàn)2個實例,注意,是某些情況下,一定要多測試幾次。下面是我們測試的結(jié)果:

我們停下來思考一下:
我們通過測試用例發(fā)現(xiàn):高并發(fā)情況下,我們的懶加載確實存在bug。為什么會這樣呢?我們假設(shè)第一個線程進入getInstance方法,判斷實例為null,準備進入if塊內(nèi)執(zhí)行實例化,這時線程突然讓出時間片,第二個線程也進入方法,判斷實例也為null,并且進入if塊執(zhí)行實例化,第一個線程喚醒也進入if塊進行實例化。這時就會出現(xiàn)2個實例。所以出現(xiàn)了bug。So, 我們想要性能(避免上面說的消耗不需要的內(nèi)存),又要線程安全。那我們該怎么辦呢?有點經(jīng)驗的同學心里肯定有數(shù)了。show me code.
第三種方式:synchronized 同步式

這是我們的第三種方式,我們分析一下代碼,我們可以看到,我們僅僅是在第一種懶漢式中加入了一個關(guān)鍵字,synchronized, 使用synchronized保證線程同步,保證同時只有一個進程進入此方法。從而保證并發(fā)安全。但是這樣做完美嗎?我們思考一下我們的代碼:我們使用synchronized關(guān)鍵字,相當于每個想要進入該方法的獲取實例的線程都要阻塞排隊,我們仔細思考一下:需要嗎?當實例已經(jīng)初始化之后,我們還需要做同步控制嗎?這對性能的影響是巨大的。是的,我們只需要在實例第一次初始化的時候同步就足夠了。我們繼續(xù)優(yōu)化。
第四種方式:雙重檢驗鎖:

我們繼續(xù)分析一下代碼:首先看getInstance方法,我們在方法聲明上去除了synchronized關(guān)鍵字,多線程進入方法內(nèi)部,判斷是否為null,如果為null,多個線程同時進入if塊內(nèi),此時,我們是用Single4 Class對象同步一段方法。保證只有一個線程進入該方法。并且判斷是否為null,如果為null,就進行初始化。我們想象一下,如果第一個線程進入進入同步塊,發(fā)現(xiàn)該實例為null,于是進入if塊實例化,第二個線程進入同步內(nèi)則發(fā)現(xiàn)實例已經(jīng)不是null,直接就返回 了,從而保證了并發(fā)安全。那么這個和第三種方式又什么區(qū)別呢?第三種方式的缺陷是:每個線程每次進入該方法都需要被同步,成本巨大。而第四種方式呢?每個線程最多只有在第一次的時候才會進入同步塊,也就是說,只要實例被初始化了,那么之后進入該方法的線程就不必進入同步塊了。就解決并發(fā)下線程安全和性能的平衡。雖然第一次還是會被阻塞。但相比較于第三種,已經(jīng)好多了。
我們還對一個東西感興趣,就是修飾變量的volatile關(guān)鍵字,為什么要用volatile關(guān)鍵字呢?這是個有趣的問題。我們好好分析一下:
首先我們看,Java虛擬機初始化一個對象都干了些什么?總的來說,3件事情:
- 在堆空間分配內(nèi)存
- 執(zhí)行構(gòu)造方法進行初始化
- 將對象指向內(nèi)存中分配的內(nèi)存空間,也就是地址
但是由于當我們編譯的時候,編譯器在生成匯編代碼的時候會對流程進行優(yōu)化(這里涉及到happen-before原則和Java內(nèi)存模型和CPU流水線執(zhí)行的知識,就不展開講了),優(yōu)化的結(jié)果式有可能式123順序執(zhí)行,也有可能式132執(zhí)行,但是,如果是按照132的順序執(zhí)行,走到第三步(還沒到第二步)的時候,這時突然另一個線程來訪問,走到if(single4 == null)塊,會發(fā)現(xiàn)single4已經(jīng)不是null了,就直接返回了,但是此時對象還沒有完成初始化,如果另一個線程對實例的某些需要初始化的參數(shù)進行操作,就有可能報錯。使用volatile關(guān)鍵字,能夠告訴編譯器不要對代碼進行重排序的優(yōu)化。就不會出現(xiàn)這種問題了。
我們看到,小小的單例模式被我們弄得很復雜。但這就是一個程序員的追求,追求最好的性能,追求最好的代碼。
那還有沒有別的更好的辦法呢?這個代碼也太多了,代碼可讀性也不好。而且線程第一次進入還會阻塞,還能更完美嗎?
第五種方式:既要懶漢式加載,又要線程安全:靜態(tài)內(nèi)部類。

我們來分析一下代碼:相比較餓漢式(也就是第二種),我們增加了一個內(nèi)部類,內(nèi)部類中有一個外部類的實例,并且已經(jīng)初始化了。我們回憶一下餓漢式有什么問題,餓漢式的問題是:在你沒有使用該單例的時候,該單例卻被加載了,如果該單例很大的話,將會浪費很多的內(nèi)存.但是,我們現(xiàn)在引入了內(nèi)部類的方式,虛擬機的機制是,如果你沒有訪問一個類,那么是不會載入該類進入虛擬機的。當我們使用外部類的時候其他屬性的時候,是不會浪費內(nèi)存載入內(nèi)部類中的單例的。從而也就保證了并發(fā)安全和防止內(nèi)存浪費。
但是,這樣就能完美了嗎?
第六種方式:反射和反序列化破壞單例

我們知道Java的反射幾乎是什么事情都能做,管你什么私有的公有的。都能破壞。我們是沒有還手之力的。精心編寫的代碼就被破壞了,而反序列化也很厲害,但是稍微還有點辦法遏制。什么辦法呢?重寫readResolve方法。show me code。

我們看到:我們重寫了readResolve方法,在該方法中直接返回了我們的內(nèi)部類實例。重寫readResolve() 方法,防止反序列化破壞單例機制,這是因為:反序列化的機制在反序列化的時候,會判斷如果實現(xiàn)了serializable或者externalizable接口的類中包含readResolve方法的話,會直接調(diào)用readResolve方法來獲取實例。這樣我們就制止了反序列化破壞我們的單例模式。那反射呢?我們有辦法嗎?
第七種方式:最后一招,使用枚舉

為什么使用枚舉可以呢?枚舉類型反編譯之后可以看到實際上是一個繼承自Enum的類。所以本質(zhì)還是一個類。 因為枚舉的特點,你只會有一個實例。我們看一下反編譯的枚舉類。

我們看到,我們的hello包下的Single7枚舉繼承了java.lang.Enum<> 類。事實上就是一個類,但是我們這樣就能防止反射破壞我們辛苦寫的單例模式了。因為枚舉的特點,而他也能保證單例??胺Q完美!?。?/p>
總結(jié)
回到開始,我們引用了一些維基百科的話,我們再看看維基百科關(guān)于并發(fā)是怎么說的:
單例模式在多線程的應用場合下必須小心使用。如果當唯一實例尚未創(chuàng)建時,有兩個線程同時調(diào)用創(chuàng)建方法,那么它們同時沒有檢測到唯一實例的存在,從而同時各自創(chuàng)建了一個實例,這樣就有兩個實例被構(gòu)造出來,從而違反了單例模式中實例唯一的原則。 解決這個問題的辦法是為指示類是否已經(jīng)實例化的變量提供一個互斥鎖(雖然這樣會降低效率).
我們看到維基百科還是靠譜的。告訴了我們可以使用互斥鎖來防止并發(fā)出現(xiàn)的問題。
而單例模式帶來了什么好處呢?
- 對于頻繁使用的對象,可以省略創(chuàng)建對象所花費的時間,這對于那些重量級對象而言,是非常可觀的一筆系統(tǒng)開銷;
- 由于 new 操作的次數(shù)減少,因而對系統(tǒng)內(nèi)存的使用頻率也會降低,這將減輕 GC 壓力,縮短 GC 停頓時間。
小小的一個單例模式也是如此的復雜,耗費了我們很多的精力去寫,去讀一篇文章??墒?,這就是我們的樂趣。任何一段小小的代碼,我們都要精益求精??傆幸惶?,我們會寫出千萬人用的優(yōu)良代碼。加油?。?/p>