細說Java中的幾種單例模式

在Java中,單例模式分爲不少種,本人所瞭解的單例模式有如下幾種,若有不全還請你們留言指點:java

  • 餓漢式
  • 懶漢式/Double check(雙重檢索)
  • 靜態內部類
  • 枚舉單例

1、餓漢式android

餓漢式是在jvm加載這個單例類的時候,就會初始化這個類中的實例,在使用單例中的實例時直接拿來使用就好,由於加載這個類的時候就已經完成初始化,而且因爲是已經加載好的單例實例所以是線程安全的,併發獲取的狀況下不會有問題,是一種可投入使用的可靠單例。面試

優勢:使用起來效率高、線程安全安全

缺點:因爲jvm在加載單例類的時候須要初始化單例實例,所以在加載單例的時候針對jvm內存不夠友好。多線程

2、懶漢式併發

最簡單的懶漢式,核心思想就是彌補餓漢式的缺點,在jvm加載單例類的時候不去初始化實例,而是在第一次獲取實例的時候再去初始化實例。可是這樣理論完美的單例在使用的時候有一個致命的缺點,在多線程使用的狀況下,有時會出現不一樣線程從單例實例中獲取不一樣的實體。針對多線程環境中並不可靠。app

優勢:針對jvm內存比較友好,實現了實例的懶加載。jvm

缺點:多線程環境下不安全,會出現不一樣線程從單例實例中獲取不一樣的實體的狀況。ide

具體爲何會出現不一樣線程從單例實例中獲取不一樣的實體的狀況呢?以下圖,咱們經過分析去解釋,爲什麼他是線程不安全的。函數

假設,當前有兩個線程同時首次獲取此單例中的實例時:

  1. 線程一執行getInstence方法,並判斷instance實例是否已經被初始化。
  2. 線程一判斷instance爲null,執行到 2 處,此時線程一尚未開始執行,而後執行權被線程二獲取,線程一進入等待。
  3. 線程二執行到 1 處判斷instance爲null,由於線程一即將開始初始化instance,可是尚未初始化。
  4. 線程二執行到 2 處開始初始化instence方法,並完成初始化,返回一個instance實例。
  5. 這時線程一被喚醒,繼續從 2 處執行,開始初始化instence方法,而且也返回一個instance實例。

這樣,線程一和線程二從單例中獲取了兩個不一樣的實例。針對懶漢式的這種線程不安全的現象,攻城獅們也是開始頭腦風暴來改善它,比較容易想到的是將getInstence方法加鎖,來實現懶漢式的線程安全:

這樣雖然看似解決問題了,可是未免太過於激進了,synchronized鎖住獲取實例的整個方法,所以在併發獲取單例實例的時候會有性能問題,而且線程安全問題的出現只是在第一次獲取實例的狀況纔會出現,初始化以後不會再出現性能問題,synchronized鎖的運用未免因小失大。

因而爲了線程安全,還爲了能在併發狀況下高效的性能,便有了Double check(雙重檢索)的懶漢式單例

Double check的理論爲:當第一次建立單例實例的時候,只有一個線程能夠去建立實例,所以不會出現多個線程獲取不一樣實例的狀況。

假設時間序列:

  1. 線程一進入getInstence方法
  2. 線程一判斷instence爲null,並在 1 處進入synchronize塊,此時線程二開始執行,線程一等待
  3. 線程二進入getInstence方法,判斷instence爲null,並準備進入synchronize塊,此時發現synchronize塊的鎖被佔用,所以進入等待
  4. 線程一開始再次判斷instence爲null,而後開始初始化instence實例,而後釋放synchronize的鎖,獲取到了實例執行完成
  5. 此時線程二開始獲得synchronize鎖,進入synchronize塊再次判斷instence是否爲null,發現instence此時已經有值,釋放鎖,直接獲取instence實例返回

Double check的理論看起來很是的完美,然而一切到頭來發現僅僅是想得美而已,在實際運行中他仍是有問題的。

