天天看點

單例模式看着一篇就夠了,破解謠言版!!!什麼叫單例模式?  如何建立執行個體?如何使用?枚舉實作單例 總結

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

什麼叫單例模式? 

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

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

 如何建立執行個體?

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

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.濫用單例将帶來一些負面問題,如為了節省資源将資料庫連接配接池對象設計為的單例類,可能會導緻共享連接配接池對象的程式過多而出現連接配接池溢出;如果執行個體化的對象長時間不被利用,系統會認為是垃圾而被回收,這将導緻對象狀态的丢失。