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

前言

單例模式應該算是 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 分支。這裡,是對枚舉類型進行的處理。

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

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