單例模式個人整理

simplejian發表於2018-11-02

單例模式


本次簡單的講一下單例模式.在講的過程中,筆者會盡量把這個模式講清楚,講詳細,講簡單.同時也會給出目前使用單例模式的例子,並分析這些案例的實現方式.在這個過程中,你會發現,小小的單例模式裡卻有著很大的學問在.

單例模式是為了保證在一個jvm環境下,一個類僅有一個物件.一般來說,每講到單例模式,大家都會想起各種實現方式(比如:懶漢式,餓漢式),這些實現方式,是通過程式碼的設計來強制保證的單例,可以稱為強制性單例模式.當然,通過文件,通過編碼約束,也可以認為是實現了一個類僅有一個物件的效果.

通常,專案中的具有連線功能的類(比如:資料庫連線,網路連線,印表機的連線),具有配置功能的類,工具類,輔助系統類,會需要使用單例模式.這些類大多是建立和銷燬需要消耗大量的系統資源,或者不需要建立多個物件(物件之間無差別).

在Java中,建立單例模式都有兩個必不可少的步驟

  1. 私有化類的所有建構函式,以阻止其他程式碼在該類的外界去建立物件,
  2. 提供獲取該物件的靜態方法,以返回該類唯一的物件引用.

基於以上兩條,可以簡單寫出三種單例模式:

第一種寫法:通過類的靜態變數來持有一個該類的物件的引用,同時使用final關鍵字來阻止其被再次賦值.

public class Singleton1 {
    private static final Singleton1 INSTANCE = new Singleton1();
 private Singleton1(){}
    public static Singleton1 getInstance(){
        return INSTANCE;
  }
}
複製程式碼

第二種寫法:這種方法和第一種方法大同小異,同樣是使用靜態變數維護該類的引用,但將物件建立的放在了靜態程式碼塊中.

public class Singleton2 {
    private static final Singleton2 INSTANCE ;
    static {
        INSTANCE=new Singleton2();
    }
    private Singleton2(){}
    public static Singleton2 getInstance(){
        return INSTANCE;
    }
}

複製程式碼

第三種寫法:使用靜態變數維持類的物件的引用(這種情況下,由於java語法的限制,將無法使用final關鍵字),在獲取物件的方法裡對物件進行判斷和建立.

public class Singleton3 {
    private static Singleton3 instance;
 private Singleton3(){}

    public static Singleton3 getInstance() {
        if(null==instance){
            instance=new Singleton3();
  }
        return instance;
  }
}
複製程式碼

前兩種,將物件的建立時機放在了類的初始化階段,後面一種,則將物件的建立放在了類的使用階段.前兩種被稱為餓漢式,第三種被稱為懶漢式.餓漢式的優點是簡單易懂,缺點是沒有達到懶載入的效果。如果從始至終從未使用過這個例項,就會比較浪費連線資源和記憶體.

但懶漢式也並不複雜,可以起到懶載入的效果.於是,讀者可能更願意使用懶漢式,或者其變種(比如具有雙重檢查鎖的懶漢式).你的理由是,節省記憶體,懶載入,而且還很酷.

但事實又是如何呢?為了弄清楚這兩種單例方式,需要簡單回憶一下類的生命週期.

  1. 類的載入:將類的位元組碼檔案(.class檔案)從硬碟載入方法區的過程
  2. 類的連線:該過程由三個部分組成:驗證、準備和解析,
  3. 類的初始化:將靜態變數賦值,執行的順序就是: 父類靜態變數->靜態程式碼塊->子類靜態變數->子類靜態程式碼塊,餓漢式的物件建立處於這個階段
  4. 類的使用,如類的例項化,懶漢式的物件建立處於這個階段,new關鍵字可以觸發該生命週期
  5. 類解除安裝

那麼問題來了,什麼時候會對類進行初始化呢?根據類的五個生命週期階段,我們只需要驗證在建立物件之前的那些操作能夠觸發類的初始化就行.筆者使用jdk1.8,預設配置,進行了簡單的實驗.首先在構造方法裡新增列印語句,列印“init”,然後再新增一個靜態方法和一個靜態變數.對Singleton1進行檢驗.

