資安

單例模式中的那些坑

前言

什麼是單例模式

單例模式(Singleton Pattern)是 Java 中最簡單的設計模式之一。
這種類型的設計模式屬於創建型模式,它提供了一種創建對象的最佳方式。

這種模式涉及到一個單一的類,該類負責創建自己的對象,同時確保只有單個對象被創建。
這個類提供了一種訪問其唯一的對象的方式,可以直接訪問,不需要實例化該類的對象。

注意:

1、單例類只能有一個實例。
2、單例類必須自己創建自己的唯一實例。
3、單例類必須給所有其他對象提供這一實例。
# 摘自菜鳥教程:https://www.runoob.com/design-pattern/singleton-pattern.html

單例模式又分 餓漢模式,懶漢模式
本片文章主要講解懶漢模式

懶漢模式

  • 首先來看一下它的定義
 懶漢模式:延遲加載,只有在真正使用的時候,才開始實例化.

實現方式

1.雙檢鎖

    private static volatile LazySingleton instance;

    private LazySingleton(){

        if(instance != null){
            throw new RuntimeException("不允許通過反射獲取");
        }
    }


    public static  LazySingleton getInstance() {
        //第一次檢查
        if (instance == null) {
            //獲取鎖
            //第一次訪問,多個線程同時擠進來,只有一個線程可以獲取鎖
            synchronized (LazySingleton.class){
                //第一個線程進入 此處為空,進入if並創建對象並返回,之後獲得鎖的線程此處判斷不為空直接返回
                if(instance == null) {
                    //執行構造方法
                    instance = new LazySingleton();
                }
            }
        }
        return instance;
    }

問題1:此處為什麼使用volatile.
private static volatile LazySingleton instance;
創建對象是非原子性操作,有三個過程 分配空間,初始化對象賦值,其中2,3步其中可能會發生指令重排現象.

  • 代碼示例

    //假設我們有如下語句
    Holder holder = new Holder();
    //則實際執行的操作如下
    tmpRef=allocate(Holder.class)//1.分配空間
    invokeConstructor(tmpRef)//2. 執行構造函數
    holder = tmpRef //3.賦值
  • 只有在初始化對象的那一步才會真正執行構造方法
  • 編譯器(JIT),CPU有可能對指令進行重排序,導致使用到尚未初始化的實例,可以通過添加volatile關鍵字進行修飾,對於volatile修飾的字段,可以防止指令重排.
    問題2:構造方法的反射判斷

對於構造方法聲明為private,可以防止直接new對象,但是阻止不了反射來獲取對象,從而破壞單例.

private LazySingleton(){
        if(instance != null){
            throw new RuntimeException("不允許通過反射獲取");
        }
}

以上代碼並不能完美的阻止反射,如果從一開始就直接使用反射而不直接去調用提供的創建方法 就會被破解

  • 代碼示例
Class<LazySingleton> lazySingletonClass = LazySingleton.class;
        Constructor<LazySingleton> constructor = lazySingletonClass.getDeclaredConstructor();
        // 暴力反射
        constructor.setAccessible(true);
        // 從一開始就不使用給定的方法來創建單例
        //LazySingleton instance = LazySingleton.getInstance();
        LazySingleton lazySingleton = constructor.newInstance();
        LazySingleton lazySingleton1 = constructor.newInstance();
        System.out.println(lazySingleton);
        System.out.println(lazySingleton1);

執行結果

com.leetao.singleton.LazySingleton@511d50c0
com.leetao.singleton.LazySingleton@60e53b93

完全是兩個對象...

  • 難道就真的任反射隨意宰割了? 彆著急,下面會通過靜態內部類的方式來介紹如何解決的
  • 在這之前,還是先得來了解一下序列化破壞反射
序列化破壞
  • 代碼實例
        //序列化的對象已實現Serializable接口
    
        //內存輸出流,此處也可以使用文件輸出流(持久化)來代替
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        //內存輸入流
    
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
        //獲取單例
        LazySingleton instance = LazySingleton.getInstance();
        //存入對象輸出中
        objectOutputStream.writeObject(instance);
        objectOutputStream.flush();
        objectOutputStream.close();
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
        //對象輸入流
        ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
        LazySingleton lazySingleton = ((LazySingleton) objectInputStream.readObject());
        //方法創建的單例
        System.out.println(instance);
        //序列化之後的單例
        System.out.println(lazySingleton);

運行結果

com.leetao.singleton.LazySingleton@6f94fa3e
com.leetao.singleton.LazySingleton@4e50df2e

也不是同一個...

  • 關於序列化對象之後為什麼不是同一個的問題
  • 因為使用了默認的序列化機制,他會直接從字節流中拿數據,並不會去調構造函數來進行初始化
