單例模式是 Java 中最簡單的設計模式之一,它是指一個類在運行期間始終只有一個實例,我們就把它稱之爲單例模式。它不但被應用在實際的工作中,而且還是面試中最常考的題目之一。通過單例模式我們可以知道此人的編程風格,以及對於基礎知識的掌握是否牢固。
我們本課時的面試題是,單例的實現方式有幾種?它們有什麼優缺點?
典型回答
單例的實現分爲餓漢模式和懶漢模式。顧名思義,餓漢模式就好比他是一個餓漢,而且有一定的危機意識,他會提前把食物囤積好,以備餓了之後直接能喫到食物。對應到程序中指的是,在類加載時就會進行單例的初始化,以後訪問時直接使用單例對象即可。
餓漢模式的實現代碼如下:
public class Singleton {
// 聲明私有對象
private static Singleton instance = new Singleton();
// 獲取實例(單例對象)
public static Singleton getInstance() {
return instance;
}
private Singleton() {
}
// 方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
class SingletonTest {
public static void main(String[] args) {
// 調用單例對象
Singleton singleton = Singleton.getInstance();
// 調用方法
singleton.sayHi();
}
}
以上程序的執行結果爲:
Hi,Java.
從上述結果可以看出,單例對象已經被成功獲取到並順利地執行了類中的方法。它的優點是線程安全,因爲單例對象在類加載的時候就已經被初始化了,當調用單例對象時只是把早已經創建好的對象賦值給變量;它的缺點是可能會造成資源浪費,如果類加載了單例對象(對象被創建了),但是一直沒有使用,這樣就造成了資源的浪費。
懶漢模式也被稱作爲飽漢模式,顧名思義他比較懶,每次只有需要喫飯的時候,纔出去找飯喫,而不是像餓漢那樣早早把飯準備好。對應到程序中指的是,當每次需要使用實例時,再去創建獲取實例,而不是在類加載時就將實例創建好。
懶漢模式的實現代碼如下:
public class Singleton {
// 聲明私有對象
private static Singleton instance;
// 獲取實例(單例對象)
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
private Singleton() {
}
// 方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
class SingletonTest {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
singleton.sayHi();
}
}
以上程序的執行結果爲:
Hi,Java.
從上述結果可以看出,單例對象已經被成功獲取到並順利地執行了類中的方法,它的優點是不會造成資源的浪費,因爲在調用的時候纔會創建被實例化對象;它的缺點在多線程環境下是非線程是安全的,比如多個線程同時執行到 if 判斷處,此時判斷結果都是未被初始化,那麼這些線程就會同時創建 n 個實例,這樣就會導致意外的情況發生。
考點分析
使用單例模式可以減少系統的內存開銷,提高程序的運行效率,但是使用不當的話就會造成多線程下的併發問題。餓漢模式爲最直接的實現單例模式的方法,但它可能會造成對系統資源的浪費,所以只有既能保證線程安全,又可以避免系統資源被浪費的回答才能徹底地征服面試官。
和此知識點相關的面試題還有以下這些:
- 什麼是雙重檢測鎖?它是線程安全的嗎?
- 單例的還有其他實現方式嗎?
知識擴展
雙重檢測鎖
爲了保證懶漢模式的線程安全我們最簡單的做法就是給獲取實例的方法上加上 synchronized(同步鎖)修飾,如下代碼所示:
public class Singleton {
// 聲明私有對象
private static Singleton instance;
// 獲取實例(單例對象)
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
這樣雖然能讓懶漢模式變成線程安全的,但由於整個方法都被 synchronized 所包圍,因此增加了同步開銷,降低了程序的執行效率。
於是爲了改進程序的執行效率,我們將 synchronized 放入到方法中,以此來減少被同步鎖所修飾的代碼範圍,實現代碼如下:
public class Singleton {
// 聲明私有對象
private static Singleton instance;
// 獲取實例(單例對象)
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
細心的你可能會發現以上的代碼也存在着非線程安全的問題。例如,當兩個線程同時執行到「if (instance == null) { 」判斷時,判斷的結果都爲 true,於是他們就排隊都創建了新的對象,這顯然不符合我們的預期。於是就誕生了大名鼎鼎的雙重檢測鎖(Double Checked Lock,DCL),實現代碼如下:
public class Singleton {
// 聲明私有對象
private static Singleton instance;
// 獲取實例(單例對象)
public static Singleton getInstance() {
// 第一次判斷
if (instance == null) {
synchronized (Singleton.class) {
// 第二次判斷
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
上述代碼看似完美,其實隱藏着一個不容易被人發現的小問題,該問題就出在 new 對象這行代碼上,也就是 instance = new Singleton() 這行代碼。這行代碼看似是一個原子操作,然而並不是,這行代碼最終會被編譯成多條彙編指令,它大致的執行流程爲以下三個步驟:
- 給對象實例分配內存空間;
- 調用對象的構造方法、初始化成員字段;
- 將 instance 對象指向分配的內存空間。
但由於 CPU 的優化會對執行指令進行重排序,也就說上面的執行流程的執行順序有可能是 1-2-3,也有可能是 1-3-2。假如執行的順序是 1-3-2,那麼當 A 線程執行到步驟 3 時,切換至 B 線程了,而此時 B 線程判斷 instance 對象已經指向了對應的內存空間,並非爲 null 時就會直接進行返回,而此時因爲沒有執行步驟 2,因此得到的是一個未初始化完成的對象,這樣就導致了問題的誕生。執行時間節點如下表所示:
爲了解決此問題,我們可以使用關鍵字 volatile 來修飾 instance 對象,這樣就可以防止 CPU 指令重排,從而完美地運行懶漢模式,實現代碼如下:
public class Singleton {
// 聲明私有對象
private volatile static Singleton instance;
// 獲取實例(單例對象)
public static Singleton getInstance() {
// 第一次判斷
if (instance == null) {
synchronized (Singleton.class) {
// 第二次判斷
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
單例其他實現方式
除了以上的 6 種方式可以實現單例模式外,還可以使用靜態內部類和枚舉類來實現單例。靜態內部類的實現代碼如下:
public class Singleton {
// 靜態內部類
private static class SingletonInstance {
private static final Singleton instance = new Singleton();
}
// 獲取實例(單例對象)
public static Singleton getInstance() {
return SingletonInstance.instance;
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
從上述代碼可以看出,靜態內部類和餓漢方式有異曲同工之妙,它們都採用了類裝載的機制來保證,當初始化實例時只有一個線程執行,從而保證了多線程下的安全操作。JVM 會在類初始化階段(也就是類裝載階段)創建一個鎖,該鎖可以保證多個線程同步執行類初始化的工作,因此在多線程環境下,類加載機制依然是線程安全的。
但靜態內部類和餓漢方式也有着細微的差別,餓漢方式是在程序啓動時就會進行加載,因此可能造成資源的浪費;而靜態內部類只有在調用 getInstance() 方法時,纔會裝載內部類從而完成實例的初始化工作,因此不會造成資源浪費的問題。由此可知,此方式也是較爲推薦的單例實現方式。
單例的另一種實現方式爲枚舉,它也是《Effective Java》作者極力推薦地單例實現方式,因爲枚舉的實現方式不僅是線程安全的,而且只會裝載一次,無論是序列化、反序列化、反射還是克隆都不會新創建對象。它的實現代碼如下:
public class Singleton {
// 枚舉類型是線程安全的,並且只會裝載一次
private enum SingletonEnum {
INSTANCE;
// 聲明單例對象
private final Singleton instance;
// 實例化
SingletonEnum() {
instance = new Singleton();
}
private Singleton getInstance() {
return instance;
}
}
// 獲取實例(單例對象)
public static Singleton getInstance() {
return SingletonEnum.INSTANCE.getInstance();
}
private Singleton() {
}
// 類方法
public void sayHi() {
System.out.println("Hi,Java.");
}
}
class SingletonTest {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
singleton.sayHi();
}
}
以上程序的執行結果爲:
Hi,Java.
小結
本課時我們講了 8 種實現單例的方式,包括線程安全但可能會造成系統資源浪費的餓漢模式,以及懶漢模式和懶漢模式變種的 5 種實現方式。其中包含了兩種雙重檢測鎖的懶漢變種模式,還有最後兩種線程安全且可以實現延遲加載的靜態內部類的實現方式和枚舉類的實現方式,其中比較推薦使用的是後兩種單例模式的實現方式。