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

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

什麼是單例模式

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

單例模式是指在內存中只會建立且僅建立一次對象的設計模式。在程序中屢次使用同一個對象且做用相同時,爲了防止頻繁地建立對象使得內存飆升,單例模式可讓程序僅在內存中建立一個對象,讓全部須要調用的地方都共享這一單例對象。設計模式

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 在保證最終結果正確的狀況下,能夠不按照程序編碼的順序執行語句,儘量提升程序的性能

在這三步中,第 二、3 步有可能會發生指令重排現象,建立對象的順序變爲 1-3-2,會致使多個線程獲取對象時,有可能線程 A 建立對象的過程當中,執行了 一、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 類內部防止反射和反序列化時破壞單例。

相關文章
相關標籤/搜索