Hi,我們再來聊一聊Java的單例吧

本文由BarryZhang原創(chuàng)族跛,同時首發(fā)于diycode.ccbarryzhang.com锐墙,簡書非商業(yè)轉(zhuǎn)載請注明作者和原文鏈接礁哄。

1. 前言

單例(Singleton)應(yīng)該是開發(fā)者們最熟悉的設(shè)計(jì)模式了,并且好像也是最容易實(shí)現(xiàn)的——基本上每個開發(fā)者都能夠隨手寫出——但是溪北,真的是這樣嗎桐绒?
作為一個Java開發(fā)者夺脾,也許你覺得自己對單例模式的了解已經(jīng)足夠多了。我并不想危言聳聽說一定還有你不知道的——畢竟我自己的了解也的確有限茉继,但究竟你自己了解的程度到底怎樣呢咧叭?往下看,我們一起來聊聊看~

2. 什么是單例烁竭?

單例對象的類必須保證只有一個實(shí)例存在——這是維基百科上對單例的定義菲茬,這也可以作為對意圖實(shí)現(xiàn)單例模式的代碼進(jìn)行檢驗(yàn)的標(biāo)準(zhǔn)。

對單例的實(shí)現(xiàn)可以分為兩大類——懶漢式餓漢式派撕,他們的區(qū)別在于:
懶漢式:指全局的單例實(shí)例在第一次被使用時構(gòu)建婉弹。
餓漢式:指全局的單例實(shí)例在類裝載時構(gòu)建。

從它們的區(qū)別也能看出來终吼,日常我們使用的較多的應(yīng)該是懶漢式的單例镀赌,畢竟按需加載才能做到資源的最大化利用嘛~

3. 懶漢式單例

先來看一下懶漢式單例的實(shí)現(xiàn)方式。

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;
    }
}

或者再進(jìn)一步衔峰,把構(gòu)造器改為私有的佩脊,這樣能夠防止被外部的類調(diào)用。

// Version 1.1
public class Single1 {
    private static Single1 instance;
    private Single1() {}
    public static Single1 getInstance() {
        if (instance == null) {
            instance = new Single1();
        }
        return instance;
    }
}

我仿佛記得當(dāng)初學(xué)校的教科書就是這么教的垫卤?—— 每次獲取instance之前先進(jìn)行判斷威彰,如果instance為空就new一個出來,否則就直接返回已存在的instance穴肘。
這種寫法在大多數(shù)的時候也是沒問題的歇盼。問題在于,當(dāng)多線程工作的時候评抚,如果有多個線程同時運(yùn)行到if (instance == null)豹缀,都判斷為null,那么兩個線程就各自會創(chuàng)建一個實(shí)例——這樣一來慨代,就不是單例了邢笙。

3.2 synchronized版本

那既然可能會因?yàn)槎嗑€程導(dǎo)致問題,那么加上一個同步鎖吧侍匙!
修改后的代碼如下氮惯,相對于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關(guān)鍵字之后妇汗,getInstance方法就會鎖上了。如果有兩個線程(T1说莫、T2)同時執(zhí)行到這個方法時杨箭,會有其中一個線程T1獲得同步鎖,得以繼續(xù)執(zhí)行储狭,而另一個線程T2則需要等待互婿,當(dāng)?shù)赥1執(zhí)行完畢getInstance之后(完成了null判斷捣郊、對象創(chuàng)建、獲得返回值之后)擒悬,T2線程才會執(zhí)行執(zhí)行模她。——所以這端代碼也就避免了Version1中懂牧,可能出現(xiàn)因?yàn)槎嗑€程導(dǎo)致多個實(shí)例的情況侈净。
但是,這種寫法也有一個問題:給gitInstance方法加鎖僧凤,雖然會避免了可能會出現(xiàn)的多個實(shí)例問題畜侦,但是會強(qiáng)制除T1之外的所有線程等待,實(shí)際上會對程序的執(zhí)行效率造成負(fù)面影響躯保。

3.3 雙重檢查(Double-Check)版本

Version2代碼相對于Version1d代碼的效率問題旋膳,其實(shí)是為了解決1%幾率的問題,而使用了一個100%出現(xiàn)的防護(hù)盾途事。那有一個優(yōu)化的思路验懊,就是把100%出現(xiàn)的防護(hù)盾,也改為1%的幾率出現(xiàn)尸变,使之只出現(xiàn)在可能會導(dǎo)致多個實(shí)例出現(xiàn)的地方义图。
——有沒有這樣的方法呢?當(dāng)然是有的召烂,改進(jìn)后的代碼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;
    }
}