public class Singleton1 {
  private static final Singleton1 INSTANCE = new Singleton1();
  //新增列印語句
  private Singleton1(){
        System.out.println("init");
  }
  public static Singleton1 getInstance(){
        return INSTANCE;
  }
    //靜態方法
  public static final void otherMethod(){
    }
    //靜態變數
  public static final int staticFiled=0; 
 }

複製程式碼

測試1:僅僅進行宣告

//測試1: 
public class Test {
    public static void main(String[] args) {
        System.out.println("-------start-------");
        Singleton1 singleton1 = null;
        if(null==singleton1){
            System.out.println("singleton1 is null");
  }
        System.out.println("-------end-------");
  }

    /* out:
     * -------start------- 
     * singleton1 is null 
     * -------end--------- 
     */ 
}
複製程式碼

從輸出上看,僅僅宣告,不會觸發類的初始化階段. 測試2:呼叫類的靜態變數

//測試2: 
public class Test {
    public static void main(String[] args) {
      System.out.println("-------start-------");
      System.out.println(Singleton1.staticFiled);
      System.out.println("-------end---------");
  }

    /* out:
     *-------start-------
     *0
     *-------end--------- 
     */ }
複製程式碼

從輸出上看,僅僅呼叫類的靜態變數,不會觸發類的初始化階段. 測試3:呼叫類的靜態方法

//測試3 
public class Test {
    public static void main(String[] args) {
        System.out.println("-------start-------");
        Singleton1.otherMethod();
        System.out.println("-------end-------");
  }

    /* out:
     *-------start------- 
     *   init 
     *-------end------- 
     */ 
}
複製程式碼

從輸出上看,僅僅呼叫類的靜態方法,會觸發類的初始化階段.

通過上面的三個例子,可以看出餓漢式,在某種情況下,也是可以表現出懶載入的效果,並且餓漢式簡單,而且不會產生執行緒安全的問題,在某些情況下是可以代替懶漢式的.並且隨著現在硬體的發展,懶漢式的節省記憶體的優點也可以慢慢的忽略不計了.

在設計上,懶漢式要優於餓漢式,在使用上,能夠恰好解決問題的就是好的設計.

在多執行緒的情況下,懶漢式會有一定修改.當兩個執行緒在if(null==instance)語句阻塞的時候,可能由兩個執行緒進入建立例項,從而返回了兩個物件,這是一個概率性的問題,一但出現,排查和定位問題都具有運氣性.對此,我們可以加鎖,以保證每次僅有一個執行緒處於getInstance()方法中,從而保證了執行緒一致性.多執行緒下的單例模式可以為

public class Singleton4 {
    private static Singleton4 instance;
 private Singleton4(){}

    public static synchronized Singleton4  getInstance() {
        if(null==instance){
            instance=new Singleton4();
  }
        return instance;
  }
}

複製程式碼

Singleton4相對於Singleton3,只是在getInstance方法上加了一個鎖(靜態方法以Singleton4.class物件為鎖,非靜態方法鎖以this物件為鎖).從而保證了,每次僅有一個執行緒進入內部的程式碼快.試想,一個專案中若有100處獲取例項,那麼jvm就會有100次進行加鎖,放鎖的操作,但僅有一次實現了對物件的建立,jvm加鎖放鎖的操作都需要對物件頭進行讀寫操作,每一次的操作都比較耗費資源.所以該方式實現的單例的模式的效率並不高.instance不為null的概率非常非常高,但又同時要相容多個執行緒下的安全性,可以在外面再加一層的判斷.可以寫成下面的形式

public class Singleton4 {
    private static Singleton4 instance;
    private Singleton4(){}

    private static synchronized void doGetInstance() {
        if(null==instance){
            instance=new Singleton4();
        }
    }
    public static synchronized Singleton4 getInstance(){
        if(null==instance){
            doGetInstance();
        }
        return instance;
  }
複製程式碼

簡化一下程式碼,可以寫成如下的形式:

public class Singleton5 {

    private static Singleton5 instance;  
    private Singleton5() {
    }

