1.什么情況下使用單例模式?
有些對象只有一個,比如配置文件,工具類,線程池,緩存,日志對象等等。單例模式保證應用中有且只有一個實例。
2. 什么是單例?
2.1、單例定義
“單例對象的類必須保證只有一個實例存在” 這是維基百科上對單例的定義,這也可以作為對意圖實現單例模式的代碼進行檢驗的標準。
2.2、單例的實現可以分為兩大類
懶漢式:指全局的單例實例在第一次被使用時構建。
餓漢式:指全局的單例實例在類裝載時構建。
注:日常我們使用的較多的應該是懶漢式的單例,畢竟按需加載才能做到資源的最大化利用。
3. 懶漢式單例
先來看一下懶漢式單例的實現方式。
3.1 簡單版本
看最簡單的寫法Version 1:
public class LazySingleton {
//1. Simplest version
private static LazySingleton instance;
private LazySingleton(){}
public static LazySingleton getInstance(){
if(instance == null){
instance = new LazySingleton();
}
return instance;
}
}
把構造器改為私有的,這樣能夠防止被外部的類調用。每次獲取instance之前先進行判斷,如果instance為空就new一個出來,否則就直接返回已存在的instance。這種寫法在大多數的時候也是沒問題的。問題在于,當多線程工作的時候,如果有多個線程同時運行到if (instance == null),都判斷為null,那么兩個線程就各自會創建一個實例——這樣一來,就不是單例了。
3.2 synchronized版本
那既然可能會因為多線程導致問題,那么加上一個同步鎖吧!修改后的代碼如下,相對于Version1,只是在方法簽名上多加了一個synchronized:
//2. Sychronized version
private static LazySingleton instance2;
private LazySingleton(){}
public static synchronized LazySingleton getInstance2(){
if(instance2 == null){
instance2 = new LazySingleton();
}
return instance2;
}
OK,加上synchronized關鍵字之后,getInstance方法就會鎖上了。如果有兩個線程(T1、T2)同時執行到這個方法時,會有其中一個線程T1獲得同步鎖,得以繼續執行,而另一個線程T2則需要等待,當第T1執行完畢getInstance之后(完成了null判斷、對象創建、獲得返回值之后),T2線程才會執行執行。——所以這端代碼也就避免了Version1中,可能出現因為多線程導致多個實例的情況。但是,這種寫法也有一個問題:給getInstance方法加鎖,雖然會避免了可能會出現的多個實例問題,但是會強制除T1之外的所有線程等待,實際上會對程序的執行效率造成負面影響。
3.3 雙重檢查(Double-Check)版本
Version2代碼相對于Version1d代碼的效率問題,其實是為了解決1%幾率的問題,而使用了一個100%出現的防護盾。那有一個優化的思路,就是把100%出現的防護盾,也改為1%的幾率出現,使之只出現在可能會導致多個實例出現的地方。——有沒有這樣的方法呢?當然是有的,改進后的代碼Vsersion3如下:
//3. Double-check version
private static LazySingleton instance3;
private LazySingleton(){}
public static LazySingleton getInstance3(){
if(instance3 == null){
synchronized (LazySingleton.class){
if(instance3 == null){
instance3 = new LazySingleton();
}
}
}
return instance3;
}
第一個if (instance == null),其實是為了解決Version2中的效率問題,只有instance為null的時候,才進入synchronized的代碼段大大減少了幾率。
第二個if (instance == null),則是跟Version2一樣,是為了防止可能出現多個實例的情況。
這段代碼看起來已經完美無瑕了。………………—— 當然,只是『看起來』,還是有小概率出現問題的。這弄清楚為什么這里可能出現問題,首先,我們需要弄清楚幾個概念:原子操作、指令重排。
主要在于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版本:
//4. Double-check with volatile version
private static volatile LazySingleton instance4;
private LazySingleton(){}
public static LazySingleton getInstance4(){
if(instance4 == null){
synchronized (LazySingleton.class){
if(instance4 == null){
instance4 = new LazySingleton();
}
}
}
return instance4;
}
一旦一個共享變量(類的成員變量、類的靜態成員變量)被volatile修飾之后,那么就具備了兩層語義:
1)可見性:保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。
2)有序性:禁止進行指令重排序。
我的理解是,volatile修飾后,保證了singleton = new Singleton()這句話的指令執行順序,從而不會出現版本3的問題。
4. 餓漢式單例
下面再聊了解一下餓漢式的單例。
如上所說,餓漢式單例是指:指全局的單例實例在類裝載時構建的實現方式。
由于類裝載的過程是由類加載器(ClassLoader)來執行的,這個過程也是由JVM來保證同步的,所以這種方式先天就有一個優勢——能夠免疫許多由多線程引起的問題。
4.1 餓漢式單例的實現方式
餓漢式單例的實現如下:
public class HungreySingleton {
private static final HungreySingleton instance = new HungreySingleton();
private HungreySingleton(){}
public static HungreySingleton getInstance() {
return instance;
}
}
對于一個餓漢式單例的寫法來說,它基本上是完美的了。所以它的缺點也就只是餓漢式單例本身的缺點所在了——由于INSTANCE的初始化是在類加載時進行的,而類的加載是由ClassLoader來做的,所以開發者本來對于它初始化的時機就很難去準確把握:可能由于初始化的太早,造成資源的浪費。
如果初始化本身依賴于一些其他數據,那么也就很難保證其他數據會在它初始化之前準備好。
當然,如果所需的單例占用的資源很少,并且也不依賴于其他數據,那么這種實現方式也是很好的。
4.2什么時候是類裝載時?
類從被加載到虛擬機內存中開始,直到卸載出內存為止,它的整個生命周期包括了:加載、驗證、準備、解析、初始化、使用和卸載這7個階段。其中,驗證、準備和解析這三個部分統稱為連接(linking)。
什么情況下需要開始類加載過程的第一個階段:"加載"。虛擬機規范中并沒強行約束,這點可以交給虛擬機的的具體實現自由把握,但是對于初始化階段虛擬機規范是嚴格規定了如下幾種情況,如果類未初始化會對類進行初始化。
- 創建類的實例
- 訪問類的靜態變量(除常量【被final修辭的靜態變量】原因:常量一種特殊的變量,因為編譯器把他們當作值(value)而不是域(field)來對待。如果你的代碼中用到了常變量(constant variable),編譯器并不會生成字節碼來從對象中載入域的值,而是直接把這個值插入到字節碼中。這是一種很有用的優化,但是如果你需要改變final域的值那么每一塊用到那個域的代碼都需要重新編譯。
- 訪問類的靜態方法
- 反射如(Class.forName("my.xyz.Test"))
- 當初始化一個類時,發現其父類還未初始化,則先出發父類的初始化
- 虛擬機啟動時,定義了main()方法的那個類先初始化
class SingleTon {
private static SingleTon singleTon = new SingleTon();
public static int count1;
public static int count2 = 0;
private SingleTon() {
count1++;
count2++;
}
public static SingleTon getInstance() {
return singleTon;
}
}
public class Test {
public static void main(String[] args) {
SingleTon singleTon = SingleTon.getInstance();
System.out.println("count1=" + singleTon.count1);
System.out.println("count2=" + singleTon.count2);
}
}
輸出:
count1=1
count2=0
分析:
1:SingleTon singleTon = SingleTon.getInstance();調用了類的SingleTon調用了類的靜態方法,觸發類的初始化
2:類加載的時候在準備過程中為類的靜態變量分配內存并初始化默認值 singleton=null count1=0,count2=0
3:類初始化,為類的靜態變量賦值和執行靜態代碼快。singleton賦值為new SingleTon()調用類的構造方法
4:調用類的構造方法后count=1;count2=1
5:繼續為count1與count2賦值,此時count1沒有賦值操作,所有count1為1,但是count2執行賦值操作就變為0
5. 一些其他的實現方式
5.1 Effective Java 1 —— 靜態內部類
《Effective Java》一書的第一版中推薦了一個中寫法:
public class InnerSingleton {
private static class SingletonHolder{
private static final InnerSingleton instance = new InnerSingleton();
}
private InnerSingleton(){}
public static final InnerSingleton getInstance(){
return SingletonHolder.instance;
}
}
這種寫法非常巧妙:對于內部類SingletonHolder,它是一個餓漢式的單例實現,在SingletonHolder初始化的時候會由ClassLoader來保證同步,使INSTANCE是一個真·單例。
同時,由于SingletonHolder是一個內部類,只在外部類的Singleton的getInstance()中被使用,所以它被加載的時機也就是在getInstance()方法第一次被調用的時候。
它利用了ClassLoader來保證了同步,同時又能讓開發者控制類加載的時機。從內部看是一個餓漢式的單例,但是從外部看來,又的確是懶漢式的實現**。簡直是神乎其技。
5.2 Effective Java 2 —— 枚舉
《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所以,盡力做到能做到的最好就行了。
感謝你花費了不少時間看到這里,但愿你沒有覺得虛度。
本文僅對于原文作少許修改。
原文:
作者:博麟K
鏈接:http://www.lxweimin.com/p/d2755af464d2