這個版本的代碼看起來有點(diǎn)復(fù)雜碱工,注意其中有兩次if (instance == null)的判斷,這個叫做『雙重檢查 Double-Check』奏夫。

  • 第一個if (instance == null)怕篷,其實(shí)是為了解決Version2中的效率問題,只有instance為null的時候酗昼,才進(jìn)入synchronized的代碼段——大大減少了幾率廊谓。
  • 第二個if (instance == null),則是跟Version2一樣麻削,是為了防止可能出現(xiàn)多個實(shí)例的情況蹂析。

—— 這段代碼看起來已經(jīng)完美無瑕了。
……
……
……
—— 當(dāng)然碟婆,只是『看起來』,還是有小概率出現(xiàn)問題的惕稻。
這弄清楚為什么這里可能出現(xiàn)問題竖共,首先,我們需要弄清楚幾個概念:原子操作俺祠、指令重排公给。

知識點(diǎn):什么是原子操作借帘?

簡單來說,原子操作(atomic)就是不可分割的操作淌铐,在計(jì)算機(jī)中肺然,就是指不會因?yàn)榫€程調(diào)度被打斷的操作。
比如腿准,簡單的賦值是一個原子操作:

m = 6; // 這是個原子操作

假如m原先的值為0际起,那么對于這個操作,要么執(zhí)行成功m變成了6吐葱,要么是沒執(zhí)行m還是0街望,而不會出現(xiàn)諸如m=3這種中間態(tài)——即使是在并發(fā)的線程中。

而弟跑,聲明并賦值就不是一個原子操作:

int n = 6; // 這不是一個原子操作

對于這個語句灾前,至少有兩個操作:
①聲明一個變量n
②給n賦值為6
——這樣就會有一個中間狀態(tài):變量n已經(jīng)被聲明了但是還沒有被賦值的狀態(tài)。
——這樣孟辑,在多線程中哎甲,由于線程執(zhí)行順序的不確定性,如果兩個線程都使用m饲嗽,就可能會導(dǎo)致不穩(wěn)定的結(jié)果出現(xiàn)炭玫。

知識點(diǎn):什么是指令重排?

簡單來說喝噪,就是計(jì)算機(jī)為了提高執(zhí)行效率础嫡,會做的一些優(yōu)化,在不影響最終結(jié)果的情況下酝惧,可能會對一些語句的執(zhí)行順序進(jìn)行調(diào)整榴鼎。
比如,這一段代碼:

int a ;   // 語句1 
a = 8 ;   // 語句2
int b = 9 ;     // 語句3
int c = a + b ; // 語句4

正常來說晚唇,對于順序結(jié)構(gòu)巫财,執(zhí)行的順序是自上到下,也即1234哩陕。
但是平项,由于指令重排的原因,因?yàn)椴挥绊懽罱K的結(jié)果悍及,所以闽瓢,實(shí)際執(zhí)行的順序可能會變成3124或者1324。
由于語句3和4沒有原子性的問題心赶,語句3和語句4也可能會拆分成原子操作扣讼,再重排。
——也就是說缨叫,對于非原子性的操作椭符,在不影響最終結(jié)果的情況下荔燎,其拆分成的原子操作可能會被重新排列執(zhí)行順序。

OK销钝,了解了原子操作指令重排的概念之后有咨,我們再繼續(xù)看Version3代碼的問題。
下面這段話直接從陳皓的文章(深入淺出單實(shí)例SINGLETON設(shè)計(jì)模式)中復(fù)制而來:

主要在于singleton = new Singleton()這句蒸健,這并非是一個原子操作座享,事實(shí)上在 JVM 中這句話大概做了下面 3 件事情。

  1. 給 singleton 分配內(nèi)存
  2. 調(diào)用 Singleton 的構(gòu)造函數(shù)來初始化成員變量纵装,形成實(shí)例
  3. 將singleton對象指向分配的內(nèi)存空間(執(zhí)行完這步 singleton才是非 null 了)
    但是在 JVM 的即時編譯器中存在指令重排序的優(yōu)化征讲。也就是說上面的第二步和第三步的順序是不能保證的,最終的執(zhí)行順序可能是 1-2-3 也可能是 1-3-2橡娄。如果是后者诗箍,則在 3 執(zhí)行完畢、2 未執(zhí)行之前挽唉,被線程二搶占了滤祖,這時 instance 已經(jīng)是非 null 了(但卻沒有初始化),所以線程二會直接返回 instance瓶籽,然后使用匠童,然后順理成章地報錯

