我向面試官講解了單例模式,他對我豎起了大拇指

單例模式相信大家都有所聽聞,甚至也寫過不少了,在面試中也是考得最多的其中一個設計模式,面試官常常會要求寫出兩種類型的單例模式並且解釋其原理,廢話不多說,我們開始學習如何很好地回答這一道面試題吧。

什麼是單例模式

面試官問什麼是單例模式時,千萬不要答非所問,給出單例模式有兩種類型之類的回答,要圍繞單例模式的定義去展開。

單例模式是指在內存中只會創建且僅創建一次對象的設計模式。在程序中多次使用同一個對象且作用相同時,爲了防止頻繁地創建對象使得內存飆升,單例模式可以讓程序僅在內存中創建一個對象,讓所有需要調用的地方都共享這一單例對象。

image.png

單例模式的類型

單例模式有兩種類型:

  • 懶漢式:在真正需要使用對象時纔去創建該單例類對象
  • 餓漢式:在類加載時已經創建好該單例對象,等待被程序使用

懶漢式創建單例對象

懶漢式創建對象的方法是在程序使用對象前,先判斷該對象是否已經實例化(判空),若已實例化直接返回該類對象。否則則先執行實例化操作。

image.png

根據上面的流程圖,就可以寫出下面的這段代碼

public class Singleton {
    
    private static Singleton singleton;
    
    private Singleton(){}
    
    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
    
}

沒錯,這裏我們已經寫出了一個很不錯的單例模式,不過它不是完美的,但是這並不影響我們使用這個“單例對象”。

以上就是懶漢式創建單例對象的方法,我會在後面解釋這段代碼在哪裏可以優化,存在什麼問題。

餓漢式創建單例對象

餓漢式在類加載時已經創建好該對象,在程序調用時直接返回該單例對象即可,即我們在編碼時就已經指明瞭要馬上創建這個對象,不需要等到被調用時再去創建。

關於類加載,涉及到JVM的內容,我們目前可以簡單認爲在程序啓動時,這個單例對象就已經創建好了。

image.png

public class Singleton{
    
    private static final Singleton singleton = new Singleton();
    
    private Singleton(){}
    
    public static Singleton getInstance() {
        return singleton;
    }
}

注意上面的代碼在第3行已經實例化好了一個Singleton對象在內存中,不會有多個Singleton對象實例存在

類在加載時會在堆內存中創建一個Singleton對象,當類被卸載時,Singleton對象也隨之消亡了。

懶漢式如何保證只創建一個對象

我們再來回顧懶漢式的核心方法

public static Singleton getInstance() {
    if (singleton == null) {
        singleton = new Singleton();
    }
    return singleton;
}

這個方法其實是存在問題的,試想一下,如果兩個線程同時判斷 singleton 爲空,那麼它們都會去實例化一個Singleton 對象,這就變成多例了。所以,我們要解決的是線程安全問題。

image.png

最容易想到的解決方法就是在方法上加鎖,或者是對類對象加鎖,程序就會變成下面這個樣子

public static synchronized Singleton getInstance() {
    if (singleton == null) {
        singleton = new Singleton();
    }
    return singleton;
}
// 或者
public static Singleton getInstance() {
    synchronized(Singleton.class) {   
        if (singleton == null) {
            singleton = new Singleton();
        }
    }
    return singleton;
}

這樣就規避了兩個線程同時創建Singleton對象的風險,但是引來另外一個問題:每次去獲取對象都需要先獲取鎖,併發性能非常地差,極端情況下,可能會出現卡頓現象。

接下來要做的就是優化性能:目標是如果沒有實例化對象則加鎖創建,如果已經實例化了,則不需要加鎖,直接獲取實例

所以直接在方法上加鎖的方式就被廢掉了,因爲這種方式無論如何都需要先獲取鎖

public static Singleton getInstance() {
    if (singleton == null) {  // 線程A和線程B同時看到singleton = null,如果不爲null,則直接返回singleton
        synchronized(Singleton.class) { // 線程A或線程B獲得該鎖進行初始化
            if (singleton == null) { // 其中一個線程進入該分支,另外一個線程則不會進入該分支
                singleton = new Singleton();
            }
        }
    }
    return singleton;
}

