前言
單例模式是一個開發(fā)者耳熟能詳?shù)脑O(shè)計模式庸追,在各種書籍或者文章都會見到這個名字,它的應(yīng)用場景是:當(dāng)一個對象的創(chuàng)建開銷是十分昂貴的時候涝缝;當(dāng)我們希望全局范圍內(nèi)只對已實例化的這個對象進行操作扑庞,而不希望重復(fù)實例化這一對象的時候,我們可以使用單例模式拒逮,以達到節(jié)省資源和協(xié)調(diào)系統(tǒng)運作的目的罐氨。
定義
確保一個類只有一個實例,并在全局范圍內(nèi)只能通過單例類來獲取這個實例滩援。
類圖
根據(jù)定義栅隐,我們可以導(dǎo)出類圖:
設(shè)計原則
1、單例具有全局唯一的特性玩徊,也就是說不能在別的地方實例化這個類租悄,所以我們要讓它的構(gòu)造器私有化,這樣就能防止在類以外的地方new這個對象了恩袱。我們只能在類的內(nèi)部來實例化這個類泣棋。
2、單例類的內(nèi)部用一個靜態(tài)變量保存這個類的實例畔塔。即:
private static Singleton sInstance;
3仿野、通過一個靜態(tài)方法來返回上面的靜態(tài)變量即單例對象糟需。
public static Singleton getInstance() { }
至于為什么要使用靜態(tài)變量以及靜態(tài)方法般哼,可以試想一下纲熏,如果使用的是普通的成員變量和方法,那么在外部就必須先獲取到這個類的實例才能訪問其成員方法棚辽,這就違反了原則1技竟,因為我們不可能在類的外部實例化這個單例類。而使用我們可以直接通過靜態(tài)方法來訪問類的靜態(tài)變量屈藐。
實現(xiàn)方法
下面就來列舉幾個單例模式的實現(xiàn)方法榔组。
1、餓漢單例模式
public class Singleton1 {
private static Singleton1 sInstance = new Singleton1();
private Singleton1(){
}
public static Singleton1 getInstance(){
return sInstance;
}
public void doSomething(){
System.out.println("Singleton do something.From:" + this.toString());
}
}
所謂的餓漢單例模式联逻,就是在聲明sInstance的同時實例化對象給它搓扯,這樣sInstance靜態(tài)變量在類被加載的同時也會被初始化,確保了每次調(diào)用getInstance方法都會返回唯一的實例包归。
2锨推、懶漢模式(延遲初始化模式)
public class Singleton2 {
private static Singleton2 sInstance = null;
private Singleton2(){
}
public synchronized static Singleton2 getInstance(){
if (sInstance == null) {
sInstance = new Singleton2();
}
return sInstance;
}
public void doSomething(){
System.out.println("Singleton do something.From:" + toString());
}
}
所謂懶漢模式,就是把該單例類的實例化過程推遲到需要用到的時候才進行初始化,也即用戶第一次調(diào)用getInstance()的時候才會實例化單例换可∽狄可以看出,當(dāng)這個單例類并不一定會使用的時候沾鳄,可以把它的實例化過程推遲到需要使用的時候慨飘,這樣節(jié)約了系統(tǒng)資源。
使用懶漢模式的時候译荞,我們使用了synchronized關(guān)鍵字來對getInstance方法進行加鎖瓤的,這樣的目的是當(dāng)多個線程調(diào)用該方法的時候,確保只有一個線程可以進入臨界區(qū)吞歼,從而確保Singleton只會被實例化一次圈膏。為了方便理解這一種情況,舉個例子:如果沒有加鎖篙骡,那么當(dāng)線程A稽坤、B同時調(diào)用了這個方法,并且都判斷sInstance為空的医增,就會進行Singleton的實例化,這樣就產(chǎn)生了兩個Singleton的實例老虫。
使用該方法能確保產(chǎn)生的實例是單實例叶骨,但同時也產(chǎn)生了一個新的問題:如果getInstance方法被頻繁調(diào)用,那么就會頻繁地加鎖祈匙、釋放鎖忽刽,這樣會產(chǎn)生較大的性能開銷。產(chǎn)生這個問題的原因在于整個方法被加鎖了夺欲,導(dǎo)致每次判空都需要上鎖跪帝、解鎖,這樣其實也是一種多余的步驟些阅。進一步地說伞剑,我們可以把synchronized的范圍縮小,以減少不必要的同步市埋。這樣就引出了第三種方法如下黎泣。
3、雙重檢查模式(DCL:Double Check Lock)
public class Singleton3 {
private static Singleton3 sInstance = null;
private Singleton3(){
}
public static Singleton3 getInstance(){
if (sInstance == null){
synchronized (Singleton3.class){
if (sInstance == null){
sInstance = new Singleton3();
}
}
}
return sInstance;
}
public void doSomething(){
System.out.println("Singleton do something.From:" + this.toString());
}
}
我們直接觀察getInstance()方法缤谎,首先會先判斷是否為空抒倚,如果是空的話,會先加鎖坷澡,在同步代碼塊內(nèi)部托呕,再次判斷是否為空,如果是空的話才會進行實例化。因為這里判斷了兩次空值项郊,所以雙重檢查模式由此得名馅扣。
這個方法看上去已經(jīng)很完善了,解決了多余的同步帶來的性能損耗的問題呆抑,也解決了延遲初始化的問題岂嗓。但實際上,采用了上述寫法的DCL模式會存在一定的缺陷鹊碍,這種缺陷是由Java內(nèi)存模型和指令重排序所帶來的厌殉。這里只是簡單地提及一下原因,詳細的知識點讀者可以參考有關(guān)Java內(nèi)存模型的相關(guān)文章侈咕。
上述的DCL代碼的問題出現(xiàn)在第一層判空上公罕,sInstance是一個共享變量,在沒有加鎖的條件下耀销,當(dāng)多個線程對它進行讀取的時候楼眷,有可能會獲得一個失效的值,從而導(dǎo)致對sInstance進行操作的時候會產(chǎn)生一些難以察覺的錯誤熊尉。簡單地說罐柳,如果一個變量是為多線程所共享的,那么在不加鎖的條件下讀取這個變量是一種不可靠的行為狰住。為了解決這個問題张吉,JDK1.5之后,Java增強了關(guān)鍵字volatile的能力催植,我們在聲明共享變量的同時為這個變量加上volatile關(guān)鍵字肮蛹,就能使得DCL變得可靠。
3.1创南、加入volatile關(guān)鍵字的DCL模式
改進很簡單伦忠,我們只需要在下面這一行代碼做改動即可:
private volatile static Singleton3 sInstance = null;
簡單來說,volatile關(guān)鍵字的作用就是使得每次都從主存中獲取sInstance變量稿辙,避免了不同線程的工作內(nèi)存的sInstance的值不一致的問題以及禁止了指令重排序昆码。同時,使用該方法對性能的影響很小邻储。
4未桥、靜態(tài)內(nèi)部類單例模式(lazy initialization holder class)
public class Singleton4 {
private Singleton4(){ }
public static Singleton4 getInstance(){
return SingletonHolder.sInstance;
}
public void doSomething(){
System.out.println("Singleton do something.From:" + this.toString());
}
private static class SingletonHolder{
private static final Singleton4 sInstance = new Singleton4();
}
}
Singleton內(nèi)部有一個靜態(tài)內(nèi)部類Holder,Holder持有一個靜態(tài)變量sInstance芥备,當(dāng)外部第一次調(diào)用Singleton#getInstance()方法時冬耿,會導(dǎo)致SingletonHolder類被加載,從而初始化sInstance萌壳,并返回這個對象亦镶。使用這種方法日月,能確保線程安全以及實現(xiàn)了延遲實例化的功能,同時避免了DCL可能的缺陷缤骨。
5爱咬、枚舉單例模式
public enum Singleton5 {
INSTANCE;
public void doSomething(){
System.out.println("Singleton do something.From:" + this.toString());
}
}
枚舉是JDK1.5發(fā)行版所添加的功能,枚舉的創(chuàng)建過程是線程安全的绊起;同時精拟,枚舉類不能提供一個Public的構(gòu)造器,所以不能通過外部來實例化枚舉類虱歪,所以枚舉只能是單實例的蜂绎;并且重要的一點是,枚舉能夠防止反序列化時產(chǎn)生新實例笋鄙,因而师枣,使用枚舉能輕松為我們實現(xiàn)單例模式。(注意:前4種方法并沒有處理反序列化生成新對象這一問題萧落,所以它們的單例模式會在反序列化的情況下會失效践美。解決辦法可以往下看。)
各實現(xiàn)方法比較
上面列舉了5種實現(xiàn)方法找岖,各種方法都有各自的特點陨倡,比如餓漢模式把單例的初始化放在了類加載的時候,這樣避免了第一次調(diào)用的時候需要消耗性能來加載實例许布。而懶漢模式兴革、DCL、Holder模式等都是把初始化延遲到了需要使用的時候爹脾,可以避免不必要的性能浪費帖旨。在JDK1.5以后箕昭,volatile關(guān)鍵字的加強以及枚舉的引入灵妨,使得單例模式在多線程環(huán)境下更加安全了,使用枚舉形式的單例不但寫法簡單并且它由Java確保是單例的落竹,開發(fā)者不必擔(dān)心由于自己代碼的問題而造成單例不唯一的現(xiàn)象泌霍。而Holder模式寫法相對于DCL來說也是簡潔的,它把多線程的問題交給了JVM來處理述召,即利用類加載機制來避免了多實例的問題朱转。總的來說积暖,筆者推薦使用Holder模式以及枚舉模式藤为。
知識拓展
本部分主要對上面所述的內(nèi)容進行知識點的補充,方便大家更深入地了解相關(guān)地知識點以及單例模式夺刑。
1缅疟、反序列化生成新對象而造成前4種方法失效的問題
為了方便說明這個問題分别,我們在Singleton4的代碼做點改動,讓它實現(xiàn)Serializable接口存淫,即:
public class Singleton4 implements Serializable{
//...
}
在Java中耘斩,Serializable接口表示該類可以被序列化和反序列化。接著桅咆,我們寫一個測試類括授,把Singleton4序列化然后反序列化,觀察生成的對象是否是同一個對象岩饼。
public class SingletonTest {
public static void main(String args[]) throws IOException, ClassNotFoundException {
Singleton4 s1 = Singleton4.getInstance();
s1.doSomething(); //讓Singleton做一些事情荚虚,里面打印了它的地址
//序列化過程
ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("test.txt"));
objectOutputStream.writeObject(s1);
objectOutputStream.close();
//反序列化過程
ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream("test.txt"));
Singleton4 s2 = (Singleton4) objectInputStream.readObject();
objectInputStream.close();
s2.doSomething();
}
}
運行main函數(shù),觀察控制臺輸出結(jié)果:
可以看出忌愚,兩個對象有著不同的內(nèi)存地址曲管,所以這兩個對象不是同一個對象,因而單例模式在序列化的條件下失效了硕糊。那么院水,該如何解決這個問題呢?我們在實現(xiàn)了Serializable接口的類中简十,添加一個readResolve()方法檬某,如下所示:
public class Singleton4 implements Serializable {
//...
private Object readResolve() throws ObjectStreamException {
return SingletonHolder.sInstance;
}
}
readResolve()方法的作用就是反序列的時候,控制對象的生成螟蝙,也就是說它和構(gòu)造函數(shù)的作用類似恢恼,也能生成一個對象。而上面的改動胰默,把SingletonHolder.sInstance返回场斑,而不是去新生成一個對象。改動完后牵署,可以再運行一次測試代碼漏隐,會發(fā)現(xiàn)兩個對象的地址是一樣的,也即是同一個對象奴迅。
綜上所述青责,如果單例類要實現(xiàn)序列化這一功能,就要實現(xiàn)readResolve()方法做出處理取具,否則單例模式會失效脖隶。而采用枚舉形式的單例則沒有這個問題。
2暇检、Java內(nèi)存模型(JMM:Java Memory Model)
這里只是簡單介紹一下相關(guān)的知識點产阱。JMM規(guī)定了所有變量都存儲在主內(nèi)存中,而每條線程都有自己的工作內(nèi)存块仆,工作內(nèi)存保存的是主內(nèi)存共享變量的一份拷貝构蹬。線程對變量的操作(讀取/存儲)都通過工作內(nèi)存的變量來完成酿矢,而不能直接讀寫主內(nèi)存的值。如果兩條線程之間需要對某一共享變量的值進行數(shù)據(jù)交換怎燥,那么就要通過主內(nèi)存瘫筐。比如,線程1修改了自己工作內(nèi)存的變量A铐姚,然后刷新到主內(nèi)存的變量A策肝,接著線程2從主內(nèi)存中獲取變量A,然后復(fù)制到線程2的工作內(nèi)存中隐绵。用下圖來表示工作內(nèi)存和主內(nèi)存的關(guān)系:
由上面的內(nèi)存模型之众,我們可以看出,如果線程1對某一共享變量進行了修改依许,而還沒有及時刷新回主內(nèi)存棺禾,此時線程2讀取這個共享變量就是一個已經(jīng)失效的值。也就是說線程2不能感知到線程1對變量所做的修改峭跳,即“不可見性”膘婶,我們不能確定被某一線程修改的值在什么時候會同步到主內(nèi)存中。因此如果需要實現(xiàn)準(zhǔn)確無誤的線程間通信蛀醉,就需要加鎖悬襟,或者給共享變量加上volatile關(guān)鍵字來確保可見性拯刁。
3脊岳、指令重排序
指令重排序是引起DCL失效的根本原因。所謂指令重排序簡單來說就是編譯器和處理器為了提高程序的運行性能垛玻,對指令進行重新排序割捅。只要在單線程環(huán)境下,指令A(yù)帚桩、指令B交換位置對運行結(jié)果沒有影響亿驾,那么就能交換指令A(yù)、B的執(zhí)行順序以提高執(zhí)行效率朗儒〖粘耍考察下面的例子:
1.為Singleton的實例s1分配內(nèi)存A
2.調(diào)用Singleton的構(gòu)造函數(shù)参淹,初始化s1
3.引用s1指向內(nèi)存A
由于Java編譯器允許CPU執(zhí)行指令的時候?qū)χ噶钸M行重排序醉锄,所以2和3的執(zhí)行順序是不確定的。假設(shè)執(zhí)行順序是1-3-2浙值,如果線程A執(zhí)行到3的時候恳不,把s1指向了內(nèi)存區(qū)A,然后該操作被同步到了主內(nèi)存中开呐;同時烟勋,線程B從主內(nèi)存中讀取了s1的值规求,那么它此時還沒有初始化,直接拿去用肯定會出錯的卵惦。這也就產(chǎn)生了DCL失效阻肿。
在jdk5之后,volatile關(guān)鍵字不但確保了可見性沮尿,同時也禁止了指令重排序丛塌,每次都從主存中獲取最新的值,從而使得DCL變得可靠畜疾。
好了赴邻,本文到這里就結(jié)束啦,謝謝大家的閱讀啡捶,歡迎留言溝通交流~