再稍微解釋一下塑顺,就是說汤求,由于有一個『instance已經(jīng)不為null但是仍沒有完成初始化』的中間狀態(tài),而這個時候严拒,如果有其他線程剛好運(yùn)行到第一層if (instance == null)這里扬绪,這里讀取到的instance已經(jīng)不為null了,所以就直接把這個中間狀態(tài)的instance拿去用了裤唠,就會產(chǎn)生問題挤牛。
這里的關(guān)鍵在于——線程T1對instance的寫操作沒有完成,線程T2就執(zhí)行了讀操作种蘸。

3.4 終極版本:volatile

對于Version3中可能出現(xiàn)的問題(當(dāng)然這種概率已經(jīng)非常小了墓赴,但畢竟還是有的嘛~),解決方案是:只需要給instance的聲明加上volatile關(guān)鍵字即可航瞭,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關(guān)鍵字的一個作用是禁止指令重排诫硕,把instance聲明為volatile之后,對它的寫操作就會有一個內(nèi)存屏障什么是內(nèi)存屏障刊侯?)痘括,這樣,在它的賦值完成之前,就不用會調(diào)用讀操作纲菌。

注意:volatile阻止的不singleton = new Singleton()這句話內(nèi)部[1-2-3]的指令重排,而是保證了在一個寫操作([1-2-3])完成之前疮绷,不會調(diào)用讀操作(if (instance == null))翰舌。

——也就徹底防止了Version3中的問題發(fā)生。
——好了冬骚,現(xiàn)在徹底沒什么問題了吧椅贱?
……
……
……
好了,別緊張只冻,的確沒問題了庇麦。大名鼎鼎的EventBus中,其入口方法EventBus.getDefault()就是用這種方法來實(shí)現(xiàn)的喜德。
……
……
……
不過山橄,非要挑點(diǎn)刺的話還是能挑出來的,就是這個寫法有些復(fù)雜了舍悯,不夠優(yōu)雅航棱、簡潔。
(傲嬌臉)(  ̄ー ̄)

4. 餓漢式單例

下面再聊了解一下餓漢式的單例萌衬。

如上所說饮醇,餓漢式單例是指:指全局的單例實(shí)例在類裝載時構(gòu)建的實(shí)現(xiàn)方式。

由于類裝載的過程是由類加載器(ClassLoader)來執(zhí)行的秕豫,這個過程也是由JVM來保證同步的朴艰,所以這種方式先天就有一個優(yōu)勢——能夠免疫許多由多線程引起的問題。

4.1 餓漢式單例的實(shí)現(xiàn)方式

餓漢式單例的實(shí)現(xiàn)如下:

//餓漢式實(shí)現(xiàn)
public class SingleB {
    private static final SingleB INSTANCE = new SingleB();
    private SingleB() {}
    public static SingleB getInstance() {
        return INSTANCE;
    }
}

對于一個餓漢式單例的寫法來說混移,它基本上是完美的了祠墅。
所以它的缺點(diǎn)也就只是餓漢式單例本身的缺點(diǎn)所在了——由于INSTANCE的初始化是在類加載時進(jìn)行的,而類的加載是由ClassLoader來做的沫屡,所以開發(fā)者本來對于它初始化的時機(jī)就很難去準(zhǔn)確把握:

  1. 可能由于初始化的太早饵隙,造成資源的浪費(fèi)
  2. 如果初始化本身依賴于一些其他數(shù)據(jù),那么也就很難保證其他數(shù)據(jù)會在它初始化之前準(zhǔn)備好沮脖。

當(dāng)然金矛,如果所需的單例占用的資源很少,并且也不依賴于其他數(shù)據(jù)勺届,那么這種實(shí)現(xiàn)方式也是很好的驶俊。

知識點(diǎn):什么時候是類裝載時?

