Java中的單例模式最全解析

柯廣的部落格發表於2020-11-28

單例模式是 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,因此得到的是一個未初始化完成的物件,這樣就導致了問題的誕生。執行時間節點如下表所示:

時間點 執行緒 執行操作
t1 A instance = new Singleton() 的 1-3 步驟,待執行步驟 2
t2 B if (instance == null) { 判斷結果為 false
t3 B 返回半初始的 instance 物件

為了解決此問題,我們可以使用關鍵字 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 種實現方式。其中包含了兩種雙重檢測鎖的懶漢變種模式,還有最後兩種執行緒安全且可以實現延遲載入的靜態內部類的實現方式和列舉類的實現方式,其中比較推薦使用的是後兩種單例模式的實現方式。

相關文章