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