前面提到了單例在類裝載時被實(shí)例化免姿,那究竟什么時候才是『類裝載時』呢饼酿?

不嚴(yán)格的說,大致有這么幾個條件會觸發(fā)一個類被加載:

  1. new一個對象時
  2. 使用反射創(chuàng)建它的實(shí)例時
  3. 子類被加載時,如果父類還沒被加載故俐,就先加載父類
  4. jvm啟動時執(zhí)行的主類會首先被加載

類在什么時候加載和初始化?

5. 一些其他的實(shí)現(xiàn)方式

5.1 Effective Java 1 —— 靜態(tài)內(nèi)部類

《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;
    }
}

這種寫法非常巧妙:

  • 對于內(nèi)部類SingletonHolder想鹰,它是一個餓漢式的單例實(shí)現(xiàn),在SingletonHolder初始化的時候會由ClassLoader來保證同步药版,使INSTANCE是一個真·單例辑舷。
  • 同時,由于SingletonHolder是一個內(nèi)部類槽片,只在外部類的Singleton的getInstance()中被使用何缓,所以它被加載的時機(jī)也就是在getInstance()方法第一次被調(diào)用的時候。

——它利用了ClassLoader來保證了同步还栓,同時又能讓開發(fā)者控制類加載的時機(jī)碌廓。從內(nèi)部看是一個餓漢式的單例,但是從外部看來剩盒,又的確是懶漢式的實(shí)現(xiàn)谷婆。

簡直是神乎其技。

5.2 Effective Java 2 —— 枚舉

你以為到這就算完了勃刨?不波材,并沒有,因?yàn)閰柡Φ拇笊裼职l(fā)現(xiàn)了其他的方法身隐。
《Effective Java》的作者在這本書的第二版又推薦了另外一種方法廷区,來直接看代碼:

// Effective Java 第二版推薦寫法
public enum SingleInstance {
    INSTANCE;
    public void fun1() { 
        // do something
    }
}

// 使用
SingleInstance.INSTANCE.fun1();

看到了么?這是一個枚舉類型……連class都不用了贾铝,極簡隙轻。
由于創(chuàng)建枚舉實(shí)例的過程是線程安全的,所以這種寫法也沒有同步的問題垢揩。

作者對這個方法的評價:

這種寫法在功能上與共有域方法相近玖绿,但是它更簡潔,無償?shù)靥峁┝诵蛄谢瘷C(jī)制叁巨,絕對防止對此實(shí)例化斑匪,即使是在面對復(fù)雜的序列化或者反射攻擊的時候。雖然這中方法還沒有廣泛采用锋勺,但是單元素的枚舉類型已經(jīng)成為實(shí)現(xiàn)Singleton的最佳方法蚀瘸。

枚舉單例這種方法問世一些,許多分析文章都稱它是實(shí)現(xiàn)單例的最完美方法——寫法超級簡單庶橱,而且又能解決大部分的問題贮勃。
不過我個人認(rèn)為這種方法雖然很優(yōu)秀,但是它仍然不是完美的——比如苏章,在需要繼承的場景寂嘉,它就不適用了奏瞬。

6. 總結(jié)

OK,看到這里泉孩,你還會覺得單例模式是最簡單的設(shè)計(jì)模式了么硼端?再回頭看一下你之前代碼中的單例實(shí)現(xiàn),覺得是無懈可擊的么寓搬?
可能我們在實(shí)際的開發(fā)中显蝌,對單例的實(shí)現(xiàn)并沒有那么嚴(yán)格的要求。比如订咸,我如果能保證所有的getInstance都是在一個線程的話,那其實(shí)第一種最簡單的教科書方式就夠用了酬诀。再比如脏嚷,有時候,我的單例變成了多例也可能對程序沒什么太大影響……
但是瞒御,如果我們能了解更多其中的細(xì)節(jié)父叙,那么如果哪天程序出了些問題,我們起碼能多一個排查問題的點(diǎn)肴裙。早點(diǎn)解決問題趾唱,就能早點(diǎn)回家吃飯……:-D