    public static Singleton5 getInstance() {
        if (null == instance) {
            synchronized (Singleton5.class) {
                if (null == instance) {
                    instance = new Singleton5();
  }
            }
        }
        return instance;
  }
}
複製程式碼

上面的這種形式,也就是所謂的雙重檢查的單例模式寫法,如果多個執行緒同時了通過了第一次檢查,並且其中一個執行緒首先通過了第二次檢查並例項化了物件,那麼剩餘通過了第一次檢查的執行緒就不會再去例項化物件.即提升了效率,又可以顯得很牛逼.

但上面的形式還是有些的問題的.還記得上面說的類的生命週期嗎?這裡再詳細展開說明類的連線過程.

類的連線過程又分為 驗證階段,準備階段解析階段.驗證階段不用多講,在這個階段,jvm對類的合法性進行驗證(很多基於jvm的語言都有自己的工具生成java位元組碼,比如clojure,kotlin等).

準備階段,則是將類裡的靜態變數賦予預設值,解析階段則是將符號引用轉換為直接引用.同時如果一個類被直接引用,就會觸發類的初始化(處於連線過程的下一個過程).總的來說,一個類在碰到new關鍵字的時候,一般經歷以下三個順序:

  1. 開闢空間,
  2. 符號引用改空間,並在空間內對類進行初始化操作,
  3. 將符合引用轉為直接引用(這個時候if(null==instance)返回false).

可在實際的情況中,為了降低cpu的閒置時間,jvm往往對指令進行重新排序以形成指令流水線.也就是說以三個部署可能是亂序的,可能為

  1. 開闢空間,
  2. 轉為直接引用(這個時候if(null=instance)返回false)),
  3. 初始化類.

因此上面的雙重檢查機制就會出現問題:可能返回一個未被完全初始化的類.

volatile的作用

  1. 可見性:可以將執行緒和堆記憶體理解比喻為計算機的cpu的核與主記憶體.cpu的每一個核心都有自己的快取,常用資料首先寫入自己的快取,然後再寫入主記憶體.這樣會導致最新的資料不能及時的在主記憶體中存在,但其卻能極大的提升效率.同樣的jvm中每一個執行緒也有自己的記憶體區域.對變數(不是方法中的臨時變數,臨時變數存在於jvm棧)使用volatile修飾,可以強制將每一次的讀寫都寫入到堆中,實現了各個執行緒都能共享的最新資料的效果.
  2. 禁止指令重排序優化:由上面的討論可指,被volatile修飾的變數,在賦值的結尾會插入一個記憶體屏障(不要被這個名詞嚇到了),從而防止指令重排序. volatile增強了資料的一致性,但降低了速率. 由此可知,上面的寫法需要進行稍微的修改:
public class Singleton5 {

    private static volatile Singleton5 instance;         
    private Singleton5() {}
    public static Singleton5 getInstance() {
        if (null == instance) {
            synchronized (Singleton5.class) {
                if (null == instance) {
                    instance = new Singleton5();
  }
            }
        }
        return instance;
  }
}
複製程式碼

講到這裡,可以再講一下Singleton1(餓漢式)的寫法,上面說過,這種寫法的問題是在於沒有達到懶載入的效果.但其具有清晰的結構,對執行緒友好的特點.如果能夠在其結構上進行簡單的改造,使其具有懶載入的效果,那就完美了!

靜態巢狀類的使用用

  • 靜態巢狀類 : 是一種在類之外宣告的巢狀類,由於是靜態的,所以不經過初始化,就可以通過類名直接呼叫.
  • 內部類 : 即該類作為另一個類的成員,因此只有引用另一個類,才能建立這個類. 通過靜態巢狀類,便可以實現對餓漢式進行懶化的效果.
public class Singleton7 {
    private Singleton7(){}
    private static  class SingletonHolder {
        private static  Singleton7 INSTANCE = new Singleton7();
  }
    public static final Singleton7 getInstance() {
        return SingletonHolder.INSTANCE;
  }
}
複製程式碼

