前言
單例模式應該算是 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 分支,這里,是對列舉型別進行的處理,
感興趣的小伙伴可以去研讀一下,最終的效果就是,我們通過列舉去定義單例,就可以防止序列化破壞單例,
微信搜「煙雨星空」,白嫖更多好文~
轉載請註明出處,本文鏈接:https://www.uj5u.com/houduan/135386.html
標籤:Java
上一篇:idea配置easycode模板