—— 還有,完美的方案是不存在蜻懦,任何方式都會有一個『度』的問題甜癞。比如,你的覺得代碼已經(jīng)無懈可擊了宛乃,但是因?yàn)槟阌玫氖荍AVA語言悠咱,可能ClassLoader有些BUG啊……你的代碼誰運(yùn)行在JVM上的,可能JVM本身有BUG啊……你的代碼運(yùn)行在手機(jī)上征炼,可能手機(jī)系統(tǒng)有問題啊……你生活在這個宇宙里析既,可能宇宙本身有些BUG啊……o(╯□╰)o
所以,盡力做到能做到的最好就行了谆奥。

—— 感謝你花費(fèi)了不少時間看到這里眼坏,但愿你沒有覺得虛度。

7. 一些有用的鏈接

深入淺出單實(shí)例SINGLETON設(shè)計(jì)模式:http://coolshell.cn/articles/265.html
Java并發(fā)編程:volatile關(guān)鍵字解析:http://www.cnblogs.com/dolphin0520/p/3920373.html
為什么volatile不能保證原子性而Atomic可以酸些?: http://www.cnblogs.com/Mainz/p/3556430.html
類在什么時候加載和初始化宰译?http://www.importnew.com/6579.html

8. 關(guān)于作者

https://github.com/barryhappy
http://www.barryzhang.com

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市擂仍,隨后出現(xiàn)的幾起案子囤屹,更是在濱河造成了極大的恐慌,老刑警劉巖逢渔,帶你破解...
    沈念sama閱讀 216,402評論 6 499
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件肋坚,死亡現(xiàn)場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)智厌,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,377評論 3 392
  • 文/潘曉璐 我一進(jìn)店門诲泌,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人铣鹏,你說我怎么就攤上這事敷扫。” “怎么了诚卸?”我有些...
    開封第一講書人閱讀 162,483評論 0 353
  • 文/不壞的土叔 我叫張陵葵第,是天一觀的道長。 經(jīng)常有香客問我合溺,道長卒密,這世上最難降的妖魔是什么妄讯? 我笑而不...
    開封第一講書人閱讀 58,165評論 1 292
  • 正文 為了忘掉前任企巢,我火速辦了婚禮,結(jié)果婚禮上串述,老公的妹妹穿的比我還像新娘睛约。我一直安慰自己鼎俘,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,176評論 6 388
  • 文/花漫 我一把揭開白布辩涝。 她就那樣靜靜地躺著贸伐,像睡著了一般。 火紅的嫁衣襯著肌膚如雪膀值。 梳的紋絲不亂的頭發(fā)上棍丐,一...
    開封第一講書人閱讀 51,146評論 1 297
  • 那天,我揣著相機(jī)與錄音沧踏,去河邊找鬼歌逢。 笑死,一個胖子當(dāng)著我的面吹牛翘狱,可吹牛的內(nèi)容都是我干的秘案。 我是一名探鬼主播,決...
    沈念sama閱讀 40,032評論 3 417
  • 文/蒼蘭香墨 我猛地睜開眼潦匈,長吁一口氣:“原來是場噩夢啊……” “哼阱高!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起茬缩,我...
    開封第一講書人閱讀 38,896評論 0 274
  • 序言:老撾萬榮一對情侶失蹤赤惊,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后凰锡,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體未舟,經(jīng)...
    沈念sama閱讀 45,311評論 1 310
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡圈暗,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,536評論 2 332
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了裕膀。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片员串。...
    茶點(diǎn)故事閱讀 39,696評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖昼扛,靈堂內(nèi)的尸體忽然破棺而出寸齐,到底是詐尸還是另有隱情,我是刑警寧澤抄谐,帶...
    沈念sama閱讀 35,413評論 5 343
  • 正文 年R本政府宣布渺鹦,位于F島的核電站,受9級特大地震影響蛹含,放射性物質(zhì)發(fā)生泄漏海铆。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,008評論 3 325
  • 文/蒙蒙 一挣惰、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧殴边,春花似錦憎茂、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,659評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至是偷,卻和暖如春拳氢,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背蛋铆。 一陣腳步聲響...
    開封第一講書人閱讀 32,815評論 1 269
  • 我被黑心中介騙來泰國打工馋评, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人刺啦。 一個月前我還...
    沈念sama閱讀 47,698評論 2 368
  • 正文 我出身青樓留特,卻偏偏與公主長得像,于是被迫代替她去往敵國和親玛瘸。 傳聞我的和親對象是個殘疾皇子蜕青,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 44,592評論 2 353

推薦閱讀更多精彩內(nèi)容