設計模式:單例模式(精講)

設計理念

在有些系統中,爲了節省內存資源、保證數據內容的一致性,對某些類要求只能創建一個實例,這就是所謂的單例模式。
單例模式,是23種設計模式中使用最廣泛的一種設計模式,同是也是最重要的設計模式之一。

定義

單例(Singleton)模式的定義:指一個類只有一個實例,且該類能自行創建這個實例的一種模式。例如,Windows 中只能打開一個任務管理器,這樣可以避免因打開多個任務管理器窗口而造成內存資源的浪費,或出現各個窗口顯示內容的不一致等錯誤。

在計算機系統中,還有 Windows 的回收站、操作系統中的文件系統、多線程中的線程池、顯卡的驅動程序對象、打印機的後臺處理服務、應用程序的日誌對象、數據庫的連接池、網站的計數器、Web 應用的配置對象、應用程序中的對話框、系統中的緩存等常常被設計成單例。

特點

單例模式有 3 個特點:

  1. 單例類只有一個實例對象;
  2. 該單例對象必須由單例類自行創建;
  3. 單例類對外提供一個訪問該單例的全局訪問點;

結構與實現

單例模式是設計模式中最簡單的模式之一。通常,普通類的構造函數是公有的,外部類可以通過“new 構造函數()”來生成多個實例。但是,如果將類的構造函數設爲私有的,外部類就無法調用該構造函數,也就無法生成多個實例。這時該類自身必須定義一個靜態私有實例,並向外提供一個靜態的公有函數用於創建或獲取該靜態私有實例。

結構

單例模式的主要角色如下。

  1. 單例類:包含一個實例且能自行創建這個實例的類。
  2. 訪問類:使用單例的類。

其結構如圖 1 所示。

在這裏插入圖片描述

實現

單例模式最常見的有6種實現方式,每種方式都有其特點,都值得大家去仔細推敲。

  1. 懶漢式,線程不安全

    代碼實例:

package com.design.pattern.creationalPattern.singletonPattern;

public class Singleton {

    //Q:這裏可不可以去掉static?
    //A:不可以,因爲getInstance()方法是靜態方法,
    //不可以在靜態方法裏調用非靜態變量
    private static Singleton instance;

    private Singleton(){
        //構造器私有化,防止外部訪問,通過new Singleton()方式創建對象
    }

    /**
     * Q:如果多個線程同時訪問,會怎樣?
     * A:嚴格意義上,這種方式不能算真正意義上的單例。
     *   當線程併發,線程上下文切換時,
     *   假設 有線程一和線程二。
     *   線程一 通過 if(instance == null )後,
     *   在 nstance = new Singleton(); 被掛起
     *   線程二 正常執行完了整個getInstance()後,
     *   線程一獲得線程使用權。
     *   這時候線程一拿到的instance實例,就不是之前的實例對象了。
     * */
    public static Singleton getInstance(){

        if (instance == null){
            instance = new Singleton();
        }
        return instance;
    }


}

對於它線程不安全的情況,我們做個試驗來驗證。

package com.design.pattern.creationalPattern.singletonPattern;


public class UnSafeSingletonTest {

    public static void main(String[] args) {
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                Singleton instance1 = Singleton.getInstance();
                System.out.println(instance1);

            }
        });
        thread1.start();
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                Singleton instance2 = Singleton.getInstance();//在這裏打上debug斷點,當斷點到達這個位置時,在Singleton#getInstance()方法中instance = new Singleton(),打上斷點
                System.out.println(instance2);

            }
        });
        thread2.start();


    }
}

模擬線程上下文切換,這裏我模擬休眠線程,將斷點位置先打到了Singleton 類的instance = new Singleton()位置。

  1. 當線程一過來的時候,直接放開斷點,可以看到線程1和線程2同時到達,如圖一。
    在這裏插入圖片描述
  2. 當線程二過來的時候,在debug watches裏讓線程二休眠2毫秒,模擬線程上下文切換場景。如圖二
    在這裏插入圖片描述
  3. 再放開斷點,可以明顯的看到線程一和線程二都去創建了實例對象,並且線程一現在持有的引用是線程二創建的對象地址。這時候,線程一再去使用instance就可能出現問題,得到結果如下:

SingletonThread2 SingletonThread1
com.design.pattern.creationalPattern.singletonPattern.Singleton@17ceefc4
com.design.pattern.creationalPattern.singletonPattern.Singleton@1d4331b

  1. 懶漢式,線程安全
package com.design.pattern.creationalPattern.singletonPattern;

public class Singleton {

    //Q:這裏可不可以去掉static?
    //A:不可以,因爲getInstance()方法是靜態方法,不可以在靜態方法裏調用非靜態變量
    private static Singleton instance;

    private Singleton(){
        //構造器私有化,防止外部訪問,通過new Singleton()方式創建對象
    }

    /**
     * synchronized 關鍵字可以保證方法或者代碼塊在運行時,同一時刻只有一        個方法可以進入到臨界區,同時它還可以保證共享變量的內存可見性
     * */
    public synchronized static Singleton getInstance(){

        if (instance == null){
            instance = new Singleton();
        }
        return instance;
    }


}