對比Singleton1,這裡做了什麼改變?僅僅是唯一的一個類的物件被靜態巢狀類包裹了一下.要分析這種方式有沒有實現懶載入,就要分析一下語句new Singleton7();是在什麼時候被呼叫了. 當使用javac 進行編譯Singleton7時,會生成三個class檔案:

  1. Singleton7$1.class
  2. Singleton7$SingletonHolder.class
  3. Singleton7.class

第一個檔案可以忽略,是一個空殼檔案(讀者可以通過反編譯外掛檢視原始碼).可以看到靜態巢狀類是單獨作為一個class存在的,而其中建立物件的邏輯位於巢狀類中,jvm讀取巢狀類的位元組碼以後才能建立物件.從硬碟中讀取class檔案,再在記憶體中分配空間,是一件費事費力的工作,所以jvm選擇按需載入,沒有必要載入的就不載入,沒必要分配就不分配.

所以Singleton7從連線和初始化的時候,不會去讀取靜態巢狀類的class檔案,當然也就不能建立Singleton7物件.在呼叫getInstance時,jvm不得不去載入位元組碼檔案,但不一定需要對類進行初始化.所以結論就是:用靜態巢狀類包裹物件的建立過程,可以實現懶載入的同時,又不會讓靜態巢狀類進行初始化!下面開始實驗驗證.首先對Singleton7進行修改,加入日誌:

public class Singleton7 {
    private Singleton7(){
        System.out.println("Singleton7");
  }
    private static final class SingletonHolder {
        SingletonHolder(){
            System.out.println("SingletonHolder");
  }
        private static final Singleton7 INSTANCE = new Singleton7();
  }
    public static Singleton7 getInstance() {
        return SingletonHolder.INSTANCE;
  }
}
複製程式碼

測試類:

public class Test {
    public static void main(String[] args) {
        System.out.println("-------start-------");
        Singleton7.getInstance();
        System.out.println("-------end---------");
  }
    /* out:
     *--------start------     
     *Singleton7 
     *-------end--------- 
     */ 
}
複製程式碼

沒有輸出SingletonHolder!!!,這個說明了什麼? 到這裡似乎就是要大結局了:我們似乎已經嚴格且完美實現了一個類在一個jvm環境下僅有一個物件了!!!但事實真是如此嗎?

利用JAVA反射破壞單例模式

上面的單例,最主要的一步是將構造方法私有化,從而外界無法new物件.但java的反射可以強制訪問private修飾的變數,方法,建構函式!所以:

//測試3 public class Test {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class<Singleton7> singleton7Class = Singleton7.class;
  Constructor<Singleton7> constructor= singleton7Class.getDeclaredConstructor();
  //這裡有個萬惡的方法
  constructor.setAccessible(true);
  Singleton7 singleton1=constructor.newInstance();
  Singleton7 singleton2=constructor.newInstance();
  }
    /**out
     * Singleton7 
     * Singleton7 
     */ 
}
複製程式碼

看來我們的單例並不是安全的.在java中,有四種建立物件的方式

方式 說明
new 需要呼叫建構函式
反射 需要呼叫建構函式,免疫一切訪問許可權的限制(public,private等)
clone 需要實現Cloneable介面,又分深複製,淺複製
序列化 1.將物件儲存在硬碟中 2.通過網路傳輸物件,需要實現Serializable

而上面介紹的各種單例模式,是不能抵抗反射,clone,序列化的破壞的.

現在考慮如何保護單例模式.對於clone和序列化,可以在設計的過程中不直接或者間接的去實現Cloneable和Serializable介面即可.對於反射,通常來說,使用普通類難以避免(可以通過在呼叫第二次建構函式的方式進行避免,但這並不是完全之策,詳情可以自行搜尋相關內容).

列舉類

列舉類是Java 5中新增特性的一部分,它是一種特殊的資料型別,之所以特殊是因為它既是一種類(class)型別卻又比類型別多了些特殊的約束.列舉類能夠實現介面,但不能繼承類,列舉類使用enum定義後在編譯時就預設繼承了java.lang.Enum類,而不是普通的繼承Object類.列舉類會預設實現Serializable和Comparable兩個介面,且採用enum宣告後,該類會被編譯器加上final宣告,故該類是無法繼承的.列舉類的內部定義的列舉值就是該類的例項.除此之外,列舉類和普通類一致.因此可以利用列舉類來實現一個單例模式

