1. 程式人生 > 其它 >重新講講單例模式和幾種實現

重新講講單例模式和幾種實現

目錄

    一、什麼講單例模式

    單例模式,最簡單的理解是物件例項只有孤單的一份,不會重複建立例項。

    這個模式已經很經典了,經典得我不再贅述理論,只給簡單註釋,畢竟教科書詳盡太多。

    解決 sonar RSPEC-2168 異味的時候,發現目前業界推薦的單例模式和教科書上的已經有了較大差異,雙重鎖定不再推薦,甚至業內認為的最優方案不在sonar的推薦裡

    於是提筆記錄,順帶補充了自己對多執行緒單例的理解 。

    二、經典的單執行緒單例

    這個部分沒有改動,簡單而經典,大致原始碼如下

    public final class SignUtil {
    
        /**
         * 需要保持單例的物件
         */
        private static Object object;
    
        /**
         * 只允許SignUtil.getInstance獲取物件,也就是入口唯一
         */
        private SignUtil() {
        }
    
        /**
         * 物件的唯一出口 呼叫時才初始化(懶載入)
         * @return Object 確保單執行緒情況下這裡出去就是初始化好的
         */
        public static Object getInstance() {
            if (null == object) {
                object = new Object();
            }
            return object;
        }
    
        /**
         * 內部函式也必須使用 getInstance這個入口
         */
        public static String getString() {
            return getInstance().toString();
        }
    }
    

    三、經典的雙重鎖定多執行緒單例 (JDK5-JDK7繼續適用)

    public final class SignUtil {
    
        /**
         * 需要保持單例的物件
         * 這裡需要宣告物件是易失的,因為object = new Object()不是一個原子操作,是被分拆為了例項化和初始化,一個申請空間,一個分配值
         * 那麼就有可能出現 C在第三瞬間進入getInstance函式,發現null!=object,此時物件例項化了但沒初始化就直接返回,是個高危操作
         */
        private volatile static Object object;
    
        /**
         * 只允許SignUtil.getInstance獲取物件,也就是入口唯一
         */
        private SignUtil() {
        }
    
        /**
         * 物件的唯一出口
         *
         * @return Object 多執行緒情況下這裡出去就是初始化好的
         */
        public static Object getInstance() {
            // 第0瞬間 A B 兩個執行緒同時初始化,一看都是null嘛
            if (null == object) {
                // 第1瞬間 A B都進來了,因為不能重複初始化,所以被synchronized鎖約束開始競爭.
                // A 贏了SignUtil的物件鎖,B 只能等著
                synchronized (SignUtil.class) {
                    // 這裡為什麼不直接object = new Object()呢?
                    // 因為B還等著呢,直接初始化就攔不住B再來一次初始化了.
                    if (null == object) {
                        // 第2瞬間, A終於初始化成功,且B不會重新初始化了.
                        object = new Object();
                        // 第3瞬間,因為object被volatile約束了,可以視為原子操作,補上最後一個漏洞,成功返回。
                    }
                }
            }
            return object;
        }
    
        /**
         * 內部函式也必須使用 getInstance這個入口
         */
        public static String getString() {
            return getInstance().toString();
        }
    }
    

    四、 JDK8 以後的多執行緒單例

    可以看到,三的要點太多了,很經典的雙重鎖定,但是不夠簡單優雅。目前更推薦下面兩種格式

    4.1 JDK8 帶來的一個特性之一即是synchronized關鍵字,從原來的monitor重量級鎖,轉變成了由偏向鎖進行逐級升級到重量級鎖。換句話說,使用synchronized的代價被降低了,我們可以將上面的函式進行一個改進,讓它保持簡單和優雅。

    但是代價依舊存在,以下適合併發衝突不嚴重的專案。

    public final class SignUtil {
    
        /**
         * 需要保持單例的物件
         */
        private static Object object;
    
        /**
         * 只允許SignUtil.getInstance獲取物件,也就是入口唯一
         */
        private SignUtil() {
        }
    
        /**
         * 物件的唯一出口 是的,僅比單執行緒版多了一個synchronized
         * @return Object 由於synchronized,同一瞬間只能有一個物件進行獲取例項
         */
        public static synchronized Object getInstance() {
            if (null == object) {
                object = new Object();
            }
            return object;
        }
    
        /**
         * 內部函式也必須使用 getInstance這個入口
         */
        public static String getString() {
            return getInstance().toString();
        }
    }
    

    4.2 利用靜態內部類的初始化特性

    很巧妙地利用了jvm的類載入機制。那就是靜態內部類的延遲載入性完成單例。

    public final class SignUtil {
    
        /**
         * 利用jvm的初始化規則 靜態內部類的靜態內部物件,只有在呼叫時才對靜態類開始初始化,
         * 類的初始化過程是執行緒安全的,所以也只有一個執行緒能進行初始化
         */
        private static class Node {
            /**
             * 在讀寫呼叫時才真正初始化,也就是懶載入
             */
            private static final Object object = new Object();
        }
    
        /**
         * 只允許SignUtil.getInstance獲取物件,也就是入口唯一
         */
        private SignUtil() {
        }
    
        /**
         * 不再是物件的唯一出口,其他地方也只要讀寫都能完成初始化
         *
         * @return Object 呼叫時,會觸發內部靜態類的初始化,返回時,初始化已完成
         */
        public static Object getInstance() {
            return Node.object;
        }
    
        /**
         * 內部函式終於不用再依賴 getInstance這個入口
         */
        public static String getString() {
            return Node.object.toString();
        }
    }
    

    五、 有沒有辦法讓單例模式不單例?

    聽起來很魔鬼,但實際上,上述的多執行緒程單例都有兩個共同的缺陷可以做到:a 反射Constructor::setAccessible將私有建構函式改為公有函式 b.序列化時還是會返回多個例項。

    解決方法為改造建構函式和申明readResolve函式,參考如下,解決方案是通用的。

    public final class SignUtil {
    
        private static volatile boolean init = false;
    
        private static class Node {
            private static final Object object = new Object();
        }
    
        /**
         * 新增一個volatile的變數去判斷,防止反射初始化
         * 第二次初始化會丟擲類強制轉換異常 當然你也可以用其他執行時異常
         */
        private SignUtil() {
            if (!init) {
                init = true;
            } else {
                throw new ClassCastException();
            }
        }
    
        public static Object getInstance() {
            return Node.object;
        }
    
        public static String getString() {
            return Node.object.toString();
        }
    
        /**
         * 反序列化時直接返回單例的物件,這麼寫的原因在 ObjectInputStream::readUnshared裡
         */
        private Object readResolve() {
            return Node.object;
        }
    }
    

    六、列舉單例
    6.1 單元素列舉單例

    和4.2一樣,《Effective Java 》找到了另一種利用jvm類載入機制實現單例的方法:單元素列舉單例。
    這裡有幾個前提:

    • Enum禁用了預設序列化。Enum::readObject、Enum::readObjectNoData約束了列舉物件的預設反序列化,保證序列化安全
    • Enum提供了自己的序列化。Enum::toString 返回的是屬性名稱name,再通過Enum::valueOf把name轉回例項,保證了列舉不會被“退貨”(這個直譯了,大概是final且不會被clone的意思)。
    • 這裡說一下valueOf的底層是Class::enumConstantDirectory,作用是呼叫時,生產一個Map<name, 列舉>的對映,而這個map很像單執行緒單例模式,但他不是靜態共享變數,所以是執行緒安全的,

    不得不說,單元素列舉的確成功避免了重重的繁瑣,但代價是沒有了懶載入的特性,變成了餓漢模式

    public enum SignUtil {
        /**
         * 從javap的反編譯結果看,會變成一個類公開的靜態變數,也就是餓漢模式
         * public static final SignUtil INSTANCE = new SignUtil();
         * 也就是會在載入類時直接初始化INSTANCE物件,而object物件是在構造時作為內部變數初始化,而建構函式是由jvm保證的
         */
        INSTANCE;
    
        /**
         * 由於INSTANCE單例,所以object才是單例的
         */
        private final Object object = new Object();
    
        public Object getInstance() {
            return object;
        }
    
        public String getString() {
            return object.toString();
        }
    
    }
    

    補一下javap反編譯後的結果

    public final class SignUtil extends java.lang.Enum<SignUtil> {
      public static final SignUtil INSTANCE;
      private final java.lang.Object object;
      private static final SignUtil[] $VALUES;
      public static SignUtil[] values();
      public static SignUtil valueOf(java.lang.String);
      private SignUtil(java.lang.Object);
      public java.lang.Object getInstance();
      public java.lang.String getString();
      static {};
    }
    

    6.2 多元素列舉的單例呢?
    由於多元素列舉的建構函式可以被反射修改成公用函式並設定object,但由於INSTANCE和object都是final約束的,所以修改就會報錯,以此保證了單例性。
    所以按照理解 多元素列舉也能完成單例,只是適用場景偏少

    public enum SignUtil {
    	/*
    	 * 對的,唯一的區別就是由無參變成了有參構造,本質是不變的餓漢
    	 * public static final SignUtil INSTANCE = new SignUtil(new Object());
    	 */
        INSTANCE(new Object()),
        OTHER(new Object());
    
        private final Object object;
    
        private SignUtil(Object object) {
            this.object = object;
        }
    
        public Object getInstance() {
            return this.object;
        }
    
        public String getString() {
            return this.object.toString();
        }
    }