單例模式看着一篇就夠了,破解謠言版!!!

大家好,今天給大家介紹一下單例模式。本文是從實際應用開發,結合網絡上多篇技術博客,總結其精華、完善其缺陷和優化案例說明角度向大家展示什麼叫做單例模式,如何創建單例及其優缺點和什麼時候用單例。原創不易,點贊關注支持一下!

什麼叫單例模式? 

 

 單例模式是設計模式中最簡單的形式之一。這一模式的目的是使得類的一個對象成爲系統中的唯一實例。

看起來很晦澀,白話一點說就是要具備某各類只能有一個實例、它必須自行創建這個實例和必須自行向整個系統提供這個實例。

 如何創建實例?

 單例的創建大致分爲懶漢模式、餓漢模式、靜態內部類、雙重加鎖等等。我們着重介紹和推薦使用的是雙檢鎖模式,其他模式請自行百度 :-),話不多說直接上代碼。

public class SingletonEntity  {

    private volatile static SingletonEntity singtonEntity = null;

    private SingletonEntity(){

    }

    public void test(String context){
        System.out.println(context);
    }

    public static SingletonEntity getInstance(){
        if (singtonEntity == null){
            synchronized (SingletonEntity.class){
                if (singtonEntity == null){
                    singtonEntity =  new SingletonEntity();
                }
            }
        }
        return singtonEntity;
    }

}

注意:代碼中一定要修改空參構造器爲私有權限,防止代用構造器破壞單例原則。

如何使用?

 

 /**
         * 正常調用
         */
        SingletonEntity instance1 = SingletonEntity.getInstance();
        SingletonEntity instance2 = SingletonEntity.getInstance();

        instance1.test("正常調用單例方法的測試類");

        //測試單利是否同一個對象
        System.out.println("正常調用instance1=" + instance1.hashCode() + ",instance2=" + instance2.hashCode());

運行得到結果:

正常調用單例方法的測試類
正常調用instance1=1625635731,instance2=1625635731

 發現兩次創建獲得的對象地址是同一個,這個是在單線程環境下運行那麼如果在多線程情況下會如何呢?

          /**
         * 多線程獲取實例
         */
        for (int i = 0; i < 50; i++) {
            new Thread(()->{
                SingletonEntity entity = SingletonEntity.getInstance();
                System.out.println(Thread.currentThread().getName() + "獲取到的對象地址" + entity.hashCode());
            },"線程" + String.valueOf(i)).start();
        }

運行結果:

線程0獲取到的對象地址1625635731
線程1獲取到的對象地址1625635731
線程2獲取到的對象地址1625635731
線程6獲取到的對象地址1625635731
線程4獲取到的對象地址1625635731
線程5獲取到的對象地址1625635731
線程3獲取到的對象地址1625635731
線程7獲取到的對象地址1625635731
線程8獲取到的對象地址1625635731
線程9獲取到的對象地址1625635731
線程11獲取到的對象地址1625635731
線程15獲取到的對象地址1625635731
線程10獲取到的對象地址1625635731
...

多線程情況下獲取到的對象地址也是一個。但是我們在JAVA中獲取一個對象除new以外還有序列化/反序列化、反射等手段。那麼序列化和反射是否能破壞單例呢?我們先用序列化方式運行看下:

         /**
         * 序列化方式調用
         */
        //序列化
        SingletonEntity singletonSerializable = SingletonEntity.getInstance();
        FileOutputStream fileOutputStream = new FileOutputStream("temp");
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(fileOutputStream);
        objectOutputStream.writeObject(singletonSerializable);
        objectOutputStream.close();
        fileOutputStream.close();
        //反序列化
        FileInputStream fileInputStream = new FileInputStream("temp");
        ObjectInputStream objectInputStream = new ObjectInputStream(fileInputStream);
        SingletonEntity readObject = (SingletonEntity)objectInputStream.readObject();
        objectInputStream.close();
        fileInputStream.close();
        readObject.test("序列化調用的測試方法");
        //單例類加了readResolve防止序列化之後地址不一致
        System.out.println("序列化之前= " + singletonSerializable.hashCode() + ",序列化調用readObject=" + readObject.hashCode());