年輕稚嫩的猿也許會一臉懵逼,老謀深算的猿也許會微微一笑,可是可能他們都會想 弄啥子嘞?

其實,這個理論的失敗,並非jvm實現的bug,而是歸咎於Java平臺的內存模型,Java的內存模型是圍繞着如何在併發過程當中處理原子性、可見性、有序性這3個特徵創建的,而針對有序性,引用深刻JVM虛擬機中的一句話解釋是:若是在本線程中觀察,全部的操做都是有序的;若是在一個線程中觀察另外一個線程,全部的操做都是無序的。前半句是指「線程內表現爲串行指令」,後半句是指「指令重排序」現象和「工做內存與主內存同步延遲」現象。而針對原子性,看似簡單一行代碼,通過虛擬機編譯成字節碼信息後,可能就不是一行代碼了。而針對可見性,一個線程改變的變量值,並不會馬上對其餘線程可見。

而上面Double check代碼失敗的源頭就是 instence = new DoubleCheck(); 這句話,而這句看似簡的一句話,其實在虛擬機中分紅了三個步驟:

  1. 爲即將實例化的對象分配內存空間
  2. 初始化單例實體對象執行構造函數
  3. 將內存空間地址賦值給instence實例引用

也就是說其實咱們所謂的new對象 並非一個原子操做,而且,針對上面的2 3 步驟虛擬機會進行指令重排序,若是上面的Double check代碼的對象實例化的通過重排序順序變成1 3 2 的話,就會出現問題:

  1. 線程一進入getInstence方法
  2. 線程一判斷instence爲null,並在 1 處進入synchronize塊
  3. 線程一再次判斷instence爲null,最後執行到 3 處,然而分配完內存,獲取到實例地址,此時instence再也不爲null,可是還未初始化對象執行構造方法,此時縣城而獲取執行權,線程一被掛起
  4. 線程二獲取getInstence方法,並判斷instence再也不null,而後獲取到了一個instence對象的地址,可是此時instence對象並未完成初始化,線程二後續執行就會出現問題
  5. 線程一此時甦醒,完成後面的instence對象初始化的動做,並返回實例

然而在jdk1.5之後,這種狀況有了解決方法,緣由在於jdk1.5開始針對volatile進行了加強,volatile變量開始能夠屏蔽指令重排,也就是說

當咱們將instence引用進行volatile進行修飾的話instence = new DoubleCheck();這句話中的指令將不會被指令重排序,Double check也就再也不只是想一想了。附上完整代碼:

3、靜態內部類

靜態內部類的優勢是:外部類加載時並不會當即加載內部類,內部類不被加載就不去初始化實例,所以實現了懶加載。當StaticSingle第一次被加載時,並不須要去加載內部類Holder,只有當getInstance()方法第一次被調用時,纔會致使虛擬機加載Holer類菜會去初始化StaticSingle實例。這種方法不只能確保線程安全,也能保證單例的惟一性,同時也延遲了單例的實例化。

那麼靜態內部類是如何實現線程安全的呢?咱們須要瞭解下面一些只是

針對於類的初始化,JVM虛擬機嚴格規定了有且僅有5種狀況必須對類進行「初始化「:

  1. 遇到new、getstatic、setstatic或者invikestatic這4個字節碼指令時,對應的java代碼場景爲:new一個關鍵字或者一個實例化對象時、讀取或設置一個靜態字段時(final修飾、已在編譯期把結果放入常量池的除外)、調用一個類的靜態方法時。
  2. 使用java.lang.reflect包的方法對類進行反射調用的時候,若是類沒進行初始化,須要先調用其初始化方法進行初始化。
  3. 當初始化一個類時,若是其父類還未進行初始化,會先觸發其父類的初始化。
  4. 當虛擬機啓動時,用戶須要指定一個要執行的主類(包含main()方法的類),虛擬機會先初始化這個類。
  5. 當使用JDK 1.7等動態語言支持時,若是一個java.lang.invoke.MethodHandle實例最後的解析結果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,而且這個方法句柄所對應的類沒有進行過初始化,則須要先觸發其初始化。