public enum Singleton8
{
    INSTANCE;
  //該方法可有可無
  public static Singleton8 getInstance(){
        return INSTANCE;
  }
    //.....other method 
}
複製程式碼

這個就怎麼能夠防止反射破壞類呢?可以看一下下面的程式碼片段

public T newInstance(Object ... initargs)
    throws InstantiationException, IllegalAccessException,
  IllegalArgumentException, InvocationTargetException
{
    if (!override) {
        if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
            Class<?> caller = Reflection.getCallerClass();
  checkAccess(caller, clazz, null, modifiers);
  }
    }
    //反射在通過newInstance建立物件時,會檢查該類是否ENUM修飾,如果是則丟擲異常,反射失敗。
    if ((clazz.getModifiers() & Modifier.ENUM) != 0)
        throw new IllegalArgumentException("Cannot reflectively create enum objects");
  ConstructorAccessor ca = constructorAccessor; // read volatile
  if (ca == null) {
        ca = acquireConstructorAccessor();
  }
    @SuppressWarnings("unchecked")
    T inst = (T) ca.newInstance(initargs);
 return inst; }
複製程式碼

上面擷取的是java.lang.reflect.Constructor類的newInstance,可以看出,噹噹前類是列舉型別時,就會丟擲異常,因此列舉類可以抗得住反射攻擊!

既然列舉類預設實現了Serializable,那麼就能夠對列舉類進行序列化操作

public class Test2 {
    public static void main(String[] args) throws Exception{
        File objectFile =new File("Singleton8.javaObject");
        Singleton8 instance1=Singleton8.INSTANCE;
        Singleton8 instance2=null;    
        //序列化到本地
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(objectFile));
        objectOutputStream.writeObject(instance1);
        objectOutputStream.flush();    
        //反序列化到記憶體
        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(objectFile));
        instance2= (Singleton8) objectInputStream.readObject();           objectInputStream.close();
        objectOutputStream.close();
        //true,說明兩者引用著同一個物件
        System.out.println(Objects.equals(instance1,instance2));
  }
}
複製程式碼

在複製操作上.列舉類直接繼承java.lang.Enum類,而非Object類,無法實現複製操作.

到這裡,單例模式的設計方式就告一段落了,下面再給出兩個簡單的小案例.

1.實現執行緒內的單例

對於實現執行緒內的單例,直觀的做法是利用一個map<Long,Singleton9>來儲存物件.其中key可以為執行緒的ID,value為每個執行緒下獨有的物件.我們可以做的更好,可以用ThreadLocal來做執行緒的變數隔離! 執行緒級單例設計如下

public class Singleton9 {
    private Singleton9(){}
    private static final ThreadLocal<Singleton9> threadHolder = new ThreadLocal<>(){
        @Override
  protected Singleton9 initialValue() {
            return new Singleton9();
  }
    };
 public static final Singleton9 getInstance(){
        return threadHolder.get();
  }
}
複製程式碼

2.HttpServlet的單例多執行緒模式

Tomcat的Servlet在需要時被建立載入,以後的請求都將利用同一個Servlet物件.是一個典型的單例多執行緒模式,Tomcat裡的StandardWrapper裡的loadServlet可看的出來.對於單例多執行緒,注意以下問題

  1. 使用棧封閉來管理變數,將變數封閉到執行緒棧內,防止變數被其他執行緒汙染(上面的說話過於裝酷,其實就是避免使用例項變數)
  2. 使用要使用ThreadLocal對例項變數進行變數隔離
  3. 實現 SingleThreadModel 介面:實現SingleThreadModel的Servlet中的service方法將不會有兩個執行緒被同時執行.

程式的設計模式,實用性大於理論性.所以,只要能夠恰好的解決問題的設計模式就是好的設計模式.

筆者才疏學淺,上述內容只是個人的整理,請客觀的閱讀,對於錯誤的地方,還請讀者能夠及時給於指出和更正,歡迎一起討論!

email:simplejian@foxmail.com

相關文章