上面的代碼已經完美地解決了併發安全 + 性能低效問題:

  • 第 2 行代碼,如果 singleton 不爲空,則直接返回對象,不需要獲取鎖;而如果多個線程發現 singleton 爲空,則進入分支;
  • 第 3 行代碼,多個線程嘗試爭搶同一個鎖,只有一個線程爭搶成功,第一個獲取到鎖的線程會再次判斷singleton 是否爲空,因爲 singleton 有可能已經被之前的線程實例化
  • 其它之後獲取到鎖的線程在執行到第 4 行校驗代碼,發現 singleton 已經不爲空了,則不會再 new 一個對象,直接返回對象即可
  • 之後所有進入該方法的線程都不會去獲取鎖,在第一次判斷 singleton 對象時已經不爲空了

因爲需要兩次判空,且對類對象加鎖,該懶漢式寫法也被稱爲:Double Check(雙重校驗) + Lock(加鎖)

完整的代碼如下所示:

public class Singleton {
    
    private static Singleton singleton;
    
    private Singleton(){}
    
    public static Singleton getInstance() {
        if (singleton == null) {  // 線程A和線程B同時看到singleton = null,如果不爲null,則直接返回singleton
            synchronized(Singleton.class) { // 線程A或線程B獲得該鎖進行初始化
                if (singleton == null) { // 其中一個線程進入該分支,另外一個線程則不會進入該分支
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
    
}

上面這段代碼已經近似完美了,但是還存在最後一個問題:指令重排

使用 volatile 防止指令重排

創建一個對象,在 JVM 中會經過三步:

(1)爲 singleton 分配內存空間

(2)初始化 singleton 對象

(3)將 singleton 指向分配好的內存空間

指令重排序是指:JVM 在保證最終結果正確的情況下,可以不按照程序編碼的順序執行語句,儘可能提高程序的性能

在這三步中,第 2、3 步有可能會發生指令重排現象,創建對象的順序變爲 1-3-2,會導致多個線程獲取對象時,有可能線程 A 創建對象的過程中,執行了 1、3 步驟,線程 B 判斷 singleton 已經不爲空,獲取到未初始化的singleton 對象,就會報 NPE 異常。文字較爲晦澀,可以看流程圖:

image.png

使用 volatile 關鍵字可以防止指令重排序,其原理較爲複雜,這篇文章不打算展開,可以這樣理解:使用 volatile 關鍵字修飾的變量,可以保證其指令執行的順序與程序指明的順序一致,不會發生順序變換,這樣在多線程環境下就不會發生 NPE 異常了。

volatile 還有第二個作用:使用 volatile 關鍵字修飾的變量,可以保證其內存可見性,即每一時刻線程讀取到該變量的值都是內存中最新的那個值,線程每次操作該變量都需要先讀取該變量。

最終的代碼如下所示:

public class Singleton {
    
    private static volatile Singleton singleton;
    
    private Singleton(){}
    
    public static Singleton getInstance() {
        if (singleton == null) {  // 線程A和線程B同時看到singleton = null,如果不爲null,則直接返回singleton
            synchronized(Singleton.class) { // 線程A或線程B獲得該鎖進行初始化
                if (singleton == null) { // 其中一個線程進入該分支,另外一個線程則不會進入該分支
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
    
}

破壞懶漢式單例與餓漢式單例

無論是完美的懶漢式還是餓漢式,終究敵不過反射和序列化,它們倆都可以把單例對象破壞掉(產生多個對象)。

利用反射破壞單例模式

下面是一段使用反射破壞單例模式的例子

public static void main(String[] args) {
    // 獲取類的顯式構造器
    Constructor<Singleton> construct = Singleton.class.getDeclaredConstructor();
    // 可訪問私有構造器
    construct.setAccessible(true); 
    // 利用反射構造新對象
    Singleton obj1 = construct.newInstance(); 
    // 通過正常方式獲取單例對象
    Singleton obj2 = Singleton.getInstance(); 
    System.out.println(obj1 == obj2); // false
}

上述的代碼一針見血了:利用反射,強制訪問類的私有構造器,去創建另一個對象

利用序列化與反序列化破壞單例模式

下面是一種使用序列化和反序列化破壞單例模式的例子

public static void main(String[] args) {
    // 創建輸出流
    ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file"));
    // 將單例對象寫到文件中
    oos.writeObject(Singleton.getInstance());
    // 從文件中讀取單例對象
    File file = new File("Singleton.file");
    ObjectInputStream ois =  new ObjectInputStream(new FileInputStream(file));
    Singleton newInstance = (Singleton) ois.readObject();
    // 判斷是否是同一個對象
    System.out.println(newInstance == Singleton.getInstance()); // false
}

兩個對象地址不相等的原因是:readObject() 方法讀入對象時它必定會返回一個新的對象實例,必然指向新的內存地址。

讓面試官鼓掌的枚舉實現

我們已經掌握了懶漢式與餓漢式的常見寫法了,通常情況下到這裏已經足夠了。但是,追求極致的我們,怎麼能夠止步於此,在《Effective Java》書中,給出了終極解決方法,話不多說,學完下面,真的不虛面試官考你了。

在 JDK 1.5 後,使用 Java 語言實現單例模式的方式又多了一種:枚舉

枚舉實現單例模式完整代碼如下:

public enum Singleton {
    INSTANCE;
    
    public void doSomething() {
        System.out.println("這是枚舉類型的單例模式!");
    }
}

使用枚舉實現單例模式較其它兩種實現方式的優勢有 3 點,讓我們來細品。

優勢 1 :一目瞭然的代碼

代碼對比餓漢式與懶漢式來說,更加地簡潔。最少只需要3行代碼,就可以完成一個單例模式:

public enum Test {
    INSTANCE;
}

我們從最直觀的地方入手,第一眼看到這3行代碼,就會感覺到,沒錯,就是少,雖然這優勢有些牽強,但寫的代碼越少,越不容易出錯。

優勢 2:天然的線程安全與單一實例

它不需要做任何額外的操作,就可以保證對象單一性與線程安全性。

我寫了一段測試代碼放在下面,這一段代碼可以證明程序啓動時僅會創建一個 Singleton 對象,且是線程安全的。

我們可以簡單地理解枚舉創建實例的過程:在程序啓動時,會調用 Singleton 的空參構造器,實例化好一個Singleton 對象賦給 INSTANCE,之後再也不會實例化

public enum Singleton {
    INSTANCE;
    Singleton() { System.out.println("枚舉創建對象了"); }
    public static void main(String[] args) { /* test(); */ }
    public void test() {
        Singleton t1 = Singleton.INSTANCE;
        Singleton t2 = Singleton.INSTANCE;
        System.out.print("t1和t2的地址是否相同:" + t1 == t2);
    }
}
// 枚舉創建對象了
// t1和t2的地址是否相同:true

除了優勢1和優勢2,還有最後一個優勢是 保護單例模式,它使得枚舉在當前的單例模式領域已經是 無懈可擊

優勢 3:枚舉保護單例模式不被破壞

使用枚舉可以防止調用者使用反射、序列化與反序列化機制強制生成多個單例對象,破壞單例模式。

防反射

image-20200718213354831.png

枚舉類默認繼承了 Enum 類,在利用反射調用 newInstance() 時,會判斷該類是否是一個枚舉類,如果是,則拋出異常。

防止反序列化創建多個枚舉對象

在讀入 Singleton 對象時,每個枚舉類型和枚舉名字都是唯一的,所以在序列化時,僅僅只是對枚舉的類型和變量名輸出到文件中,在讀入文件反序列化成對象時,使用 Enum 類的 valueOf(String name) 方法根據變量的名字查找對應的枚舉對象。

所以,在序列化和反序列化的過程中,只是寫出和讀入了枚舉類型和名字,沒有任何關於對象的操作。

image-20200718224707754.png

小結:

(1)Enum 類內部使用Enum 類型判定防止通過反射創建多個對象

(2)Enum 類通過寫出(讀入)對象類型和枚舉名字將對象序列化(反序列化),通過 valueOf() 方法匹配枚舉名找到內存中的唯一的對象實例,防止通過反序列化構造多個對象

(3)枚舉類不需要關注線程安全、破壞單例和性能問題,因爲其創建對象的時機與餓漢式單例有異曲同工之妙

總結

(1)單例模式常見的寫法有兩種:懶漢式、餓漢式

(2)懶漢式:在需要用到對象時才實例化對象,正確的實現方式是:Double Check + Lock,解決了併發安全和性能低下問題

(3)餓漢式:在類加載時已經創建好該單例對象,在獲取單例對象時直接返回對象即可,不會存在併發安全和性能問題。

(4)在開發中如果對內存要求非常高,那麼使用懶漢式寫法,可以在特定時候才創建該對象;

(5)如果對內存要求不高使用餓漢式寫法,因爲簡單不易出錯,且沒有任何併發安全和性能問題

(6)爲了防止多線程環境下,因爲指令重排序導致變量報NPE,需要在單例對象上添加 volatile 關鍵字防止指令重排序

(7)最優雅的實現方式是使用枚舉,其代碼精簡,沒有線程安全問題,且 Enum 類內部防止反射和反序列化時破壞單例。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章