這5種狀況被稱爲是類的主動引用,注意,這裏《虛擬機規範》中使用的限定詞是"有且僅有",那麼,除此以外的全部引用類都不會對類進行初始化,稱爲被動引用。靜態內部類就屬於被動引用的行列。

咱們再回頭看下getInstance()方法,調用的是Holer.INSTANCE,取的是Holer裏的INSTANCE對象,跟上面那個DCL方法不一樣的是,getInstance()方法並無屢次去new對象,故無論多少個線程去調用getInstance()方法,取的都是同一個INSTANCE對象,而不用去從新建立。當getInstance()方法被調用時,Holer纔在StaticSingle的運行時常量池裏,把符號引用替換爲直接引用,這時靜態對象INSTANCE也真正被建立,而後再被getInstance()方法返回出去,這點同餓漢模式。那麼INSTANCE在建立過程當中又是如何保證線程安全的呢?在《深刻理解JAVA虛擬機》中,有這麼一句話:

虛擬機會保證一個類的()方法在多線程環境中被正確地加鎖、同步,若是多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的()方法,其餘線程都須要阻塞等待,直到活動線程執行()方法完畢。若是在一個類的()方法中有耗時很長的操做,就可能形成多個進程阻塞(須要注意的是,其餘線程雖然會被阻塞,但若是執行()方法後,其餘線程喚醒以後不會再次進入()方法。同一個加載器下,一個類型只會初始化一次。),在實際應用中,這種阻塞每每是很隱蔽的。

故而,能夠看出INSTANCE在建立過程當中是線程安全的,因此說靜態內部類形式的單例可保證線程安全,也能保證單例的惟一性,同時也延遲了單例的實例化。

那麼,是否是能夠說靜態內部類單例就是最完美的單例模式了呢?其實否則,靜態內部類也有着一個致命的缺點,就是傳參的問題,因爲是靜態內部類的形式去建立單例的,故外部沒法傳遞參數進去,例如Context這種參數,因此,咱們建立單例時,能夠在靜態內部類與DCL模式裏本身斟酌。

4、枚舉單例

從上述3種單例模式的寫法中,彷佛也解決了效率或者懶加載以及線程安全的問題,可是它們都有兩個共同的缺點:

  • 序列化可能會破壞單例模式,比較每次反序列化一個序列化的對象實例時都會建立一個新的實例,解決方案以下:

  • 使用反射強行調用私有構造器,解決方式能夠修改構造器,讓它在建立第二個實例的時候拋異常,解決方案以下:

如上所述,問題確實也獲得瞭解決,但問題是咱們爲此付出了很多努力,即添加了很多代碼,還應該注意到若是單例類維持了其餘對象的狀態時還須要使他們成爲transient的對象,這種就更復雜了,那有沒有更簡單更高效的呢?固然是有的,那就是枚舉單例了,先來看看如何實現:

代碼至關簡潔,咱們也能夠像常規類同樣編寫enum類,爲其添加變量和方法,訪問方式也更簡單,使用EnumSingle.INSTANCE進行訪問,這樣也就避免調用getInstance方法,更重要的是使用枚舉單例的寫法,咱們徹底不用考慮序列化和反射的問題。枚舉序列化是由jvm保證的,每個枚舉類型和定義的枚舉變量在JVM中都是惟一的。

在枚舉類型的序列化和反序列化上,Java作了特殊的規定:在序列化時Java僅僅是將枚舉對象的name屬性輸出到結果中,反序列化的時候則是經過java.lang.Enum的valueOf方法來根據名字查找枚舉對象。同時,編譯器是不容許任何對這種序列化機制的定製的並禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法,從而保證了枚舉實例的惟一性,這裏咱們不妨再次看看Enum類的valueOf方法:

public static <T extends Enum<T>> T valueOf(Class<T> enumType,
                                              String name) {
      T result = enumType.enumConstantDirectory().get(name);
      if (result != null)
          return result;
      if (name == null)
          throw new NullPointerException("Name is null");
      throw new IllegalArgumentException(
          "No enum constant " + enumType.getCanonicalName() + "." + name);
  }

實際上經過調用enumType(Class對象的引用)的enumConstantDirectory方法獲取到的是一個Map集合,在該集合中存放了以枚舉name爲key和以枚舉實例變量爲value的Key&Value數據,所以經過name的值就能夠獲取到枚舉實例,看看enumConstantDirectory方法源碼:

Map<String, T> enumConstantDirectory() {
        if (enumConstantDirectory == null) {
            //getEnumConstantsShared最終經過反射調用枚舉類的values方法
            T[] universe = getEnumConstantsShared();
            if (universe == null)
                throw new IllegalArgumentException(
                    getName() + " is not an enum type");
            Map<String, T> m = new HashMap<>(2 * universe.length);
            //map存放了當前enum類的全部枚舉實例變量,以name爲key值
            for (T constant : universe)
                m.put(((Enum<?>)constant).name(), constant);
            enumConstantDirectory = m;
        }
        return enumConstantDirectory;
    }
    private volatile transient Map<String, T> enumConstantDirectory = null;

到這裏咱們也就能夠看出枚舉序列化確實不會從新建立新實例,jvm保證了每一個枚舉實例變量的惟一性。再來看看反射到底能不能建立枚舉,下面試圖經過反射獲取構造器並建立枚舉

public static void main(String[] args) throws IllegalAccessException, InvocationTargetException, InstantiationException, NoSuchMethodException {
   //獲取枚舉類的構造函數(前面的源碼已分析過)
   Constructor<EnumSingle> constructor=EnumSingle.class.getDeclaredConstructor(String.class,int.class);
   constructor.setAccessible(true);
   //建立枚舉
   EnumSingle singleton=constructor.newInstance("otherInstance",9);
  }

執行報錯

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
    at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
    at zejian.SingletonEnum.main(SingletonEnum.java:38)
    at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method)
    at sun.reflect.NativeMethodAccessorImpl.invoke(NativeMethodAccessorImpl.java:62)
    at sun.reflect.DelegatingMethodAccessorImpl.invoke(DelegatingMethodAccessorImpl.java:43)
    at java.lang.reflect.Method.invoke(Method.java:498)
    at com.intellij.rt.execution.application.AppMain.main(AppMain.java:144)

顯然告訴咱們不能使用反射建立枚舉類,這是爲何呢?不妨看看newInstance方法源碼:

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);
            }
        }
        //這裏判斷Modifier.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;
    }

源碼很瞭然,確實沒法使用反射建立枚舉實例,也就是說明了建立枚舉實例只有編譯器可以作到而已。顯然枚舉單例模式確實是很不錯的選擇,所以咱們推薦使用它。可是這總不是萬能的,對於android平臺這個可能未必是最好的選擇,在android開發中,內存優化是個大塊頭,而使用枚舉時佔用的內存經常是靜態變量的兩倍還多,所以android官方在內存優化方面給出的建議是儘可能避免在android中使用enum。可是無論如何,關於單例,咱們老是應該記住:線程安全,延遲加載,序列化與反序列化安全,反射安全是很重重要的。

至此,單例模式的介紹完畢,不足之處你們補充指點。

參考:

https://blog.csdn.net/chenchaofuck1/article/details/51702129

https://blog.csdn.net/mnb65482/article/details/80458571

https://blog.csdn.net/javazejian/article/details/71333103#%E6%9E%9A%E4%B8%BE%E4%B8%8E%E5%8D%95%E4%BE%8B%E6%A8%A1%E5%BC%8F

《深刻理解Java虛擬機 JVM高級特性與最佳實踐》

相關文章
相關標籤/搜索