單例模式我的整理

單例模式


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

單例模式是爲了保證在一個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關鍵字),在獲取對象的方法裏對對象進行判斷和建立.app

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

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

前兩種,將對象的建立時機放在了類的初始化階段,後面一種,則將對象的建立放在了類的使用階段.前兩種被稱爲餓漢式,第三種被稱爲懶漢式.餓漢式的優勢是簡單易懂,缺點是沒有達到懶加載的效果。若是從始至終從未使用過這個實例,就會比較浪費鏈接資源和內存.jvm

但懶漢式也並不複雜,能夠起到懶加載的效果.因而,讀者可能更願意使用懶漢式,或者其變種(好比具備雙重檢查鎖的懶漢式).你的理由是,節省內存,懶加載,並且還很酷.ide

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

  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

相關文章
相關標籤/搜索