前言
單例模式應該算是 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
分支。這裡,是對列舉型別進行的處理。
感興趣的小夥伴可以去研讀一下,最終的效果就是,我們通過列舉去定義單例,就可以防止序列化破壞單例。
微信搜「煙雨星空」,白嫖更多好文~