注意:此時直接調用會異常,需要類SingletonEntity 實現 Serializable接口

運行結果:

序列化調用的測試方法
序列化之前= 1625635731,序列化調用readObject=793589513

發現單例獲得的對象被序列化之後對象的地址和遠來的不一樣了,這就違反了文章開篇提到的唯一實例原則。別急我們優化一下代碼在SingletonEntity類中增加一段代碼:

    /**
     * 防止序列化後的對象不一致
     * @return
     * @throws ObjectStreamException
     */
    private Object readResolve() throws ObjectStreamException {
        return singtonEntity;
    }

這時候我們運行一下得到結果:

序列化調用的測試方法
序列化之前= 1625635731,序列化調用readObject=1625635731

在這解釋一下readResolve方法,readResolve方法可以理解爲一種約定。在目標類中定義一個私有的readResolve方法,然後再反序列化的時候會被調用到。readResolve在進行反序列化的時候執行循序在readObject之後,會覆蓋readObject方法修改。通過此方式序列化/反序列化都不會破壞單例的唯一原則。 說完了序列化在說說反射是否能破壞呢?上代碼:

        /**
         * 反射調用
         */
        Constructor<SingletonEntity> singtonEntityClass = SingletonEntity.class.getDeclaredConstructor();//獲取全部構造 包括私有
        singtonEntityClass.setAccessible(true);//忽略修飾符檢查
        SingletonEntity singletonEntity = singtonEntityClass.newInstance();
        SingletonEntity singletonEntity2 = singtonEntityClass.newInstance();
        singletonEntity.test("反射調用單例方法的測試類");
        System.out.println("反射調用singtonEntity=" + singletonEntity.hashCode() + ",singtonEntity2=" + singletonEntity2.hashCode());

運行結果:

反射調用單例方法的測試類
反射調用singtonEntity=1329552164,singtonEntity2=363771819

結果顯示反射也是可以破壞單例的,想要解決也是可以的我們在類SingletonEntity中的私有構造器裏面增加一小段代碼。

private static volatile boolean flag = true;
 
private SingletonEntity(){
       synchronized (SingletonEntity.class){
            if(flag){
                flag = false;
            }else{
                throw new RuntimeException("The instance  already exists !");
            }
        }
    }

調用反射破壞單例其實就是通過反射的方式拿到私有的構造器,我們對私有構造器進行增加判斷在創建第二個對象的時候進行異常拋出。以上方法都是通過添加代碼方式進行主觀避免,那麼是否有一中JAVA提供好的API給我們使用呢?答案是有的,我們引入了枚舉方式實現單例。

枚舉實現單例

寫這篇文章之前查看了衆多介紹用枚舉實現單例的方式,總結一下代碼如下(以下代碼爲錯誤枚舉實現單例Demo):

public class UserSingletonEntity implements Serializable {

    private UserSingletonEntity(){
    }

    static enum SingletonEnum {
        //創建一個實例對象
        INSTANCE;

        private UserSingletonEntity userSingletonEntity;

        private SingletonEnum(){
            userSingletonEntity = new UserSingletonEntity();
        }

        public UserSingletonEntity getInstance(){
            return userSingletonEntity;
        }
    }

    public static UserSingletonEntity getInstance(){
        return SingletonEnum.INSTANCE.getInstance();
    }
}

 調用方式和普通類一樣,不加贅述。但是這種方式並沒有解決序列化和反射破壞單例原則問題。我貼出來一個驗證序列化破壞單例原則代碼,反射破壞參照上述反射方法。

        /**
         * 枚舉類單例 網上大多數寫法
         */
        UserSingletonEntity entity = UserSingletonEntity.getInstance();
        UserSingletonEntity entity1 = UserSingletonEntity.getInstance();
        System.out.println(entity == entity1);
        UserSingletonEntity userSingletonEntity = UserSingletonEntity.getInstance();
        FileOutputStream fileOutputStream1 = new FileOutputStream("temp1");
        ObjectOutputStream objectOutputStream1 = new ObjectOutputStream(fileOutputStream1);
        objectOutputStream1.writeObject(userSingletonEntity);
        objectOutputStream1.close();
        fileOutputStream1.close();
        
        FileInputStream fileInputStream1 = new FileInputStream("temp1");
        ObjectInputStream objectInputStream1 = new ObjectInputStream(fileInputStream1);
        UserSingletonEntity readObject1 =                 
                                   (UserSingletonEntity)objectInputStream1.readObject();
        objectInputStream1.close();
        fileInputStream1.close();
        System.out.println("枚舉類單例序列化之前= " + userSingletonEntity.hashCode() + ",序列化調用readObject=" + readObject1.hashCode());

