1. 前言 單例(Singleton)應(yīng)該是開發(fā)者們最熟悉的設(shè)計(jì)模式了,并且好像也是最容易實(shí)現(xiàn)的——基本上每個(gè)開發(fā)者都能夠隨手寫出——但是,真的是這樣嗎? 作為一個(gè)Java開發(fā)者,也許你覺得自己對(duì)單例模式的了解已經(jīng)足夠多了。我并不想危言聳聽說(shuō)一定還有你不知道的——畢竟我自己的了解也的確有限,但究竟你自己了解的程度到底怎樣呢?往下看,我們一起來(lái)聊聊看~ 2. 什么是單例? 單例對(duì)象的類必須保證只有一個(gè)實(shí)例存在——這是維基百科上對(duì)單例的定義,這也可以作為對(duì)意圖實(shí)現(xiàn)單例模式的代碼進(jìn)行檢驗(yàn)的標(biāo)準(zhǔn)。 對(duì)單例的實(shí)現(xiàn)可以分為兩大類——懶漢式和餓漢式,他們的區(qū)別在于:
從它們的區(qū)別也能看出來(lái),日常我們使用的較多的應(yīng)該是懶漢式的單例,畢竟按需加載才能做到資源的最大化利用嘛~ 3. 懶漢式單例 先來(lái)看一下懶漢式單例的實(shí)現(xiàn)方式。 3.1 簡(jiǎn)單版本 看最簡(jiǎn)單的寫法Version 1:
或者再進(jìn)一步,把構(gòu)造器改為私有的,這樣能夠防止被外部的類調(diào)用。
我仿佛記得當(dāng)初學(xué)校的教科書就是這么教的?—— 每次獲取instance之前先進(jìn)行判斷,如果instance為空就new一個(gè)出來(lái),否則就直接返回已存在的instance。 這種寫法在大多數(shù)的時(shí)候也是沒問(wèn)題的。問(wèn)題在于,當(dāng)多線程工作的時(shí)候,如果有多個(gè)線程同時(shí)運(yùn)行到if (instance == null),都判斷為null,那么兩個(gè)線程就各自會(huì)創(chuàng)建一個(gè)實(shí)例——這樣一來(lái),就不是單例了。 3.2 synchronized版本 那既然可能會(huì)因?yàn)槎嗑€程導(dǎo)致問(wèn)題,那么加上一個(gè)同步鎖吧! 修改后的代碼如下,相對(duì)于Version1.1,只是在方法簽名上多加了一個(gè)synchronized:
OK,加上synchronized關(guān)鍵字之后,getInstance方法就會(huì)鎖上了。如果有兩個(gè)線程(T1、T2)同時(shí)執(zhí)行到這個(gè)方法時(shí),會(huì)有其中一個(gè)線程T1獲得同步鎖,得以繼續(xù)執(zhí)行,而另一個(gè)線程T2則需要等待,當(dāng)?shù)赥1執(zhí)行完畢getInstance之后(完成了null判斷、對(duì)象創(chuàng)建、獲得返回值之后),T2線程才會(huì)執(zhí)行執(zhí)行?!赃@端代碼也就避免了Version1中,可能出現(xiàn)因?yàn)槎嗑€程導(dǎo)致多個(gè)實(shí)例的情況。 但是,這種寫法也有一個(gè)問(wèn)題:給gitInstance方法加鎖,雖然會(huì)避免了可能會(huì)出現(xiàn)的多個(gè)實(shí)例問(wèn)題,但是會(huì)強(qiáng)制除T1之外的所有線程等待,實(shí)際上會(huì)對(duì)程序的執(zhí)行效率造成負(fù)面影響。 3.3 雙重檢查(Double-Check)版本 Version2代碼相對(duì)于Version1d代碼的效率問(wèn)題,其實(shí)是為了解決1%幾率的問(wèn)題,而使用了一個(gè)100%出現(xiàn)的防護(hù)盾。那有一個(gè)優(yōu)化的思路,就是把100%出現(xiàn)的防護(hù)盾,也改為1%的幾率出現(xiàn),使之只出現(xiàn)在可能會(huì)導(dǎo)致多個(gè)實(shí)例出現(xiàn)的地方。 ——有沒有這樣的方法呢?當(dāng)然是有的,改進(jìn)后的代碼Vsersion3如下:
這個(gè)版本的代碼看起來(lái)有點(diǎn)復(fù)雜,注意其中有兩次if (instance == null)的判斷,這個(gè)叫做『雙重檢查 Double-Check』。
—— 這段代碼看起來(lái)已經(jīng)完美無(wú)瑕了。 …… …… …… —— 當(dāng)然,只是『看起來(lái)』,還是有小概率出現(xiàn)問(wèn)題的。 這弄清楚為什么這里可能出現(xiàn)問(wèn)題,首先,我們需要弄清楚幾個(gè)概念:原子操作、指令重排。 知識(shí)點(diǎn):什么是原子操作? 簡(jiǎn)單來(lái)說(shuō),原子操作(atomic)就是不可分割的操作,在計(jì)算機(jī)中,就是指不會(huì)因?yàn)榫€程調(diào)度被打斷的操作。 比如,簡(jiǎn)單的賦值是一個(gè)原子操作:
假如m原先的值為0,那么對(duì)于這個(gè)操作,要么執(zhí)行成功m變成了6,要么是沒執(zhí)行m還是0,而不會(huì)出現(xiàn)諸如m=3這種中間態(tài)——即使是在并發(fā)的線程中。 而,聲明并賦值就不是一個(gè)原子操作:
對(duì)于這個(gè)語(yǔ)句,至少有兩個(gè)操作: ①聲明一個(gè)變量n ②給n賦值為6 ——這樣就會(huì)有一個(gè)中間狀態(tài):變量n已經(jīng)被聲明了但是還沒有被賦值的狀態(tài)。 ——這樣,在多線程中,由于線程執(zhí)行順序的不確定性,如果兩個(gè)線程都使用m,就可能會(huì)導(dǎo)致不穩(wěn)定的結(jié)果出現(xiàn)。 知識(shí)點(diǎn):什么是指令重排? 簡(jiǎn)單來(lái)說(shuō),就是計(jì)算機(jī)為了提高執(zhí)行效率,會(huì)做的一些優(yōu)化,在不影響最終結(jié)果的情況下,可能會(huì)對(duì)一些語(yǔ)句的執(zhí)行順序進(jìn)行調(diào)整。 比如,這一段代碼:
正常來(lái)說(shuō),對(duì)于順序結(jié)構(gòu),執(zhí)行的順序是自上到下,也即1234。 但是,由于指令重排的原因,因?yàn)椴挥绊懽罱K的結(jié)果,所以,實(shí)際執(zhí)行的順序可能會(huì)變成3124或者1324。 由于語(yǔ)句3和4沒有原子性的問(wèn)題,語(yǔ)句3和語(yǔ)句4也可能會(huì)拆分成原子操作,再重排。 ——也就是說(shuō),對(duì)于非原子性的操作,在不影響最終結(jié)果的情況下,其拆分成的原子操作可能會(huì)被重新排列執(zhí)行順序。 OK,了解了原子操作和指令重排的概念之后,我們?cè)倮^續(xù)看Version3代碼的問(wèn)題。 下面這段話直接從陳皓的文章(深入淺出單實(shí)例SINGLETON設(shè)計(jì)模式)中復(fù)制而來(lái): 主要在于singleton = new Singleton()這句,這并非是一個(gè)原子操作,事實(shí)上在 JVM 中這句話大概做了下面 3 件事情。 1. 給 singleton 分配內(nèi)存 2. 調(diào)用 Singleton 的構(gòu)造函數(shù)來(lái)初始化成員變量,形成實(shí)例 3. 將singleton對(duì)象指向分配的內(nèi)存空間(執(zhí)行完這步 singleton才是非 null 了) 但是在 JVM 的即時(shí)編譯器中存在指令重排序的優(yōu)化。也就是說(shuō)上面的第二步和第三步的順序是不能保證的,最終的執(zhí)行順序可能是 1-2-3 也可能是 1-3-2。如果是后者,則在 3 執(zhí)行完畢、2 未執(zhí)行之前,被線程二搶占了,這時(shí) instance 已經(jīng)是非 null 了(但卻沒有初始化),所以線程二會(huì)直接返回 instance,然后使用,然后順理成章地報(bào)錯(cuò)。 再稍微解釋一下,就是說(shuō),由于有一個(gè)『instance已經(jīng)不為null但是仍沒有完成初始化』的中間狀態(tài),而這個(gè)時(shí)候,如果有其他線程剛好運(yùn)行到第一層if (instance == null)這里,這里讀取到的instance已經(jīng)不為null了,所以就直接把這個(gè)中間狀態(tài)的instance拿去用了,就會(huì)產(chǎn)生問(wèn)題。 這里的關(guān)鍵在于——線程T1對(duì)instance的寫操作沒有完成,線程T2就執(zhí)行了讀操作。 3.4 終極版本:volatile 對(duì)于Version3中可能出現(xiàn)的問(wèn)題(當(dāng)然這種概率已經(jīng)非常小了,但畢竟還是有的嘛~),解決方案是:只需要給instance的聲明加上volatile關(guān)鍵字即可,Version4版本:
volatile關(guān)鍵字的一個(gè)作用是禁止指令重排,把instance聲明為volatile之后,對(duì)它的寫操作就會(huì)有一個(gè)內(nèi)存屏障(什么是內(nèi)存屏障?),這樣,在它的賦值完成之前,就不用會(huì)調(diào)用讀操作。 注意:volatile阻止的不singleton = new Singleton()這句話內(nèi)部[1-2-3]的指令重排,而是保證了在一個(gè)寫操作([1-2-3])完成之前,不會(huì)調(diào)用讀操作(if (instance == null))。 ——也就徹底防止了Version3中的問(wèn)題發(fā)生。 ——好了,現(xiàn)在徹底沒什么問(wèn)題了吧? …… …… …… 好了,別緊張,的確沒問(wèn)題了。大名鼎鼎的EventBus中,其入口方法EventBus.getDefault()就是用這種方法來(lái)實(shí)現(xiàn)的。 …… …… …… 不過(guò),非要挑點(diǎn)刺的話還是能挑出來(lái)的,就是這個(gè)寫法有些復(fù)雜了,不夠優(yōu)雅、簡(jiǎn)潔。 4. 餓漢式單例 下面再聊了解一下餓漢式的單例。 如上所說(shuō),餓漢式單例是指:指全局的單例實(shí)例在類裝載時(shí)構(gòu)建的實(shí)現(xiàn)方式。 由于類裝載的過(guò)程是由類加載器(ClassLoader)來(lái)執(zhí)行的,這個(gè)過(guò)程也是由JVM來(lái)保證同步的,所以這種方式先天就有一個(gè)優(yōu)勢(shì)——能夠免疫許多由多線程引起的問(wèn)題。 4.1 餓漢式單例的實(shí)現(xiàn)方式 餓漢式單例的實(shí)現(xiàn)如下:
對(duì)于一個(gè)餓漢式單例的寫法來(lái)說(shuō),它基本上是完美的了。 所以它的缺點(diǎn)也就只是餓漢式單例本身的缺點(diǎn)所在了——由于INSTANCE的初始化是在類加載時(shí)進(jìn)行的,而類的加載是由ClassLoader來(lái)做的,所以開發(fā)者本來(lái)對(duì)于它初始化的時(shí)機(jī)就很難去準(zhǔn)確把握:
當(dāng)然,如果所需的單例占用的資源很少,并且也不依賴于其他數(shù)據(jù),那么這種實(shí)現(xiàn)方式也是很好的。 知識(shí)點(diǎn):什么時(shí)候是類裝載時(shí)? 前面提到了單例在類裝載時(shí)被實(shí)例化,那究竟什么時(shí)候才是『類裝載時(shí)』呢? 不嚴(yán)格的說(shuō),大致有這么幾個(gè)條件會(huì)觸發(fā)一個(gè)類被加載: 1. new一個(gè)對(duì)象時(shí) 2. 使用反射創(chuàng)建它的實(shí)例時(shí) 3. 子類被加載時(shí),如果父類還沒被加載,就先加載父類 4. jvm啟動(dòng)時(shí)執(zhí)行的主類會(huì)首先被加載 5. 一些其他的實(shí)現(xiàn)方式 5.1 Effective Java 1 —— 靜態(tài)內(nèi)部類 《Effective Java》一書的第一版中推薦了一個(gè)中寫法:
這種寫法非常巧妙:
——它利用了ClassLoader來(lái)保證了同步,同時(shí)又能讓開發(fā)者控制類加載的時(shí)機(jī)。從內(nèi)部看是一個(gè)餓漢式的單例,但是從外部看來(lái),又的確是懶漢式的實(shí)現(xiàn)。 簡(jiǎn)直是神乎其技。 5.2 Effective Java 2 —— 枚舉 你以為到這就算完了?不,并沒有,因?yàn)閰柡Φ拇笊裼职l(fā)現(xiàn)了其他的方法。 《Effective Java》的作者在這本書的第二版又推薦了另外一種方法,來(lái)直接看代碼:
看到了么?這是一個(gè)枚舉類型……連class都不用了,極簡(jiǎn)。 由于創(chuàng)建枚舉實(shí)例的過(guò)程是線程安全的,所以這種寫法也沒有同步的問(wèn)題。 作者對(duì)這個(gè)方法的評(píng)價(jià): 這種寫法在功能上與共有域方法相近,但是它更簡(jiǎn)潔,無(wú)償?shù)靥峁┝诵蛄谢瘷C(jī)制,絕對(duì)防止對(duì)此實(shí)例化,即使是在面對(duì)復(fù)雜的序列化或者反射攻擊的時(shí)候。雖然這中方法還沒有廣泛采用,但是單元素的枚舉類型已經(jīng)成為實(shí)現(xiàn)Singleton的最佳方法。 枚舉單例這種方法問(wèn)世一些,許多分析文章都稱它是實(shí)現(xiàn)單例的最完美方法——寫法超級(jí)簡(jiǎn)單,而且又能解決大部分的問(wèn)題。 不過(guò)我個(gè)人認(rèn)為這種方法雖然很優(yōu)秀,但是它仍然不是完美的——比如,在需要繼承的場(chǎng)景,它就不適用了。 6. 總結(jié) OK,看到這里,你還會(huì)覺得單例模式是最簡(jiǎn)單的設(shè)計(jì)模式了么?再回頭看一下你之前代碼中的單例實(shí)現(xiàn),覺得是無(wú)懈可擊的么? 可能我們?cè)趯?shí)際的開發(fā)中,對(duì)單例的實(shí)現(xiàn)并沒有那么嚴(yán)格的要求。比如,我如果能保證所有的getInstance都是在一個(gè)線程的話,那其實(shí)第一種最簡(jiǎn)單的教科書方式就夠用了。再比如,有時(shí)候,我的單例變成了多例也可能對(duì)程序沒什么太大影響…… 但是,如果我們能了解更多其中的細(xì)節(jié),那么如果哪天程序出了些問(wèn)題,我們起碼能多一個(gè)排查問(wèn)題的點(diǎn)。早點(diǎn)解決問(wèn)題,就能早點(diǎn)回家吃飯…… —— 還有,完美的方案是不存在,任何方式都會(huì)有一個(gè)『度』的問(wèn)題。比如,你的覺得代碼已經(jīng)無(wú)懈可擊了,但是因?yàn)槟阌玫氖荍AVA語(yǔ)言,可能ClassLoader有些BUG啊……你的代碼誰(shuí)運(yùn)行在JVM上的,可能JVM本身有BUG啊……你的代碼運(yùn)行在手機(jī)上,可能手機(jī)系統(tǒng)有問(wèn)題啊……你生活在這個(gè)宇宙里,可能宇宙本身有些BUG啊…… 所以,盡力做到能做到的最好就行了。 —— 感謝你花費(fèi)了不少時(shí)間看到這里,但愿你沒有覺得虛度。 7. 一些有用的鏈接
8. 關(guān)于作者
看完本文有收獲?請(qǐng)轉(zhuǎn)發(fā)分享給更多人 關(guān)注「ImportNew」,提升Java技能
|
|
來(lái)自: 釋皇天 > 《專業(yè)知識(shí)》