在Java中如何寫(xiě)一個(gè)正確的單例模式
今天我們一起來(lái)探討下單例模式,可以說(shuō),單例模式是面試???,如果考察你對(duì)設(shè)計(jì)模式的理解程度,那么有很大可能會(huì)考察到,因?yàn)閱卫J诫m然看似簡(jiǎn)單,每個(gè)人都可能寫(xiě)出來(lái)。但如果往深了挖,又能考察出面試候選人對(duì)于并發(fā)、類(lèi)加載、序列化等重要知識(shí)點(diǎn)的掌握程度和水平。單例模式有很多種寫(xiě)法,那么哪種寫(xiě)法更好呢,為什么?
要想知道哪種寫(xiě)法好,首先我們需要知道什么是單例模式,單例模式指的是,保證一個(gè)類(lèi)只有一個(gè)實(shí)例,并且提供一個(gè)全局可以訪(fǎng)問(wèn)的入口。
舉個(gè)例子,這就好比是“分身術(shù)”,但是每個(gè)“分身”其實(shí)都對(duì)應(yīng)同一個(gè)“真身”。
那么我們?yōu)槭裁葱枰獑卫兀渲幸粋€(gè)理由,那就是為了節(jié)省內(nèi)存、節(jié)省計(jì)算。很多情況下,我們只需要一個(gè)實(shí)例就夠了,如果出現(xiàn)了更多的實(shí)例,反而屬于浪費(fèi)。舉個(gè)例子,我們就拿一個(gè)初始化比較耗時(shí)的類(lèi)來(lái)說(shuō):
public class ExpensiveResource {
public ExpensiveResource() {
field1 = // 查詢(xún)數(shù)據(jù)庫(kù)
field2 = // 然后對(duì)查到的數(shù)據(jù)做大量計(jì)算
field3 = // 加密、壓縮等耗時(shí)操作
}
}
這個(gè)類(lèi)在構(gòu)造的時(shí)候,需要查詢(xún)數(shù)據(jù)庫(kù)并對(duì)查到的數(shù)據(jù)做大量計(jì)算,所以在第一次構(gòu)造時(shí),我們花了很多時(shí)間來(lái)初始化這個(gè)對(duì)象。但是假設(shè)我們數(shù)據(jù)庫(kù)里的數(shù)據(jù)是不變的,并把這個(gè)對(duì)象保存在了內(nèi)存中,那么以后就用同一個(gè)實(shí)例了,如果每次都重新生成新的實(shí)例,實(shí)在是沒(méi)必要。
接下來(lái)看看需要單例的第二個(gè)理由,那就是為了保證結(jié)果的正確。比如我們需要一個(gè)全局的計(jì)數(shù)器,用來(lái)統(tǒng)計(jì)人數(shù),那么如果有多個(gè)實(shí)例,反而會(huì)造成混亂。
另外呢,就是為了方便管理。很多工具類(lèi),我們只需要一個(gè)實(shí)例,那么我們通過(guò)統(tǒng)一的入口,比如通過(guò) getInstance 方法去獲取這個(gè)單例是很方便的,太多實(shí)例不但沒(méi)有幫助,反而會(huì)讓人眼花繚亂。
在了解了單例模式的好處之后,我們接下來(lái)就來(lái)探討一下單例模式有哪些適用場(chǎng)景。
無(wú)狀態(tài)的工具類(lèi):比如日志工具類(lèi),不管是在哪里使用,我們需要的只是它幫我們記錄日志信息,除此之外,并不需要在它的實(shí)例對(duì)象上存儲(chǔ)任何狀態(tài),這時(shí)候我們就只需要一個(gè)實(shí)例對(duì)象。
全局信息類(lèi):比如我們?cè)谝粋€(gè)類(lèi)上記錄網(wǎng)站的訪(fǎng)問(wèn)次數(shù),并且不希望有的訪(fǎng)問(wèn)被記錄在對(duì)象 A 上,有的卻被記錄在對(duì)象 B 上,這時(shí)候我們就可以讓這個(gè)類(lèi)成為單例,需要計(jì)數(shù)的時(shí)候拿出來(lái)用即可。
常見(jiàn)的寫(xiě)法又有哪些呢,我認(rèn)為有這么 5 種:餓漢式、懶漢式、雙重檢查式、靜態(tài)內(nèi)部類(lèi)式、枚舉式。
我們按照寫(xiě)法的難易度來(lái)逐層遞講,先來(lái)看下相對(duì)簡(jiǎn)單的餓漢式寫(xiě)法具體是什么樣的。
public class Singleton {
private static Singleton singleton = new Singleton();
private Singleton(){}
public static Singleton getInstance(){
return singleton;
}
}
用 static 修飾我們的實(shí)例,并把構(gòu)造函數(shù)用 private 修飾。這是最直觀(guān)的寫(xiě)法。由 JVM 的類(lèi)加載機(jī)制保證了線(xiàn)程安全。
這種寫(xiě)法的缺點(diǎn)也比較明顯,那就是在類(lèi)被加載時(shí)便會(huì)把實(shí)例生成出來(lái),所以假設(shè)我們最終沒(méi)有使用到這個(gè)實(shí)例的話(huà),便會(huì)造成不必要的開(kāi)銷(xiāo)。
下面我們?cè)賮?lái)看下餓漢式的變種——靜態(tài)代碼塊形式。
public class Singleton {
private static Singleton singleton;
static {
singleton = new Singleton();
}
private Singleton() {}
public static Singleton getInstance() {
return singleton;
}
}
這種寫(xiě)法把新建對(duì)象的相關(guān)代碼轉(zhuǎn)移到了靜態(tài)代碼塊里,在原理上和上面那一種“餓漢式”的寫(xiě)法是比較相近的,所以同樣會(huì)在類(lèi)加載的時(shí)候完成實(shí)例的創(chuàng)建。
在了解了餓漢式的寫(xiě)法后,再來(lái)看下第二種寫(xiě)法,懶漢式。
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
這種寫(xiě)法的優(yōu)點(diǎn)在于,只有在 getInstance 方法被調(diào)用的時(shí)候,才會(huì)去進(jìn)行實(shí)例化,所以不會(huì)造成資源浪費(fèi),但是在創(chuàng)建的過(guò)程中,并沒(méi)有考慮到線(xiàn)程安全問(wèn)題,如果有兩個(gè)線(xiàn)程同時(shí)執(zhí)行 getInstance 方法,就可能會(huì)創(chuàng)建多個(gè)實(shí)例。所以這里需要注意,不能使用這種方式,這是錯(cuò)誤的寫(xiě)法。
為了避免發(fā)生線(xiàn)程安全問(wèn)題,我們可以對(duì)前面的寫(xiě)法進(jìn)行升級(jí),那么線(xiàn)程安全的懶漢式的寫(xiě)法是怎樣的呢。
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
我們?cè)?getInstance 方法上加了 synchronized 關(guān)鍵字,保證同一時(shí)刻最多只有一個(gè)線(xiàn)程能執(zhí)行該方法,這樣就解決了線(xiàn)程安全問(wèn)題。但是這種寫(xiě)法的缺點(diǎn)也很明顯:如果有多個(gè)線(xiàn)程同時(shí)獲取實(shí)例,那他們不得不進(jìn)行排隊(duì),多個(gè)線(xiàn)程不能同時(shí)訪(fǎng)問(wèn),然而這在大多數(shù)情況下是沒(méi)有必要的。
為了提高效率,縮小同步范圍,就把 synchronized 關(guān)鍵字從方法上移除了,然后再把 synchronized 關(guān)鍵字放到了我們的方法內(nèi)部,采用代碼塊的形式來(lái)保護(hù)線(xiàn)程安全。
public class Singleton {
private static Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
singleton = new Singleton();
}
}
return singleton;
}
}
這種寫(xiě)法是錯(cuò)誤的。它的本意是想縮小同步的范圍,但是從實(shí)際效果來(lái)看反而得不償失。因?yàn)榧僭O(shè)有多個(gè)線(xiàn)程同時(shí)通過(guò)了 if 判斷,那么依然會(huì)產(chǎn)生多個(gè)實(shí)例,這就破壞了單例模式。
所以,為了解決這個(gè)問(wèn)題,在這基礎(chǔ)上就有了“雙重檢查模式”。
public class Singleton {
privatestaticvolatile Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
這種寫(xiě)法的優(yōu)點(diǎn)就是不僅做到了延遲加載,而且是線(xiàn)程安全的,同時(shí)也避免了過(guò)多的同步環(huán)節(jié)。我們重點(diǎn)來(lái)看一下 getInstance 方法,這里面有兩層 if 判空,下面我們分別來(lái)看一下每個(gè) if 的作用。
這里涉及到一個(gè)常見(jiàn)的問(wèn)題,面試官可能會(huì)問(wèn)你,“為什么要 double-check?去掉第二次的 check 行不行?”這時(shí)你需要考慮這樣一種情況,有兩個(gè)線(xiàn)程同時(shí)調(diào)用 getInstance 方法,并且由于 singleton 是空的,所以?xún)蓚€(gè)線(xiàn)程都可以通過(guò)第一個(gè) if。
然后就遇到了 synchronized 鎖的保護(hù),假設(shè)線(xiàn)程 1 先搶到鎖,并進(jìn)入了第二個(gè) if,那么線(xiàn)程 1 就會(huì)創(chuàng)建新實(shí)例,然后退出 synchronized 代碼塊。接著才會(huì)輪到線(xiàn)程 2 進(jìn)入 synchronized 代碼塊,并進(jìn)入第二層 if,此時(shí)線(xiàn)程 2 會(huì)發(fā)現(xiàn) singleton 已經(jīng)不為 null,所以直接退出 synchronized 代碼塊,這樣就保證了沒(méi)有創(chuàng)建多個(gè)實(shí)例。假設(shè)沒(méi)有第二層 if,那么線(xiàn)程 2 也可能會(huì)創(chuàng)建一個(gè)新實(shí)例,這樣就破壞了單例,所以第二層 if 肯定是需要的。
而對(duì)于第一個(gè) check 而言,如果去掉它,那么所有線(xiàn)程都只能串行執(zhí)行,效率低下,所以?xún)蓚€(gè) check 都是需要保留。
相信你可能看到了,我們?cè)陔p重檢查模式中,給 singleton 這個(gè)對(duì)象加了 volatile 關(guān)鍵字,那 為什么要用 volatile 呢?這是因?yàn)?new 一個(gè)對(duì)象的過(guò)程,其實(shí)并不是原子的,至少包括以下這 3 個(gè)步驟:
- 給 singleton 對(duì)象分配內(nèi)存空間;
- 調(diào)用 Singleton 的構(gòu)造函數(shù)等,來(lái)進(jìn)行初始化;
- 把 singleton 對(duì)象指向在第一步中分配的內(nèi)存空間,而在執(zhí)行完這步之后,singleton 對(duì)象就不再是 null 了。
這里需要留意一下這 3 個(gè)步驟的順序,因?yàn)榇嬖谥嘏判颍陨厦嫠f(shuō)的三個(gè)步驟的順序,并不是固定的。雖然看起來(lái)是 1-2-3 的順序,但是在實(shí)際執(zhí)行時(shí),也可能發(fā)生 1-3-2 的情況,也就是說(shuō),先把 singleton 對(duì)象指向在第一步中分配的內(nèi)存空間,再調(diào)用 Singleton 的構(gòu)造函數(shù)。
如果發(fā)生了 1-3-2 的情況,線(xiàn)程 1 首先執(zhí)行新建實(shí)例的第一步,也就是分配單例對(duì)象的內(nèi)存空間,然后線(xiàn)程 1 因?yàn)楸恢嘏判?,所以去?zhí)行了新建實(shí)例的第三步,也就是把 singleton 指向之前的內(nèi)存地址,在這之后對(duì)象不是 null,可是這時(shí)第 2 步并沒(méi)有執(zhí)行。假設(shè)這時(shí)線(xiàn)程 2 進(jìn)入 getInstance 方法,由于這時(shí) singleton 已經(jīng)不是 null 了,所以會(huì)通過(guò)第一重檢查并直接返回 singleton 對(duì)象并使用,但其實(shí)這時(shí)的 singleton 并沒(méi)有完成初始化,所以使用這個(gè)實(shí)例的時(shí)候會(huì)報(bào)錯(cuò)。
最后,線(xiàn)程 1“姍姍來(lái)遲”,才開(kāi)始執(zhí)行新建實(shí)例的第二步——初始化對(duì)象,可是這時(shí)的初始化已經(jīng)晚了,因?yàn)榍懊嬉呀?jīng)報(bào)錯(cuò)了。
到這里關(guān)于“為什么要用 volatile”問(wèn)題就講完了,使用 volatile 的意義,我認(rèn)為主要在于呢,它可以防止剛講到的重排序的發(fā)生,也就避免了拿到?jīng)]完成初始化的對(duì)象。
接下來(lái)要講到的這種方式,靜態(tài)內(nèi)部類(lèi)的寫(xiě)法,利用了類(lèi)裝載時(shí)由 JVM 所保證的單線(xiàn)程原則,進(jìn)而保證了線(xiàn)程安全。
public class Singleton {
private Singleton() {}
private static class SingletonInstance {
private static final Singleton singleton = new Singleton();
}
public static Singleton getInstance() {
return SingletonInstance.singleton;
}
}
相比于餓漢式在類(lèi)加載時(shí)就完成實(shí)例化,這種靜態(tài)內(nèi)部類(lèi)的寫(xiě)法并不會(huì)有這個(gè)問(wèn)題,這種寫(xiě)法只有在調(diào)用 getInstance 方法時(shí),才會(huì)進(jìn)一步完成內(nèi)部類(lèi)的 singleton 的實(shí)例化,所以不存在內(nèi)存浪費(fèi)的問(wèn)題。
這里簡(jiǎn)單做個(gè)小總結(jié),靜態(tài)內(nèi)部類(lèi)寫(xiě)法與雙重檢查模式的優(yōu)點(diǎn)一樣,都是避免了線(xiàn)程不安全的問(wèn)題,并且延遲加載,效率高。
可以看出,靜態(tài)內(nèi)部類(lèi)和雙重檢查的寫(xiě)法都是不錯(cuò)的寫(xiě)法,但是它們不能防止被反序列化生成多個(gè)實(shí)例,那有沒(méi)有更好的寫(xiě)法呢?最后我們來(lái)看枚舉方式的寫(xiě)法。
public enum Singleton {
INSTANCE;
public void myMethod() {
}
}
這就是枚舉方式的寫(xiě)法,下面我們會(huì)對(duì)這種寫(xiě)法進(jìn)行展開(kāi)分析。
前面我們講了餓漢式、懶漢式、雙重檢查、靜態(tài)內(nèi)部類(lèi)、枚舉這 5 種寫(xiě)法,有了這么多方法可以實(shí)現(xiàn)單例,這時(shí)你可能會(huì)問(wèn)了,那我該怎么選擇,用哪種單例的實(shí)現(xiàn)方案最好呢?
Joshua Bloch(約書(shū)亞·布洛克)在《Effective Java》一書(shū)中明確表達(dá)過(guò)一個(gè)觀(guān)點(diǎn):“使用枚舉實(shí)現(xiàn)單例的方法,雖然還沒(méi)有被廣泛采用,但是單元素的枚舉類(lèi)型已經(jīng)成為了實(shí)現(xiàn) Singleton 的最佳方法?!?/span>
為什么他會(huì)更為推崇枚舉模式的單例呢?這就不得不回到枚舉寫(xiě)法的優(yōu)點(diǎn)上來(lái)說(shuō)了,枚舉寫(xiě)法的優(yōu)點(diǎn)有這么幾個(gè):
首先就是寫(xiě)法簡(jiǎn)單。枚舉的寫(xiě)法不需要我們自己考慮懶加載、線(xiàn)程安全等問(wèn)題。同時(shí),代碼也比較“短小精悍”,比任何其他的寫(xiě)法都更簡(jiǎn)潔,很優(yōu)雅。
第二個(gè)優(yōu)點(diǎn)是線(xiàn)程安全有保障,枚舉類(lèi)的本質(zhì)也是一個(gè) Java 類(lèi),但是它的枚舉值會(huì)在枚舉類(lèi)被加載時(shí)完成初始化,所以依然是由 JVM 幫我們保證了線(xiàn)程安全。
前面幾種實(shí)現(xiàn)單例的方式,其實(shí)是存在隱患的,那就是可能被反序列化生成新對(duì)象,產(chǎn)生多個(gè)實(shí)例,從而破壞了單例模式。接下來(lái)要說(shuō)的枚舉寫(xiě)法的第 3 個(gè)優(yōu)點(diǎn),它恰恰解決了這些問(wèn)題。
對(duì) Java 官方文檔中的相關(guān)規(guī)定翻譯如下:“枚舉常量的序列化方式不同于普通的可序列化或可外部化對(duì)象。枚舉常量的序列化形式僅由其名稱(chēng)組成;該常量的字段值不存在于表單中。要序列化枚舉常量,ObjectOutputStream 將寫(xiě)入枚舉常量的 name 方法返回的值。要反序列化枚舉常量,ObjectInputStream 從流中讀取常量名稱(chēng);然后,通過(guò)調(diào)用 java.lang.Enum.valueOf 方法獲得反序列化常量,并將常量的枚舉類(lèi)型和收到的常量名稱(chēng)作為參數(shù)傳遞?!?/span>
也就是說(shuō),對(duì)于枚舉類(lèi)而言,反序列化的時(shí)候,會(huì)根據(jù)名字來(lái)找到對(duì)應(yīng)的枚舉對(duì)象,而不是創(chuàng)建新的對(duì)象,所以這就防止了反序列化導(dǎo)致的單例破壞問(wèn)題的出現(xiàn)。
對(duì)于通過(guò)反射破壞單例而言,枚舉類(lèi)同樣有防御措施。反射在通過(guò) newInstance 創(chuàng)建對(duì)象時(shí),會(huì)檢查這個(gè)類(lèi)是否是枚舉類(lèi),如果是,就拋出 IllegalArgumentException(“Cannot reflectively create enum objects”) 異常,反射創(chuàng)建對(duì)象失敗。
可以看出,枚舉這種方式,能夠防止序列化和反射破壞單例,在這一點(diǎn)上,與其他的實(shí)現(xiàn)方式比,有很大的優(yōu)勢(shì)。安全問(wèn)題不容小視,一旦生成了多個(gè)實(shí)例,單例模式就徹底沒(méi)用了。
所以結(jié)合講到的這 3 個(gè)優(yōu)點(diǎn),寫(xiě)法簡(jiǎn)單、線(xiàn)程安全、防止反序列化和反射破壞單例,枚舉寫(xiě)法最終勝出。
今天的分享到這里就結(jié)束了,最后我來(lái)總結(jié)一下。今天我講解了單例模式什么是,它的作用、用途,以及 5 種經(jīng)典寫(xiě)法,其中包含了餓漢式、懶漢式、雙重檢查方式、靜態(tài)內(nèi)部類(lèi)方式和枚舉的方式,最后我們還經(jīng)過(guò)對(duì)比,看到枚舉方式在寫(xiě)法、線(xiàn)程安全,以及避免序列化、反射攻擊上,都有優(yōu)勢(shì)。
這里也跟大家強(qiáng)調(diào)一下,如果使用線(xiàn)程不安全的錯(cuò)誤的寫(xiě)法,在并發(fā)情況下可能產(chǎn)生多個(gè)實(shí)例,那么不僅會(huì)影響性能,更可能造成數(shù)據(jù)錯(cuò)誤等嚴(yán)重后果。
如果是在面試中遇到這個(gè)問(wèn)題,那么你可以從一開(kāi)始的餓漢式、懶漢式說(shuō)起,一步步分析每種寫(xiě)法的優(yōu)缺點(diǎn),并對(duì)寫(xiě)法進(jìn)行演進(jìn),然后重點(diǎn)講一下雙重檢查模式為什么需要兩次檢查,以及為什么需要 volatile 關(guān)鍵字,最后再說(shuō)到枚舉類(lèi)寫(xiě)法的優(yōu)點(diǎn)和背后的原理,相信這一定會(huì)為你的面試加分。
另外在工作中,要是遇到了全局信息類(lèi)、無(wú)狀態(tài)工具類(lèi)等場(chǎng)景的時(shí)候,推薦使用枚舉的寫(xiě)法來(lái)實(shí)現(xiàn)單例模式。