面試官看完我手寫的單例直接驚呆了!

煙雨星空發表於2020-09-28

前言

單例模式應該算是 23 種設計模式中,最常見最容易考察的知識點了。經常會有面試官讓手寫單例模式,別到時候傻乎乎的說我不會。

之前,我有介紹過單例模式的幾種常見寫法。還不知道的,傳送門看這裡:

設計模式之單例模式

本篇文章將展開一些不太容易想到的問題。帶著你思考一下,傳統的單例模式有哪些問題,並給出解決方案。讓面試官眼中一亮,心道,小夥子有點東西啊!

以下,以 DCL 單例模式為例。

DCL 單例模式

DCL 就是 Double Check Lock 的縮寫,即雙重檢查的同步鎖。程式碼如下,

public class Singleton {

    //注意,此變數需要用volatile修飾以防止指令重排序
    private static volatile Singleton singleton = null;

    private Singleton(){

    }

    public static Singleton getInstance(){
        //進入方法內,先判斷例項是否為空,以確定是否需要進入同步程式碼塊
        if(singleton == null){
            synchronized (Singleton.class){
                //進入同步程式碼塊時再次判斷例項是否為空
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

乍看,以上的寫法沒有什麼問題,而且我們確實也經常這樣寫。

但是,問題來了。

DCL 單例一定能確保執行緒安全嗎?

有的小夥伴就會說,你這不是廢話麼,大家不都這樣寫麼,肯定是執行緒安全的啊。

確實,在正常情況,我可以保證呼叫 getInstance 方法兩次,拿到的是同一個物件。

但是,我們知道 Java 中有個很強大的功能——反射。對的,沒錯,就是他。

通過反射,我就可以破壞單例模式,從而呼叫它的建構函式,來建立不同的物件。

public class TestDCL {
    public static void main(String[] args) throws Exception {
        Singleton singleton1 = Singleton.getInstance();
        System.out.println(singleton1.hashCode()); // 723074861
        Class<Singleton> clazz = Singleton.class;
        Constructor<Singleton> ctr = clazz.getDeclaredConstructor();
        //通過反射拿到無參構造,設為可訪問
        ctr.setAccessible(true);
        Singleton singleton2 = ctr.newInstance();
        System.out.println(singleton2.hashCode()); // 895328852
    }
}

我們會發現,通過反射就可以直接呼叫無參建構函式建立物件。我管你構造器是不是私有的,反射之下沒有隱私。

列印出的 hashCode 不同,說明了這是兩個不同的物件。

那怎麼防止反射破壞單例呢?

很簡單,既然你想通過無參構造來建立物件,那我就在建構函式裡多判斷一次。如果單例物件已經建立好了,我就直接丟擲異常,不讓你建立就可以了。

修改建構函式如下,

再次執行測試程式碼,就會丟擲異常。

有效的阻止了通過反射去建立物件。

那麼,這樣寫單例就沒問題了嗎?

這時,機靈的小夥伴肯定就會說,既然問了,那就是有問題(可真是個小機靈鬼)。

但是,是有什麼問題呢?

我們知道,物件還可以進行序列化反序列化。那如果我把單例物件序列化,再反序列化之後的物件,還是不是之前的單例物件呢?

實踐出真知,我們測試一下就知道了。

// 給 Singleton 新增序列化的標誌,表明可以序列化
public class Singleton implements Serializable{ 
    ... //省略不重要程式碼
}
//測試是否返回同一個物件
public class TestDCL {
    public static void main(String[] args) throws Exception {
        Singleton singleton1 = Singleton.getInstance();
        System.out.println(singleton1.hashCode()); // 723074861
        //通過序列化物件,再反序列化得到新物件
        String filePath = "D:\\singleton.txt";
        saveToFile(singleton1,filePath);
        Singleton singleton2 = getFromFile(filePath);
        System.out.println(singleton2.hashCode()); // 1259475182
    }

    //將物件寫入到檔案
    private static void saveToFile(Singleton singleton, String fileName){
        try {
            FileOutputStream fos = new FileOutputStream(fileName);
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(singleton); //將物件寫入oos
            oos.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

    //從檔案中讀取物件
    private static Singleton getFromFile(String fileName){
        try {
            FileInputStream fis = new FileInputStream(fileName);
            ObjectInputStream ois = new ObjectInputStream(fis);
            return (Singleton) ois.readObject();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
        return null;
    }
}

可以發現,我把單例物件序列化之後,再反序列化之後得到的物件,和之前已經不是同一個物件了。因此,就破壞了單例。

那怎麼解決這個問題呢?

我先說解決方案,一會兒解釋為什麼這樣做可以。

很簡單,在單例類中新增一個方法 readResolve 就可以了,方法體中讓它返回我們建立的單例物件。

然後再次執行測試類會發現,列印出來的 hashCode 碼一樣。

是不是很神奇。。。

readResolve 為什麼可以解決序列化破壞單例的問題?

我們通過檢視原始碼中一些關鍵的步驟,就可以解決心中的疑惑。

我們思考一下,序列化和反序列化的過程中,哪個流程最有可能有操作空間。

首先,序列化時,就是把物件轉為二進位制存在 ``ObjectOutputStream` 流中。這裡,貌似好像沒有什麼特殊的地方。

其次,那就只能看反序列化了。反序列化時,需要從 ObjectInputStream 物件中讀取物件,正常讀出來的物件是一個新的不同的物件,為什麼這次就能讀出一個相同的物件呢,我猜這裡會不會有什麼貓膩?

應該是有可能的。所以,來到我們寫的方法 getFromFile中,找到這一行ois.readObject()。它就是從流中讀取物件的方法。

點進去,檢視 ObjectInputStream.readObject 方法,然後找到 readObject0()方法

再點進去,我們發現有一個 switch 判斷,找到 TC_OBJECT 分支。它是用來處理物件型別。

然後看到有一個 readOrdinaryObject方法,點進去。

然後找到這一行,isInstantiable() 方法,用來判斷物件是否可例項化。

由於 cons 建構函式不為空,所以這個方法返回 true。因此構造出來一個 非空的 obj 物件 。

再往下走,呼叫,hasReadResolveMethod 方法去判斷變數 readResolveMethod是否為非空。

我們去看一下這個變數,在哪裡有沒有賦值。會發現有這樣一段程式碼,

點進去這個方法 getInheritableMethod。發現它最後就是為了返回我們新增的readResolve 方法。

同時我們發現,這個方法的修飾符可以是 public , protected 或者 private(我們當前用的就是private)。但是,不允許使用 static 和 abstract 修飾。

再次回到 readOrdinaryObject方法,繼續往下走,會發現呼叫了 invokeReadResolve 方法。此方法,是通過反射呼叫 readResolve方法,得到了 rep 物件。

然後,判斷 rep 是否和 obj 相等 。 obj 是剛才我們通過建構函式建立出來的新物件,而由於我們重寫了 readResolve 方法,直接返回了單例物件,因此 rep 就是原來的單例物件,和 obj 不相等。

於是,把 rep 賦值給 obj ,然後返回 obj。

所以,最終得到這個 obj 物件,就是我們原來的單例物件。

至此,我們就明白了是怎麼一回事。

一句話總結就是:當從物件流 ObjectInputStream 中讀取物件時,會檢查物件的類否定義了 readResolve 方法。如果定義了,則呼叫它返回我們想指定的物件(這裡就指定了返回單例物件)。

總結

因此,完整的 DCL 就可以這樣寫,

public class Singleton implements Serializable {

    //注意,此變數需要用volatile修飾以防止指令重排序
    private static volatile Singleton singleton = null;

    private Singleton(){
        if(singleton != null){
            throw new RuntimeException("Can not do this");
        }
    }

    public static Singleton getInstance(){
        //進入方法內,先判斷例項是否為空,以確定是否需要進入同步程式碼塊
        if(singleton == null){
            synchronized (Singleton.class){
                //進入同步程式碼塊時再次判斷例項是否為空
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }

    // 定義readResolve方法,防止反序列化返回不同的物件
    private Object readResolve(){
        return singleton;
    }
}

另外,不知道細心的讀者有沒有發現,在看原始碼中 switch 分支有一個 case TC_ENUM 分支。這裡,是對列舉型別進行的處理。

感興趣的小夥伴可以去研讀一下,最終的效果就是,我們通過列舉去定義單例,就可以防止序列化破壞單例。

微信搜「煙雨星空」,白嫖更多好文~

相關文章