附1:JAVA序列化過程
1.將對象實例相關的類元數據輸出。
2.遞歸地輸出類的超類描述直到不再有超類。
3.類元數據完了以後,開始從最頂層的超類開始輸出對象實例的實際數據值。
4.從上至下遞歸輸出實例的數據

腳註: 默認的序列化機制會將對象所有實現Serializable接口的-內容-全部序列化,序列化過程會讀取內容的字節流數據,會通過此產生新的對象,並不是通過構造函數來製造新的對象(網上很多文章都說序列化通過構造函數來創建對象,其實並不是!!)
原型模式中的深克隆也就是通過此機制來實現的.
腳註所示內容代表的是:對象繼承的類以及超類..、成員變量中的引用變量

  • 那麼單例中的序列化破壞如何解決
    官方答案

image.png

  • 可以通過寫入readResovler() throws ObjectStreamException 方法來實現自定義的序列化機制
    代碼
     /**
      * 自定義的序列化機制
      */
    private Object readResolve() throws ObjectStreamException{
        //直接返回單例
        return LazySingleton.getInstance();
    }

再次運行結果

com.leetao.singleton.LazySingleton@6f94fa3e
com.leetao.singleton.LazySingleton@6f94fa3e

序列化破壞的問題完美解決

2. 靜態內部類

1.本質上是利用類加載器機制來保證線程安全
2.只有在實際使用的時候,才會觸發類的初始化,所以也是懶加載的一種形式
3.藉助於jvm類加載機制,保證實例的唯一性.

何為類加載

# 類加載過程
1. 加載: 將字節碼數據從不同的數據源讀取到 JVM 中,並映射為 JVM 認可的數據結構(Class 對象)
2. 連接: a.校驗,b.準備(給類的靜態成員變量賦默認值),c.解析
3. 初始化:給類的靜態變量賦初值

# 注意:只有在真正使用對應的類是,才會觸發初始化 如
    1. 當前類是啟動類(main方法所在的類).
    2. 直接進行new操作.
    3. 訪問靜態字段(final修飾的靜態字面量除外).
    4. 訪問靜態方法.
    5. 用反射訪問類.
    6. 初始化此類的子類.
    $. 後續會出關於類加載相關的文章

前面提到反射破壞的問題,在靜態內部類中可以這樣解決

public class LeeFactory {
    private LeeFactory(){
        //通過靜態類加載機制破解反射破壞
       if(LeeFactoryHolder.LEE_FACTORY!=null){
            throw new UnsupportedOperationException("非法反射不予允許");
        }
    }
    /**
     * 靜態內部類
     */
    private static class LeeFactoryHolder{
        private static final LeeFactory LEE_FACTORY  = new LeeFactory();
    }

    public static LeeFactory getInstance(){
        return LeeFactoryHolder.LEE_FACTORY;
    }

}

以上代碼可以完美解決反射破壞,如果直接通過getInstance()的方式來獲取對象的話.第一次調用才會觸發類初始化和構造方法.之後的調用直接拿數據

而第一次調用反射會觸發兩次兩次構造方法,
1.構造方法中if中的判斷會調用一次(因為訪問類字段會涉及到類初始化,類初始化調用了構造方法)
2.反射創建對象本身會調用一次構造方法,此時靜態內部類字段因為初始化已經存在值了(判斷有值,拋出異常)


還有一個特點
private static final LeeFactory LEE_FACTORY = new LeeFactory();
為什麼加final
1.因為一旦被賦值便無法在修改,即使是反射也不能(也是因為這個原因才使用final)
2.被final修飾的字段會在完全初始化後才會對其他線程可見

說到這裡不得不說為什麼不用volatile

final和volatile 
volatile修飾的字段不但可以防止重排序,還可以直接在主存更新,讓其他線程同步更新
但是它阻止不了反射對其重新賦值,如果使用反射對內部類字段賦值為null,會導致其他正常調用的代碼出現問題.

而final修飾的類,會在完全初始化後才會對其他線程可見,而且不能被反射破壞,正好符合我們的需求

如果對final感興趣的同學,可以閱讀https://zhuanlan.zhihu.com/p/100536345 瞭解更多

結尾:淺談枚舉單例

枚舉本質上是一個不可變類 ,它的成員全部為類字段.它不可以被反射所破壞,同時還擁有自己的序列化機制.可以說是完美的單例.

參考

Java序列化機制https://www.iteye.com/blog/bingobird-867950
final特徵 https://zhuanlan.zhihu.com/p/100536345

文中所述內容,如有錯誤,歡迎指正.


忌妒別人,不會給自己增加任何的好處,忌妒別人,也不可能減少別人的成就。

菅江暉

Leave a Reply

Your email address will not be published. Required fields are marked *