單例模式(SingletonPattern)一般被認(rèn)為是最簡單、最易理解的設(shè)計(jì)模式,也因?yàn)樗暮啙嵰锥?,是?xiàng)目中最常用、最易被識(shí)別出來的模式。既然即使是一個(gè)初級(jí)的程序員,也會(huì)使用單例模式了,為什么我們還要在這里特意地討論它,并且作為第一個(gè)模式來分析呢?事實(shí)上在我看來,單例模式是很有“深度”的一個(gè)模式,要用好、用對它并不是一件簡單的事。
首先,單例模式可以有多種實(shí)現(xiàn)方法,需要根據(jù)情況作出正確的選擇。
看名字就知道單例模式的目標(biāo)就是要確保某個(gè)類只產(chǎn)生一個(gè)實(shí)例,要達(dá)到這個(gè)目的,代碼可以有多種寫法,它們各自有不同的優(yōu)缺點(diǎn),我們要綜合考慮多線程、初始化時(shí)機(jī)、性能優(yōu)化、java 版本、類加載器個(gè)數(shù)等各方面因素,才能做到在合適的情況下選出合用的方法。簡單舉例看一下 Android 或 Java 中,幾個(gè)應(yīng)用了單例模式的場景各自所選擇的實(shí)現(xiàn)方式:
isoChronology,LoggingProxy:餓漢模式;
CalendarAccessControlContext:內(nèi)部靜態(tài)類;
EventBus:雙重檢查加鎖 DCL;
LayoutInflater:容器方式管理的單例服務(wù)之一,通過靜態(tài)語句塊被注冊到 Android 應(yīng)用的服務(wù)中。
其次,單例模式極易被濫用?;旧现滥J降某绦騿T都聽說過單例模式,但是在不熟悉的情況下,單例模式往往被用在使用它并不能帶來好處的場景下。有很多用了單例的代碼并不真的只需要一個(gè)實(shí)例,這時(shí)使用單例模式就會(huì)引入不必要的限制和全局狀態(tài)維護(hù)困難等缺陷。通常說來,適合使用單例模式的機(jī)會(huì)也并不會(huì)太多,如果你的某個(gè)工程中出現(xiàn)了太多單例,你就應(yīng)該重新審視一下你的設(shè)計(jì),詳細(xì)確認(rèn)一下這些場景是否真的都必須要控制實(shí)例的個(gè)數(shù)。
再者,目前對單例模式也出現(xiàn)了不少爭議,使用時(shí)更要上心:
a. 不少人認(rèn)為,單例既負(fù)責(zé)實(shí)例化類并提供全局訪問,又實(shí)現(xiàn)了特定的業(yè)務(wù)邏輯,一定程度上違背了“單一職責(zé)原則”,是反模式的。
b. 單例模式將全局狀態(tài)(global state)引入了應(yīng)用,這是單元測試的大敵。
譬如說 Java 用戶都耳熟能詳?shù)膸讉€(gè)方法:
System.currentTimeMillis();
newDate();
Math.random();
它們是
JVM 中非常常用的暗藏全局狀態(tài)(global state)的方法,全局狀態(tài)會(huì)引入狀態(tài)不確定性(state
indeterminism),導(dǎo)致微妙的副作用,很容易就會(huì)破壞了單元測試的有效性。也就是說多次調(diào)用上述的這些方法,輸出結(jié)果會(huì)不相同;同時(shí)它們的輸出還同代碼執(zhí)行的順序有關(guān),對于單元測試來說,這簡直就是噩夢!要防止?fàn)顟B(tài)從一個(gè)測試被帶到另一個(gè)測試,就不能使用靜態(tài)變量,而單例類通常都會(huì)持有至少一個(gè)靜態(tài)變量(唯一的實(shí)例),現(xiàn)實(shí)中更是靜態(tài)變量頻繁出現(xiàn)的類,從而是測試人員最不想看到的一個(gè)模式。
c. 單例導(dǎo)致了類之間的強(qiáng)耦合,擴(kuò)展性差,違反了面向?qū)ο缶幊痰睦砟睢?/p>
單例封裝了自己實(shí)例的創(chuàng)建,不適用于繼承和多態(tài),同時(shí)創(chuàng)建時(shí)一般也不傳入?yún)?shù)等,難以用一個(gè)模擬對象來進(jìn)行測試。這都不是健康的代碼表現(xiàn)形式。
鑒于上述的這些爭議,有部分程序員逐步將單例模式移除出他們的工程,然而這在我看來實(shí)在是有點(diǎn)因噎廢食,畢竟比起測試的簡便性,代碼是否健壯易用才是我們的關(guān)注點(diǎn)。很多對單例的批評(píng)也是基于因?yàn)椴涣私馑`用所引發(fā)的問題,如果能得到正確的使用,單例也可以發(fā)揮出很強(qiáng)的作用。每個(gè)模式都有它的優(yōu)缺點(diǎn)和適用范圍,相信大家看過的每一本介紹模式的書籍,都會(huì)詳細(xì)寫明某個(gè)模式適用于哪些場景。我的觀點(diǎn)是,我們要做的是更清楚地了解每一個(gè)模式,從而決定在當(dāng)前的應(yīng)用場景是否需要使用,以及如何更好地使用這個(gè)模式。就像《深入淺出設(shè)計(jì)模式》里說的:
使用模式最好的方式是:“把模式裝進(jìn)腦子里,然后在你的設(shè)計(jì)和已有的應(yīng)用中,尋找何處可以使用它們。”
單例模式是經(jīng)得起時(shí)間考驗(yàn)的模式,只是在錯(cuò)誤使用的情況下可能為項(xiàng)目帶來額外的風(fēng)險(xiǎn),因此在使用單例模式之前,我們一定要明確知道自己在做什么,也必須搞清楚為什么要這么做。此文就帶大家好好了解一下單例模式,以求在今后的使用中能正確地將它用在利遠(yuǎn)大于弊的地方,優(yōu)化我們的代碼。
1 單例模式簡介
Singleton 模式可以是很簡單的,一般的實(shí)現(xiàn)只需要一個(gè)類就可以完成,甚至都不需要UML圖就能解釋清楚。在這個(gè)唯一的類中,單例模式確保此類僅有一個(gè)實(shí)例,自行實(shí)例化并提供一個(gè)訪問它的全局公有靜態(tài)方法。
一般在兩種場景下會(huì)考慮使用單例(Singleton)模式:
產(chǎn)生某對象會(huì)消耗過多的資源,為避免頻繁地創(chuàng)建與銷毀對象對資源的浪費(fèi)。如:
對數(shù)據(jù)庫的操作、訪問 IO、線程池(threadpool)、網(wǎng)絡(luò)請求等。
某種類型的對象應(yīng)該有且只有一個(gè)。如果制造出多個(gè)這樣的實(shí)例,可能導(dǎo)致:程序行為異常、資源使用過量、結(jié)果不一致等問題。如果多人能同時(shí)操作一個(gè)文件,又不進(jìn)行版本管理,必然會(huì)有的修改被覆蓋,所以:
一個(gè)系統(tǒng)只能有:一個(gè)窗口管理器或文件系統(tǒng),計(jì)時(shí)工具或 ID(序號(hào))生成器,緩存(cache),處理偏好設(shè)置和注冊表(registry)的對象,日志對象。
單例模式的優(yōu)點(diǎn):可以減少系統(tǒng)內(nèi)存開支,減少系統(tǒng)性能開銷,避免對資源的多重占用、同時(shí)操作。
單例模式的缺點(diǎn):擴(kuò)展很困難,容易引發(fā)內(nèi)存泄露,測試?yán)щy,一定程度上違背了單一職責(zé)原則,進(jìn)程被殺時(shí)可能有狀態(tài)不一致問題。
2 單例的各種實(shí)現(xiàn)
我們經(jīng)??吹降膯卫J?,按加載時(shí)機(jī)可以分為:餓漢方式和懶漢方式;按實(shí)現(xiàn)的方式,有:雙重檢查加鎖,內(nèi)部類方式和枚舉方式等等。另外還有一種通過Map容器來管理單例的方式。它們有的效率很高,有的節(jié)省內(nèi)存,有的實(shí)現(xiàn)得簡單漂亮,還有的則存在嚴(yán)重缺陷,它們大部分使用的時(shí)候都有限制條件。下面我們來分析下各種寫法的區(qū)別,辨別出哪些是不可行的,哪些是推薦的,最后為大家篩選出幾個(gè)最值得我們適時(shí)應(yīng)用到項(xiàng)目中的實(shí)現(xiàn)方式。
因?yàn)橄旅嬉懻摰膯卫龑懛ū容^多,篩選過程略長,結(jié)論先行:
無論以哪種形式實(shí)現(xiàn)單例模式,本質(zhì)都是使單例類的構(gòu)造函數(shù)對其他類不可見,僅提供獲取唯一一個(gè)實(shí)例的靜態(tài)方法,必須保證這個(gè)獲取實(shí)例的方法是線程安全的,并防止反序列化、反射、克?。ā⒍鄠€(gè)類加載器、分布式系統(tǒng))等多種情況下重新生成新的實(shí)例對象。至于選擇哪種實(shí)現(xiàn)方式則取決于項(xiàng)目自身情況,如:是否是復(fù)雜的高并發(fā)環(huán)境、JDK 是哪個(gè)版本的、對單例對象資源消耗的要求等。

上表中僅列舉那些線程安全的實(shí)現(xiàn)方式,永遠(yuǎn)不要使用線程不安全的單例!
另有使用容器管理單例的方式,屬于特殊的應(yīng)用情況,下文單獨(dú)討論。
直觀一點(diǎn),再上一張圖:

此四種單例實(shí)現(xiàn)方式都是線程安全的,是實(shí)現(xiàn)單例時(shí)不錯(cuò)的選擇
下文會(huì)詳細(xì)給出的三種餓漢模式差別不大,一般使用第二種 static factory 方式
下面就來具體談一下各種單例實(shí)現(xiàn)方式及適用范圍。
2.1 線程安全
作為一個(gè)單例,我們首先要確保的就是實(shí)例的“唯一性”,有很多因素會(huì)導(dǎo)致“唯一性”失效,它們包括:多線程、序列化、反射、克隆等,更特殊一點(diǎn)的情況還有:分布式系統(tǒng)、多個(gè)類加載器等等。其中,多線程問題最為突出。為了提高應(yīng)用的工作效率,現(xiàn)如今我們的工程中基本上都會(huì)用到多線程;目前使用單線程能輕松完成的任務(wù),日復(fù)一日,隨著業(yè)務(wù)邏輯的復(fù)雜化、用戶數(shù)量的遞增,也有可能要被升級(jí)為多線程處理。所以任何在多線程下不能保證單個(gè)實(shí)例的單例模式,我都認(rèn)為應(yīng)該立即被棄用。
在只考慮一個(gè)類加載器的情況下,“餓漢方式”實(shí)現(xiàn)的單例(在系統(tǒng)運(yùn)行起來裝載類的時(shí)候就進(jìn)行初始化實(shí)例的操作,由
JVM
虛擬機(jī)來保證一個(gè)類的初始化方法在多線程環(huán)境中被正確加鎖和同步,所以)是線程安全的,而“懶漢”方式則需要注意了,先來看一種最簡單的“懶漢方式”的單例:

這種寫法只能在單線程下使用。如果是多線程,可能發(fā)生一個(gè)線程通過并進(jìn)入了if (singleton == null)判斷語句塊,但還未來得及創(chuàng)建新的實(shí)例時(shí),另一個(gè)線程也通過了這個(gè)判斷語句,兩個(gè)線程最終都進(jìn)行了創(chuàng)建,導(dǎo)致多個(gè)實(shí)例的產(chǎn)生。所以在多線程環(huán)境下必須摒棄此方式。
除了多并發(fā)的情況,實(shí)現(xiàn)單例模式時(shí)另一個(gè)重要的考量因素是效率。前述的“懶漢方式”的多線程問題可以通過加上synchronized修飾符解決,但考慮到性能,一定不要簡單粗暴地將其添加在如下位置:

上述方式通過為getInstence()方法增加synchronized關(guān)鍵字,迫使每個(gè)線程在進(jìn)入這個(gè)方法前,要先等候別的線程離開該方法,即不會(huì)有兩個(gè)線程可以同時(shí)進(jìn)入此方法執(zhí)行new Singleton(),從而保證了單例的有效。但它的致命缺陷是效率太低了,每個(gè)線程每次執(zhí)行g(shù)etInstance()方法獲取類的實(shí)例時(shí),都會(huì)進(jìn)行同步。而事實(shí)上實(shí)例創(chuàng)建完成后,同步就變?yōu)椴槐匾拈_銷了,這樣做在高并發(fā)下必然會(huì)拖垮性能。所以此方法雖然可行但也不推薦。那我們將同步方法改為同步代碼塊是不是就能減少同步對性能的影響了呢:

但是這種同步卻并不能做到線程安全,同最初的懶漢模式一個(gè)道理,它可能產(chǎn)生多個(gè)實(shí)例,所以亦不可行。我們必須再增加一個(gè)單例不為空的判斷來確保線程安全,也就是所謂的“雙重檢查鎖定”(Double Check Lock(DCL))方式:

此方法的“Double-Check”體現(xiàn)在進(jìn)行了兩次if (singleton == null)的檢查,這樣既同步代碼塊保證了線程安全,同時(shí)實(shí)例化的代碼也只會(huì)執(zhí)行一次,實(shí)例化后同步操作不會(huì)再被執(zhí)行,從而效率提升很多(詳細(xì)比較見附錄 1)。
雙重檢查鎖定(DCL)方式也是延遲加載的,它唯一的問題是,由于
Java 編譯器允許處理器亂序執(zhí)行,在 JDK 版本小于 1.5 時(shí)會(huì)有 DCL 失效的問題(原因解釋詳見附錄 2)。當(dāng)然,現(xiàn)在大家使用的
JDK 普遍都已超過 1.4,只要在定義單例時(shí)加上 1.5 及以上版本具體化了的 volatile
關(guān)鍵字,即可保證執(zhí)行的順序,從而使單例起效。所以 DCL 方式是推薦的一種方式。
Android 中鼎鼎大名的Universal Image Loader和EventBus都是采用了這種方式的單例,下面節(jié)選的源碼片段就是從它們的 GitHub 工程內(nèi)拷貝過來的:


EventBus 是一個(gè)事件發(fā)布和訂閱的框架,各個(gè)組件向全局唯一的一個(gè) EventBus 對象注冊自己,就能發(fā)布和接收到 event 事件。
我們項(xiàng)目中用到的 DCL 方式實(shí)例分析:
VersionManager:
版本控制類,主要用于應(yīng)用啟動(dòng)時(shí)判斷當(dāng)前屬于:新安裝、更新、沒有改變?nèi)N情況中的哪一種,從而決定是否要檢查更新、顯示引導(dǎo)頁、拉取素材等等。這個(gè)類在應(yīng)用啟動(dòng)時(shí)就使用,貌似使用急切加載更合適,但是由于它是根據(jù) Preference 中記錄的版本號(hào)來實(shí)現(xiàn)判斷的,在項(xiàng)目的PrefsUtils類初始化完 preference 成員變量以后才會(huì)被使用,所以使用 DCL 方式完全合適。
PoiManager:拉取地理位置信息(用于拼圖及 Webview);WtLoginManager:QQ 登錄使用;WeiboManager:新浪微博登錄分享使用;CollageTemplateManager,CollageDataManager,CollageDataObserver:拼圖的模板、數(shù)據(jù)、天氣地理位置信息等的管理類:這些類都只有在進(jìn)入了相應(yīng)模塊或使用某一功能時(shí)才會(huì)被用到,所以使用 DCL 方式。它們中幾個(gè)持有較多資源的類,甚至還寫了destroy()方法,可以在退出功能或使用完成時(shí)釋放資源,銷毀單例。以CollageTemplateManager類為例,它載入了模板描述文件、縮略圖等較多的資源,而退出拼圖功能模塊后在其他模塊中都不會(huì)再被使用。代碼如下:

我們最后再看一種延遲加載的“靜態(tài)內(nèi)部類”方式:

這種方式利用了 classloder 的機(jī)制來保證初始化 instance 時(shí)只會(huì)有一個(gè)。需要注意的是:雖然它的名字中有“靜態(tài)”兩字,但它是屬于“懶漢模式”的??!這種方式的 Singleton 類被裝載時(shí),只要 SingletonHolder 類還沒有被主動(dòng)使用,instance 就不會(huì)被初始化。只有在顯式調(diào)用getInstance()方法時(shí),才會(huì)裝載 SingletonHolder 類,從而實(shí)例化對象。
“靜態(tài)內(nèi)部類”方式基本上彌補(bǔ)了
DCL 方式在 JDK 版本低于 1.5 時(shí)高并發(fā)環(huán)境失效的缺陷?!禞ava并發(fā)編程實(shí)踐》中也指出 DCL
方式的“優(yōu)化”是丑陋的,對靜態(tài)內(nèi)部類方式推崇備至。但是可能因?yàn)橥蠹覄?chuàng)建單例時(shí)的思考習(xí)慣不太一致(根據(jù)單例模式的特點(diǎn),一般首先想到的是通過
instance
判空來確保單例),此方式并不特別常見,然而它是所有懶加載的單例實(shí)現(xiàn)中適用范圍最廣、限制最小、最為推薦的一種。(下述的枚舉方式限制也很少,但是可能更不易理解。)
我們的 Android 項(xiàng)目中也用到了“靜態(tài)內(nèi)部類”方式來實(shí)現(xiàn)單例:
SoundController:用于控制拍照時(shí)的快門聲音。由于用戶很少會(huì)修改拍照快門聲,所以此功能采用了延遲加載,靜態(tài)內(nèi)部類方式簡潔又方便。話說回來,因?yàn)槭褂妙l率低,此處即使是使用同步方法的懶漢模式也沒有什么問題。
至此,所有的常用懶漢模式都已討論完畢,僅推薦“雙重檢查鎖定”(DCL)方式(符合思考邏輯)和“靜態(tài)內(nèi)部類”方式(任意 JDK 版本可用),它們共同的特點(diǎn)是:懶加載、線程安全、效率較高。
2.2 加載時(shí)機(jī)
除了高并發(fā)下的線程安全,對于單例模式另一個(gè)必須要考慮的問題是加載的時(shí)機(jī),也就是要在延遲加載和急切加載間做出選擇。之前已經(jīng)看了懶漢加載的單例實(shí)現(xiàn)方法,這里再給出兩種餓漢加載方式:



這三種方式差別不大,都依賴 JVM 在類裝載時(shí)就完成唯一對象的實(shí)例化,基于類加載的機(jī)制,它們天生就是線程安全的,所以都是可行的,第二種更易于理解也比較常見。
那么我們到底什么時(shí)候選擇懶加載,什么時(shí)候選擇餓加載呢?
首先,餓漢式的創(chuàng)建方式對使用的場景有限制。如果實(shí)例創(chuàng)建時(shí)依賴于某個(gè)非靜態(tài)方法的結(jié)果,或者依賴于配置文件等,就不考慮使用餓漢模式了(靜態(tài)變量也是同樣的情況)。但是這些情況并不常見,我們主要考慮的還是兩種方法對空間和時(shí)間利用率上的差別。
餓漢式因?yàn)樵陬悇?chuàng)建的同時(shí)就實(shí)例化了靜態(tài)對象,其資源已經(jīng)初始化完成,所以第一次調(diào)用時(shí)更快,優(yōu)勢在于速度和反應(yīng)時(shí)間,但是不管此單例會(huì)不會(huì)被使用,在程序運(yùn)行期間會(huì)一直占據(jù)著一定的內(nèi)存;而懶漢式是延遲加載的,優(yōu)點(diǎn)在于資源利用率高,但第一次調(diào)用時(shí)的初始化工作會(huì)導(dǎo)致性能延遲,以后每次獲取實(shí)例時(shí)也都要先判斷實(shí)例是否被初始化,造成些許效率損失。
所以這是一個(gè)空間和時(shí)間之間的選擇題,如果一個(gè)類初始化需要耗費(fèi)很多時(shí)間,或應(yīng)用程序總是會(huì)使用到該單例,那建議使用餓漢模式;如果資源要占用較多內(nèi)存,或一個(gè)類不一定會(huì)被用到,或資源敏感,則可以考慮懶漢模式。
有人戲稱單例為“內(nèi)存泄露”,即使一直沒有人使用,它也占據(jù)著內(nèi)存。所以再重申一遍,在使用單例模式前先考慮清楚是否必須,對于那些不是頻繁創(chuàng)建和銷毀,且創(chuàng)建和銷毀也不會(huì)消耗太多資源的情況,不要因?yàn)槭紫认氲降氖菃卫J骄褪褂昧怂?/p>
下面我們先看一下項(xiàng)目中用到的餓漢單例的例子:
根據(jù)業(yè)務(wù)邏輯需要在程序一啟動(dòng)的時(shí)候就進(jìn)行操作的類有:
SimpleRequest:啟動(dòng)時(shí)拉取相機(jī)配置和熱補(bǔ)丁
HotFixEngine:熱補(bǔ)丁應(yīng)用類
CameraAttrs:相機(jī)屬性,包括黑名單等
DeviceInstance:(拍照)設(shè)備信息類
VideoDeviceInstance:視頻設(shè)備信息類
OpDataManager:運(yùn)營信息管理,包括:廣告頁、首頁 icon、首頁 banner、應(yīng)用推薦、紅點(diǎn)角標(biāo)等等
其中典型的HotFixEngine類用于加載 hack dex 包,需要盡早執(zhí)行,不然會(huì)出現(xiàn)一堆 java.lang.ClassNotFoundException? 錯(cuò)誤。最好的執(zhí)行時(shí)機(jī)是在 Application 的 attachBaseContext 中(如果工程中引入了 multidex 的,則放在 multidex 之后執(zhí)行),所以采用了餓漢模式。
也有在整個(gè)程序運(yùn)行過程中從頭至尾都需要用到,最好不要頻繁創(chuàng)建回收的類:
MemoryManager:所有縮略圖的 cache,大圖、拼圖模板等的管理
PerformanceLog:性能打點(diǎn)
DataReport:數(shù)據(jù)上報(bào)
最后是其實(shí)不太適合使用餓漢模式,可以修改為懶漢模式的類:
LoginManager:登錄管理和 WxLoginManager:微信登錄管理,其實(shí)這兩個(gè)類是之前同空間的話題圈合作時(shí),工程集成了社區(qū)化功能,首頁就需要拉取用戶消息所引入的類。當(dāng)時(shí)采用急切加載是非常合理且符合需求的,但是由于近期將社區(qū)化功能弱化以后,只有在用戶反饋時(shí)才需要登錄,這兩個(gè)類在后續(xù)改為延遲加載會(huì)更好。
SownloadFailDialogue:拉取 banner 后臺(tái)協(xié)議出錯(cuò)時(shí)彈出對話框。最大問題是,這是出錯(cuò)時(shí)才會(huì)用到的類,很少需要使用,餓漢模式顯然過于“急切”了。
FaceValueDetector:人臉數(shù)值檢測(夫妻相等)和 VideoPreviewFaceOutLineDetector:人臉檢測 & 人臉追蹤,并不一定會(huì)使用到,可以考慮修改為懶漢式。
之前已經(jīng)舉過 DCL 和靜態(tài)內(nèi)部類實(shí)現(xiàn)的單例模式,都沒有問題,不過項(xiàng)目中也發(fā)現(xiàn)了一些同步方法的懶漢單例模式,這些類有空的話,最好還是可以修改成前兩種方式:
CameraManager:相機(jī)管理類
MaterialDownloadBroadcast:素材下載廣播類
2.3 其他需要注意的對單例模式的破壞
2.3.1 序列化
除了多線程,序列化也可能破壞單例模式一個(gè)實(shí)例的要求。
序列化一是可以將一個(gè)單例的實(shí)例對象寫到磁盤,實(shí)現(xiàn)數(shù)據(jù)的持久化;二是實(shí)現(xiàn)對象數(shù)據(jù)的遠(yuǎn)程傳輸。當(dāng)單例對象有必要實(shí)現(xiàn)
Serializable
接口時(shí),即使將其構(gòu)造函數(shù)設(shè)為私有,在它反序列化時(shí)依然會(huì)通過特殊的途徑再創(chuàng)建類的一個(gè)新的實(shí)例,相當(dāng)于調(diào)用了該類的構(gòu)造函數(shù)有效地獲得了一個(gè)新實(shí)例!下述代碼就展示了一般情況下行之有效的餓漢式單例,在反序列化情況下不再是單例。

輸出如下:
Is singleton pattern normally valid: true
Is singleton pattern valid for deserialize: false
要避免單例對象在反序列化時(shí)重新生成對象,則在implements Serializable的同時(shí)應(yīng)該實(shí)現(xiàn)readResolve()方法,并在其中保證反序列化的時(shí)候獲得原來的對象。
(注:readResolve()是反序列化操作提供的一個(gè)很特別的鉤子函數(shù),它在從流中讀取對象的readObject(ObjectInputStream)方法之后被調(diào)用,可以讓開發(fā)人員控制對象的反序列化。我們在readResolve()方法中用原來的 instance 替換掉從流中讀取到的新創(chuàng)建的 instance,就可以避免使用序列化方式破壞了單例。)

在單例中加入上述代碼后,輸出即變?yōu)椋?/p>
Is singleton pattern normally valid: true
Is singleton pattern valid for deserialize with readResolve(): true
單例有效。
如果想要比較“優(yōu)雅”地避免上述問題,最好的方式其實(shí)是使用枚舉。這種方式也是
Effective Java 作者 Josh Bloch 在 item 3
討論中提倡的方式。枚舉不僅在創(chuàng)建實(shí)例的時(shí)候默認(rèn)是線程安全的,而且在反序列化時(shí)可以自動(dòng)防止重新創(chuàng)建新的對象。實(shí)現(xiàn)如下:

枚舉類型是有“實(shí)例控制”的類,確保了不會(huì)同時(shí)有兩個(gè)實(shí)例,即當(dāng)且僅當(dāng)a=b時(shí)a.equals(b),用戶也可以用==操作符來替代equals(Object)方法來提高效率。使用枚舉來實(shí)現(xiàn)單例還可以不用getInstance()方法(當(dāng)然,如果你想要適應(yīng)大家的習(xí)慣用法,加上getInstance()方法也是可以的),直接通過Singleton.INSTANCE來拿取實(shí)例。枚舉類是在第一次訪問時(shí)才被實(shí)例化,是懶加載的。它寫法簡單,并板上釘釘?shù)乇WC了在任何情況(包括反序列化,以及后面會(huì)談及的反射、克?。┫露际且粋€(gè)單例。不過由于枚舉是 JDK 1.5 才加入的特性,所以同 DCL 方式一樣,它對 JDK 的版本也有要求。因?yàn)榇朔ㄔ谠缙?JDK 版本不支持,且和一般單例寫起來的思路不太一樣,還沒有被廣泛使用,使用時(shí)也可能會(huì)比較生疏。所以在實(shí)際工作中,很少看見這種用法,在我們的項(xiàng)目中甚至沒有找到一例應(yīng)用的實(shí)例。
2.3.2 反射
除了多線程、反序列化以外,反射也會(huì)對單例造成破壞。反射可以通過setAccessible(true)來繞過 private 限制,從而調(diào)用到類的私有構(gòu)造函數(shù)創(chuàng)建對象。我們來看下面的代碼:

將會(huì)打?。?/p>
Is singleton pattern normally valid: true
Is singleton pattern valid for Reflection: false
說明使用反射調(diào)利用私有構(gòu)造器也是可以破壞單例的,要防止此情況發(fā)生,可以在私有的構(gòu)造器中加一個(gè)判斷,需要?jiǎng)?chuàng)建的對象不存在就創(chuàng)建;存在則說明是第二次調(diào)用,拋出RuntimeException提示。修改私有構(gòu)造函數(shù)代碼如下:

這樣一旦程序中出現(xiàn)代碼使用反射方式二次創(chuàng)建單例時(shí),就會(huì)打印出:
Is singleton pattern normally valid: true
java.lang.reflect.InvocationTargetException
Caused by: java.lang.RuntimeException: Cannot construct a Singleton more than once!
另外,同反序列化相似,也可以使用枚舉的方式來杜絕反射的破壞。當(dāng)我們通過反射方式來創(chuàng)建枚舉類型的實(shí)例時(shí),會(huì)拋出“Exception in thread "main" java.lang.NoSuchMethodException: net.local.singleton.EnumSingleton.()”異常。所以雖然不常見,但是枚舉確實(shí)可以作為實(shí)現(xiàn)單例的第一選擇。
2.3.3 克隆
clone()是 Object 的方法,每一個(gè)對象都是 Object 的子類,都有clone()方法。clone()方法并不是調(diào)用構(gòu)造函數(shù)來創(chuàng)建對象,而是直接拷貝內(nèi)存區(qū)域。因此當(dāng)我們的單例對象實(shí)現(xiàn)了 Cloneable 接口時(shí),盡管其構(gòu)造函數(shù)是私有的,仍可以通過克隆來創(chuàng)建一個(gè)新對象,單例模式也相應(yīng)失效了。即:

輸出為:
Is singleton pattern normally valid: true
Is singleton pattern valid for clone: false
所以單例模式的類是不可以實(shí)現(xiàn) Cloneable 接口的,這與 Singleton 模式的初衷相違背。那要如何阻止使用clone()方法創(chuàng)建單例實(shí)例的另一個(gè)實(shí)例?可以override它的clone()方法,使其拋出異常。(也許你想問既然知道了某個(gè)類是單例且單例不應(yīng)該實(shí)現(xiàn) Cloneable 接口,那不實(shí)現(xiàn)該接口不就可以了嗎?事實(shí)上盡管很少見,但有時(shí)候單例類可以繼承自其它類,如果其父類實(shí)現(xiàn)了clone()方法的話,就必須在我們的單例類中復(fù)寫clone()方法來阻止對單例的破壞。)

輸出:
Is singleton pattern normally valid: true
java.lang.CloneNotSupportedException
P.S. Enum 是沒有clone()方法的。
2.4 登記式單例——使用 Map 容器來管理單例模式
在我們的程序中,隨著迭代版本的增加,代碼也越來越復(fù)雜,往往會(huì)使用到多個(gè)處理不同業(yè)務(wù)的單例,這時(shí)我們就可以采用
Map
容器來統(tǒng)一管理這些單例,使用時(shí)通過統(tǒng)一的接口來獲取某個(gè)單例。在程序的初始,我們將一組單例類型注入到一個(gè)統(tǒng)一的管理類中來維護(hù),即將這些實(shí)例存放在一個(gè)
Map 登記薄中,在使用時(shí)則根據(jù) key 來獲取對象對應(yīng)類型的單例對象。對于已經(jīng)登記過的實(shí)例,從 Map
直接返回實(shí)例;對于沒有登記的,則先登記再返回。從而在對用戶隱藏具體實(shí)現(xiàn)、降低代碼耦合度的同時(shí),也降低了用戶的使用成本。簡易版代碼實(shí)現(xiàn)如下:

Android 的系統(tǒng)核心服務(wù)就是以如上形式存在的,以達(dá)到減少資源消耗的目的。其中最為大家所熟知的服務(wù)有 LayoutInflater Service,它就是在虛擬機(jī)第一次加載 ContextImpl 類時(shí),以單例形式注冊到系統(tǒng)中的一個(gè)服務(wù),其它系統(tǒng)級(jí)的服務(wù)還有:WindowsManagerService、ActivityManagerService 等。JVM 第一次加載調(diào)用 ContextImpl 的registerService()方法,將這些服務(wù)以鍵值對的形式(以 service name 為鍵,值則是對應(yīng)的 ServiceFetcher)存儲(chǔ)在一個(gè) HashMap 中,要使用時(shí)通過 key 拿到所需的 ServiceFetcher 后,再通過 ServiceFetcher 的getService()方法來獲取具體的服務(wù)對象。在第一次使用服務(wù)時(shí),ServiceFetcher 調(diào)用createService()方法創(chuàng)建服務(wù)對象,并緩存到一個(gè)列表中,下次再取時(shí)就可以直接從緩存中獲取,無需重復(fù)創(chuàng)建對象,從而實(shí)現(xiàn)單例的效果。
3 關(guān)于單例模式的其他問題(Q & A)
3.1 還有其他情況會(huì)使單例模式失效嗎?
是的,其實(shí)前文有提到過,上述的所有討論都是基于一個(gè)類加載器(class loader)的情況。由于每個(gè)類加載器有各自的命名空間,static關(guān)鍵詞的作用范圍也不是整個(gè) JVM,而只到類加載器,也就是說不同的類加載器可以加載同一個(gè)類。所以當(dāng)一個(gè)工程下面存在不止一個(gè)類加載器時(shí),整個(gè)程序中同一個(gè)類就可能被加載多次,如果這是個(gè)單例類就會(huì)產(chǎn)生多個(gè)單例并存失效的現(xiàn)象。因此當(dāng)程序有多個(gè)類加載器又需要實(shí)現(xiàn)單例模式,就須自行指定類加載器,并要指定同一個(gè)類加載器?;谕瑯拥脑?,分布式系統(tǒng)和集群系統(tǒng)也都可能出現(xiàn)單例失效的情況,這就需要利用數(shù)據(jù)庫或者第三方工具等方式來解決失效的問題了。

3.2 單例的構(gòu)造函數(shù)是私有的,那還能不能繼承單例?
單例是不適合被繼承的,要繼承單例就要將構(gòu)造函數(shù)改成公開的或受保護(hù)的(僅考慮 Java 中的情況),這就會(huì)導(dǎo)致:
1)別的類也可以實(shí)例化它了,無法確保實(shí)例“獨(dú)一無二”,這顯然有違單例的設(shè)計(jì)理念。
2) 因?yàn)閱卫膶?shí)例是使用的靜態(tài)變量,所有的派生類事實(shí)上是共享同一個(gè)實(shí)例變量的,這種情況下要想讓子類們維護(hù)正確的狀態(tài),順利工作,基類就不得不實(shí)現(xiàn)注冊表(Registry)功能了。
要實(shí)現(xiàn)單例模式的代碼非常簡潔,任意現(xiàn)有的類,添加十?dāng)?shù)行代碼后,就可以改造為單例模式。也許繼承并不是一個(gè)好主意。同時(shí),也應(yīng)該審視一下單例模式是否在此處被濫用了,在需要繼承和擴(kuò)展的情況下,一開始就不要使用單例模式,這會(huì)為你省下很多時(shí)間??傊瑳Q定一下對你的需求來說,到底是單例更重要還是可繼承更重要。
3.3 單例有沒有違反“單一責(zé)任原則”?
單例確實(shí)承擔(dān)了兩個(gè)責(zé)任,它不僅僅負(fù)責(zé)管理自己的實(shí)例并提供全局訪問,還要處理應(yīng)用程序的某個(gè)業(yè)務(wù)邏輯。但是由類來管理自己的實(shí)例的方式可以讓整體設(shè)計(jì)更簡單易懂,單例類自己負(fù)責(zé)實(shí)例的創(chuàng)建也已經(jīng)是很多程序員耳熟能詳?shù)淖龇耍螞r單例模式的創(chuàng)建只需要屈指可數(shù)的幾行代碼,在結(jié)構(gòu)不復(fù)雜的情況下,單獨(dú)將其移到其它類中并不一定經(jīng)濟(jì)。
當(dāng)然在代碼繁復(fù)的情況下優(yōu)化你的設(shè)計(jì),讓單例類專注于自己的業(yè)務(wù)責(zé)任,將它的實(shí)例化以及對對象個(gè)數(shù)的控制封裝在一個(gè)工廠類或生成器中,也是較好的解決方案。除了遵循了“單一責(zé)任原則”,這樣做的另一個(gè)好處,是可以在創(chuàng)建的時(shí)候傳入?yún)?shù),解耦了類,對對象的創(chuàng)建有了更好的控制,也使使用模擬對象(Mock
Object)完成測試目標(biāo)成為可能,基本上解決了文章開頭談到的單例是測試不友好的爭議。
3.4 是否可以把一個(gè)類的所有方法和變量都定義為靜態(tài)的,把此類直接當(dāng)作單例來使用?
事實(shí)上在最開始討論過的,Java 里的java.lang.System類以及java.lang.Math類都是這么做的,它們的全部方法都用static關(guān)鍵詞修飾,包裝起來提供類級(jí)訪問??梢钥吹剑琈ath 類把 Java 基本類型值運(yùn)算的相關(guān)方法組織了起來,當(dāng)我們調(diào)用 Math 類的某個(gè)類方法時(shí),所要做的都只是數(shù)據(jù)操作,并不涉及到對象的狀態(tài),對這樣的工具類來說實(shí)例化沒有任何意義。所以如果一個(gè)類是自給自足的,初始化簡潔,也不需要維護(hù)任何狀態(tài),僅僅是需要將一些工具方法集中在一起,并提供給全局使用,那么確實(shí)可以使用靜態(tài)類和靜態(tài)方法來達(dá)到單例的效果。但如果單例需要訪問資源并對象狀態(tài)是關(guān)注點(diǎn)之一時(shí),則應(yīng)該使用普通的單例模式。
靜態(tài)方法會(huì)比一般的單例更快,因?yàn)殪o態(tài)的綁定是在編譯期就進(jìn)行的。但是也要注意到,靜態(tài)初始化的控制權(quán)完全握在
Java
手上,當(dāng)涉及到很多類時(shí),這么做可能會(huì)引起一些微妙而不易察覺的,和初始化次序有關(guān)的bug。除非絕對必要,確保一個(gè)對象只有一個(gè)實(shí)例,會(huì)比類只有一個(gè)單例更保險(xiǎn)。
3.5 考慮技術(shù)實(shí)現(xiàn)時(shí),如何從單例模式和全局變量中作出選擇?
全局變量雖然使用起來比較簡單,但相對于單例有如下缺點(diǎn):
1) 全局變量只是提供了對象的全局的靜態(tài)引用,但并不能確保只有一個(gè)實(shí)例;
2) 全局變量是急切實(shí)例化的,在程序一開始就創(chuàng)建好對象,對非常耗費(fèi)資源的對象,或是程序執(zhí)行過程中一直沒有用到的對象,都會(huì)形成浪費(fèi);
3) 靜態(tài)初始化時(shí)可能信息不完全,無法實(shí)例化一個(gè)對象。即可能需要使用到程序中稍后才計(jì)算出來的值才能創(chuàng)建單例;
4) 使用全局變量容易造成命名空間(namespace)污染。
3.6 據(jù)說垃圾收集器會(huì)將沒有引用的單例清除?
比較早的 Java 版本(JVM ≤ 1.2)的垃圾收集器確實(shí)有 bug,會(huì)把沒有全局引用的單例當(dāng)作垃圾清除。假設(shè)一個(gè)單例被創(chuàng)建并使用以后,它實(shí)例里的一些變量發(fā)生了變化。此時(shí)引用它的類被銷毀了,除了它本身以外,再?zèng)]有類引用它,那么一小會(huì)兒后,它會(huì)就被 Java 的垃圾收集器給清除了。這樣再次調(diào)用此單例類的getInstance()時(shí)會(huì)重新生成一個(gè)單例,使用時(shí)會(huì)發(fā)現(xiàn)之前更新過的實(shí)例的變量值都回到了最原始的設(shè)置(如網(wǎng)絡(luò)連接被重新設(shè)置等),一切都混亂了。這個(gè) bug 在 1.2 以后的版本已經(jīng)被修復(fù),但是如果還在使用 Java 1.3 之前的版本,必須建立單例注冊表,增加全局引用來避免垃圾收集器將單例回收。
3.7 可以用單例對象 Application 來解決組件見傳遞數(shù)據(jù)的問題嗎?
在
Android 應(yīng)用啟動(dòng)后、任意組件被創(chuàng)建前,系統(tǒng)會(huì)自動(dòng)為應(yīng)用創(chuàng)建一個(gè) Application
類(或其子類)的對象,且只創(chuàng)建一個(gè)。從此它就一直在那里,直到應(yīng)用的進(jìn)程被殺掉。所以雖然 Application
并沒有采用單例模式來實(shí)現(xiàn),但是由于它的生命周期由框架來控制,和整個(gè)應(yīng)用的保持一致,且確保了只有一個(gè),所以可以被看作是一個(gè)單例。
一個(gè)
Android 應(yīng)用總有一些信息,譬如說一次耗時(shí)計(jì)算的結(jié)果,需要被用在多個(gè)地方。如果將需要傳遞的對象塞到 intent
里或者存儲(chǔ)到數(shù)據(jù)庫里來進(jìn)行傳遞,存取都要分別寫代碼來實(shí)現(xiàn),還是有點(diǎn)麻煩的。既然 Application(或繼承它的子類)對于 App 中的所有
activity 和 service 都可見,而且隨著 App 啟動(dòng),它自始至終都在那里,就不禁讓我們想到,何不利用 Application
來持有內(nèi)部變量,從而實(shí)現(xiàn)在各組件間傳遞、分享數(shù)據(jù)呢?這看上去方便又優(yōu)雅,但卻是完全錯(cuò)誤的一種做法?。∪绻闶褂昧巳缟献龇?,那你的應(yīng)用最終要么會(huì)因?yàn)槿〔坏綌?shù)據(jù)發(fā)生
NullPointerException 而崩潰,要么就是取到了錯(cuò)誤的數(shù)據(jù)。
我們來看一個(gè)具體的例子:
1) 在我們的 App 啟動(dòng)后的第一個(gè) Activity A 中,會(huì)要求用戶輸入需要顯示的字符串,假設(shè)為 “Hello, Singlton!”,然后我們把它作為全局變量 showString 保存在 Application 中;
2) 然后從 Activity A 中 startActivity() 跳轉(zhuǎn)到 Activity B,我們從 Application 對象中將 showString 取出來并顯示到屏幕上。目前看起來,一切都很正常。
3) 但是如果我們按了 Home 鍵將 App 退到后臺(tái),那么在等了較長的時(shí)間后,系統(tǒng)可能會(huì)因?yàn)閮?nèi)存不夠而回收了我們的應(yīng)用。(也可以直接手動(dòng)殺進(jìn)程。)
4) 此時(shí)再打開我們的 App,系統(tǒng)會(huì)重新創(chuàng)建一個(gè) Application 對象,并恢復(fù)到剛剛離開時(shí)的頁面,即跳轉(zhuǎn)到 Activity B。
5) 當(dāng) Activity B 再次運(yùn)行到向 Application 對象拿取 showString 并顯示時(shí),就會(huì)發(fā)現(xiàn)現(xiàn)在顯示的不再是“Hello, Singlton!”了,而是空字符串。
這是因?yàn)樵谖覀冃陆ǖ?Application 對象中,showString并沒有被賦值,所以為 null。如果我們在顯示前先將字符串全部變?yōu)榇髮懀瑂howString.toUpperCase(),我們的程序甚至?xí)虼硕?crash?。?/p>
究其本質(zhì),Application 不會(huì)永遠(yuǎn)駐留在內(nèi)存里,隨著進(jìn)程被殺掉,Application 也被銷毀了,再次使用時(shí),它會(huì)被重新創(chuàng)建,它之前保存下來的所有狀態(tài)都會(huì)被重置。
要預(yù)防這個(gè)問題,我們不能用 Application 對象來傳遞數(shù)據(jù),而是要:
1) 通過傳統(tǒng)的 intent 來顯式傳遞數(shù)據(jù)(將 Parcelable 或 Serializable 對象放入Intent / Bundle。Parcelable 性能比 Serializable 快一個(gè)量級(jí),但是代碼實(shí)現(xiàn)要復(fù)雜一些)。
2) 重寫onSaveInstanceState()以及onRestoreInstanceState()方法,確保進(jìn)程被殺掉時(shí)保存了必須的應(yīng)用狀態(tài),從而在重新打開時(shí)可以正確恢復(fù)現(xiàn)場。
3) 使用合適的方式將數(shù)據(jù)保存到數(shù)據(jù)庫或硬盤。
4) 總是做判空保護(hù)和處理。
上述這個(gè)問題除了 Application 類存在,App 中的任何一個(gè)單例或者公共的靜態(tài)變量都存在,這就要求我們寫出健壯的代碼來好好來維護(hù)它們的狀態(tài),也要在考慮是否使用單例時(shí)慎之又慎。
3.8 在 Android 中使用單例還有哪些需要注意的地方
單例在 Android 中的生命周期等于應(yīng)用的生命周期,所以要特別小心它持有的對象是否會(huì)造成內(nèi)存泄露。如果將 Activity 等 Context 傳遞給單例又沒有釋放,就會(huì)發(fā)生內(nèi)存泄露,所以最好僅傳遞給單例 Application Context。
4 舉一個(gè)例子
我們的某個(gè)項(xiàng)目中單例的實(shí)現(xiàn)略有點(diǎn)特別,它把單例抽象了出來,寫了一個(gè)抽象的 Singlton 泛型類:

所有的單例創(chuàng)建都是在繼承了 Application 的 XXXXXApplication 類中,以其中以用于登錄和注冊的單例為例,首先創(chuàng)建單例,使用時(shí)只需要調(diào)用XXXXXApplication.getLoginManager()就可以拿到實(shí)例了:

說實(shí)話,當(dāng)年我咋一看到這個(gè)單例實(shí)現(xiàn),覺得那是相當(dāng)?shù)摹案叽笊稀保坪跻埠芎糜茫和瑫r(shí)用到了抽象類和泛型類,安全性高,靈活性好,通用性強(qiáng);用全局唯一的 Application 類來統(tǒng)一管理各個(gè)單例也貌似再合適不過,但是如果我們仔細(xì)分析一下的話,可以發(fā)現(xiàn)這種實(shí)現(xiàn)方式有不少問題:
1. 雖然使用泛型感覺是很有彈性的做法,但是事實(shí)上所有的單例都繼承了這個(gè)類,而父類的get()方法用了final來修飾,在子類中是不能被重寫的,這就造成了我們應(yīng)用中的所有單例用的是相同的單例方式,也就是都用了 DCL 方式來實(shí)現(xiàn)單例,難以想象一種單例可以適用于整個(gè)項(xiàng)目(此項(xiàng)目中的單例類包括:登錄注冊管理類 LoginManager,賬戶管理類 AccountManager,用戶信息業(yè)務(wù)邏輯類 UserBusiness,主線程 Handler 類 MainHandler,數(shù)據(jù)上報(bào) Looper 類 ReportLooper,Preference 管理類 PrefManager,WNS 數(shù)據(jù)透傳管理類 SenderManager, PUSH業(yè)務(wù)邏輯類 PushBusiness,素材業(yè)務(wù)邏輯類 MaterailBusiness,搜索業(yè)務(wù)邏輯類 SearchBusiness,消息業(yè)務(wù)邏輯類 MessageBusiness 等等,DCL 顯然不適用于所有這些單例。P.S. 感覺單例的使用也有點(diǎn)多了,需要檢查一下是否有濫用)。
這種方法其實(shí)是 3.2 中討論的單例的繼承的情況,為了提高可擴(kuò)展性,父類的構(gòu)造函數(shù)不再是私有的,導(dǎo)致單例的“唯一性”遭到了破壞。工程的任意處,我調(diào)用如下代碼,即可以再得到一個(gè) LoginManager:

整個(gè)項(xiàng)目中考慮到可擴(kuò)展性偶一為之還能接受(不推薦),但所有的單例都不能確保獨(dú)一無二就是一個(gè)大問題了。
代碼的 owner 用了private、final和static等關(guān)鍵詞,可能是希望能確保單例的唯一性(前面已經(jīng)證明這一目的并未達(dá)到),但是它們使得這些單例類在 XXXXXApplication 類加載的時(shí)候,即程序一開始運(yùn)行時(shí)就被實(shí)例化了。無論這些單例類有沒有用到,它的實(shí)例都存在于內(nèi)存中了。雖然因?yàn)?DCL 方式實(shí)現(xiàn)的單例有延遲加載的優(yōu)點(diǎn),這些單例的 instance 會(huì)在使用時(shí)才創(chuàng)建,但是現(xiàn)在思路混亂地把兩者搭配在一起,不但無法體現(xiàn)兩者的優(yōu)勢,反而會(huì)同時(shí)有兩者的限制;
上面只列舉了幾處明顯問題,顯然這個(gè)反面教材是在沒有深刻理解單例的情況下編寫的,從而思路不清,錯(cuò)漏百出。而這樣的代碼一直存在于我們的項(xiàng)目中,在沒有深入研究單例這個(gè)模式前,我也完全沒有看出任何問題,使用得非常歡快:(。我希望大家看了此文,了解了單例的方方面面后,除了能正確地使用好單例,也能體會(huì)到設(shè)計(jì)模式是久經(jīng)時(shí)間考驗(yàn)、多次優(yōu)化后的經(jīng)驗(yàn)總結(jié),在沒有理解透徹前的隨意改動(dòng)可能會(huì)引入意想不到的問題。另外,代碼也不是用到的“高端”技巧越多就是越好的,“高端”往往意味著不常用,不熟悉,不通用,不易理解,所以使用時(shí)一定要謹(jǐn)慎??!
5 總結(jié)
關(guān)于單例模式先講到這里,其實(shí)總結(jié)已經(jīng)在文章前半部分給出了,我也沒有體力重申一遍了:P
由于內(nèi)容比較多,又是利用平時(shí)的零碎時(shí)間斷斷續(xù)續(xù)撰寫此文的,難免會(huì)有錯(cuò)失遺漏,大家有任何想法和建議也請不吝賜教,謝謝!
附錄
重新貼一遍“雙重檢查鎖定(DCL)”方式實(shí)現(xiàn)單例模式的代碼,在下面兩個(gè)分析中都會(huì)涉及:

粗略比較一下高并發(fā)的情況下,同步方法方式同 DCL 方式效率上的差別。在服務(wù)器允許的情況下,假設(shè)有一百個(gè)線程,則耗時(shí)結(jié)果如下:

在第一次運(yùn)行的時(shí)候,同步方法方式耗費(fèi)的時(shí)間為:100 * (同步判斷時(shí)間 + if 判斷時(shí)間)。以后也保持這樣的消耗不變。
而 DCL 方式中雖然有兩個(gè) if 判斷,但 100 個(gè)線程是可以同時(shí)進(jìn)行第一個(gè) if 判斷的(因?yàn)榇藭r(shí)還沒有同步),理論上 100 個(gè)線程第一個(gè) if 判斷消耗的總時(shí)間只需一次判斷的時(shí)間,第二個(gè) if 判斷,在第一次執(zhí)行時(shí),如果是最壞的情況會(huì)有 100 次,加上 100 個(gè)同步判斷時(shí)間,DCL 方法第一次執(zhí)行會(huì)比同步方法方式多一個(gè)判斷時(shí)間,即100 * (同步判斷時(shí)間 + if 判斷時(shí)間) + 1 * if 判斷時(shí)間。但重要的是,這種 DCL 方式只在第一次實(shí)例化的時(shí)候進(jìn)行加鎖,之后就不會(huì)再通過第一個(gè) if 判斷,也就不用加鎖,不再有同步判斷和第二次 if 判斷的時(shí)間損耗,100 個(gè)線程也只會(huì)有一個(gè)if 判斷時(shí)間,效率相比100 * (同步判斷時(shí)間 + if判斷時(shí)間)大大提高。
雙重檢查鎖定(DCL)單例在 JDK 1.5 之前版本失效原因解釋
在高并發(fā)環(huán)境,JDK 1.4 及更早版本下,雙重檢查鎖定偶爾會(huì)失敗。其根本原因是,Java 中new一個(gè)對象并不是一個(gè)原子操作,編譯時(shí)singleton = new Singleton();語句會(huì)被轉(zhuǎn)成多條匯編指令,它們大致做了3件事情:
1)給 Singleton 類的實(shí)例分配內(nèi)存空間;
2)調(diào)用私有的構(gòu)造函數(shù)Singleton(),初始化成員變量;
3)將singleton對象指向分配的內(nèi)存(執(zhí)行完此操作singleton就不是 null 了)
由于 Java 編譯器允許處理器亂序執(zhí)行,以及 JDK 1.5 之前的舊的 Java 內(nèi)存模型(Java Memory Model)中 Cache、寄存器到主內(nèi)存回寫順序的規(guī)定,上面步驟 2) 和 3) 的執(zhí)行順序是無法確定的,可能是 1) → 2) → 3) 也可能是 1) → 3) → 2) 。如果是后一種情況,在線程 A 執(zhí)行完步驟 3) 但還沒完成 2) 之前,被切換到線程 B 上,此時(shí)線程 B 對singleton第1次判空結(jié)果為 false,直接取走了singleton使用,但是構(gòu)造函數(shù)卻還沒有完成所有的初始化工作,就會(huì)出錯(cuò),也就是 DCL 失效問題。
在 JDK 1.5的版本中具體化了volatile關(guān)鍵字,將其加在對象前就可以保證每次都是從主內(nèi)存中讀取對象,從而修復(fù)了 DCL 失效問題。當(dāng)然,volatile 或多或少還是會(huì)影響到一些性能,但比起得到錯(cuò)誤的結(jié)果,犧牲這點(diǎn)性能還是值得的。