運行結果:

true
枚舉類單例序列化之前= 793589513,序列化調用readObject=1313922862

 正確枚舉實現單例寫法:

public enum  User {

    INSTANCE;

    private User(){};


    public void test(String text){
        System.out.println(text);
    }
}

調用方式:

         /**
         * 枚舉類正確寫法調用
         */
        for (int i = 0; i < 10; i++) {
            final String a = i+"";
            new  Thread(()->{
                User.INSTANCE.test(a);
            },"線程" + i).start();
        }

這種方式實現單例是可以避免序列化和反射破壞單例的,有代碼爲證:

        //單例不支持反射,以下寫法報錯
        Constructor<User> declaredConstructor =                     
                                 User.class.getDeclaredConstructor(String.class,int.class);
        declaredConstructor.setAccessible(true);
        User user = declaredConstructor.newInstance();

        user.setName("aaaa");
        System.out.println(user.getName());

運行結果:

爲什麼會報錯呢?我們翻閱newInstance源碼:

在JDK裏規範定義就是不允許的。通過枚舉這種方式可以避免反射和序列化方式破壞單例是值得推薦的,但是這種方式的類缺點一是不能作爲數據庫實體類來使用二是類的屬性值設置時候需要注意屬性狀態,有狀態屬性在多線程下是不安全的。 枚舉類的方式實現單例模式原理是因爲枚舉的調用方式是User.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保證了每個枚舉實例變量的唯一性。 

 總結

  一個類能返回對象一個引用(永遠是同一個)和一個獲得該實例的方法(必須是靜態方法,通常使用getInstance這個名 稱);當我們調用這個方法時,如果類持有的引用不爲空就返回這個引用,如果類保持的引用爲空就創建該類的實例並將實例的引用賦予該類保持的引用;同時我們還將該類的構造函數定義爲私有方法,這樣其他處的代碼就無法通過調用該類的構造函數來實例化該類的對象,只有通過該類提供的靜態方法來得到該類的唯一實例。

雙重檢索實現單例方法優點是類的使用比較靈活可以支持高併發的場景,缺點是需要手動添加方法防止序列化和反射破壞單例原則;

枚舉方式實現單例好處是利用JDK自帶特性避免了反射和序列化破壞單例,壞處是枚舉類使用場景有侷限性。

優點: 

    1.在單例模式中,活動的單例只有一個實例,對單例類的所有實例化得到的都是相同的一個實例。這樣就 防止其它對象對自己的實例化,確保所有的對象都訪問一個實例 

    2.單例模式具有一定的伸縮性,類自己來控制實例化進程,類就在改變實例化進程上有相應的伸縮性。 

    3.提供了對唯一實例的受控訪問。 

    4.由於在系統內存中只存在一個對象,因此可以 節約系統資源,當 需要頻繁創建和銷燬的對象時單例模式無疑可以提高系統的性能。 

    5.允許可變數目的實例。 

    6.避免對共享資源的多重佔用。 

缺點: 

    1.不適用於變化的對象,如果同一類型的對象總是要在不同的用例場景發生變化,單例就會引起數據的錯誤,不能保存彼此的狀態。 

    2.由於單利模式中沒有抽象層,因此單例類的擴展有很大的困難。 

    3.單例類的職責過重,在一定程度上違背了“單一職責原則”。 

    4.濫用單例將帶來一些負面問題,如爲了節省資源將數據庫連接池對象設計爲的單例類,可能會導致共享連接池對象的程序過多而出現連接池溢出;如果實例化的對象長時間不被利用,系統會認爲是垃圾而被回收,這將導致對象狀態的丟失。 

 

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