這種方式具備很好的 lazy loading,能夠在多線程中很好的工作,但是,效率低,99% 情況下不需要同步。

優點:第一次調用才初始化,避免內存浪費。

缺點:必須加鎖 synchronized 才能保證單例,但加鎖會影響效率。
getInstance() 的性能對應用程序不是很關鍵(該方法使用不太頻繁)。

synchronized 關鍵字JDK隨着版本升級一直在進行優化,其實效率已經很不錯了,但是編程是一門科學性學科,實事求是的說,加這個關鍵字,會影響效率。

  1. 餓漢式
public class HungrySingleton {

    //private 私有化,外部不得調用,static 啓動時就分配該對象內存
    private static HungrySingleton instance = new HungrySingleton();

    private HungrySingleton(){} //私有化構造器,外部無法調用

    //提供類靜態方法,直接調用,得到唯一實例。
    public static HungrySingleton getInstance(){
        return instance;
    }

}

這種方式比較常用,但容易產生垃圾對象。

優點:沒有加鎖,執行效率會提高。

缺點:類加載時就初始化,浪費內存。

它基於 classloader 機制避免了多線程的同步問題,不過,instance 在類裝載時就實例化,雖然導致類裝載的原因有很多種,在單例模式中大多數都是調用 getInstance 方法, 但是也不能確定有其他的方式(或者其他的靜態方法)導致類裝載,這時候初始化 instance 顯然沒有達到 lazy loading 的效果。

浪費內存的原因,這個類在你的項目啓動時,就會去加載到JVM裏,如果你沒有去使用,就佔據了一小塊內存,如果項目中存在很多單例對象,會導致資源的浪費。

  1. 雙檢鎖/雙重校驗鎖(DCL,即 double-checked locking)
package com.design.pattern.creationalPattern.singletonPattern;

public class Singleton {

    //這裏加入volatile使其在多線程操作時,保持可見性
    private volatile static Singleton instance;

    private Singleton(){
        //構造器私有化,防止外部訪問,通過new Singleton()方式創建對象
    }
    
    public  static Singleton getInstance(){

        if (instance == null){ //第一次檢查instance是不是null 
            synchronized (Singleton.class){ //第二次加鎖再去檢查一遍是不是空,防止多線程併發導致線程上下文切換,生成多個實例對象。
                if (instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }


}

這種方式採用雙鎖機制,也是懶加載方式實現,安全且在多線程情況下能保持高性能。
getInstance() 的性能對應用程序很關鍵。

  1. 登記式/靜態內部類
package com.design.pattern.creationalPattern.singletonPattern;

public class Singleton {


    private Singleton(){
        //構造器私有化,防止外部訪問,通過new Singleton()方式創建對象
    }

    public  static Singleton getInstance(){

        return SingletonHolder.instance;
    }

    private static class SingletonHolder{
        
        private static final Singleton instance = new Singleton();
    }


}

這種方式能達到雙檢鎖方式一樣的功效,但實現更簡單,所以使用比較廣泛。對靜態域使用延遲初始化,應使用這種方式而不是雙檢鎖方式。這種方式只適用於靜態域的情況,雙檢鎖方式可在實例域需要延遲初始化時使用。
這種方式同樣利用了 classloader 機制來保證初始化 instance 時只有一個線程,它跟第 3 種方式不同的是:第 3 種方式只要 Singleton 類被裝載了,那麼 instance 就會被實例化(沒有達到 lazy loading 效果),而這種方式是 Singleton 類被裝載了,instance 不一定被初始化。因爲 SingletonHolder 類沒有被主動使用,只有通過顯式調用 getInstance 方法時,纔會顯式裝載 SingletonHolder 類,從而實例化 instance
想象一下,如果實例化 instance 很消耗資源,所以想讓它延遲加載,另外一方面,又不希望在 Singleton 類加載時就實例化,因爲不能確保 Singleton 類還可能在其他的地方被主動使用從而被加載,那麼這個時候實例化 instance 顯然是不合適的。這個時候,這種方式相比第 3 種方式就顯得很合理。

  1. 枚舉
public enum Singleton {  
    INSTANCE;  
    public void whateverMethod() {  
    }  
}

這種實現方式還沒有被廣泛採用,但這是實現單例模式的最佳方法。它更簡潔,自動支持序列化機制,絕對防止多次實例化。
這種方式是 Effective Java 作者 Josh Bloch 提倡的方式,它不僅能避免多線程同步問題,而且還自動支持序列化機制,防止反序列化重新創建新的對象,絕對防止多次實例化。不過,由於 JDK1.5 之後才加入 enum 特性,因爲枚舉類型是線程安全的,並且只會裝載一次,用這種方式寫不免讓人感覺生疏,在實際工作中,也很少用。
不能通過 reflection attack 來調用私有構造方法。

感謝大家的閱讀,如果對您有幫助,希望您能給我點個贊!謝謝!

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