Java 中的單例設計模式,很多時候我們只會注意到執行緒引起的表象性問題,但是沒考慮過對反射機制的限制,此文旨在簡單介紹利用列舉來防止反射的漏洞。
一、最常見的單例
我們先展示一段最常見的懶漢式的單例:
public class Singleton {
private Singleton(){} // 私有構造
private static Singleton instance = null; // 私有單例物件
// 靜態工廠
public static Singleton getInstance(){
if (instance == null) { // 雙重檢測機制
synchronized (Singleton.class) { // 同步鎖
if (instance == null) { // 雙重檢測機制
instance = new Singleton();
}
}
}
return instance;
}
}
複製程式碼
上述單例的寫法採用的雙重檢查機制增加了一定的安全性,但是沒有考慮到 JVM 編譯器的指令重排。
二、杜絕 JVM 的指令重排對單例造成的影響
1、什麼是指令重排
比如 java 中簡單的一句 instance = new Singleton,會被編譯器編譯成如下 JVM 指令:
memory =allocate(); //1:分配物件的記憶體空間
ctorInstance(memory); //2:初始化物件
instance =memory; //3:設定instance指向剛分配的記憶體地址
複製程式碼
但是這些指令順序並非一成不變,有可能會經過 JVM 和 CPU 的優化,指令重排成下面的順序:
memory =allocate(); //1:分配物件的記憶體空間
instance =memory; //3:設定instance指向剛分配的記憶體地址
ctorInstance(memory); //2:初始化物件
複製程式碼
2、影響
對應到上文的單例模式,會產生如下圖的問題:
-
當執行緒 A 執行完1,3,時,準備走2,即 instance 物件還未完成初始化,但已經不再指向 null 。
-
此時如果執行緒 B 搶佔到CPU資源,執行 if(instance == null)的結果會是 false,
-
從而返回一個沒有初始化完成的instance物件。
3、解決
如何去防止呢,很簡單,可以利用關鍵字 volatile 來修飾 instance 物件,如下圖進行優化:
why?
很簡單,volatile 修飾符在此處的作用就是阻止變數訪問前後的指令重排,從而保證了指令的執行順序。
意思就是,指令的執行順序是嚴格按照上文的 1、2、3 來執行的,從而物件不會出現中間態。
其實,volatile 關鍵字在多執行緒的開發中應用很廣,暫不贅述。
雖然很贊,但是此處仍然沒有考慮過反射機制帶來的影響。
三、進階篇,實現完美單例
1、小插曲
實現單例有很多種模式,在此介紹一種使用靜態內部類實現單例模式的方式:
public class Singleton {
private static class LazyHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}
複製程式碼
這是一種很巧妙的方式,原由是:
-
從外部無法訪問靜態內部類 LazyHolder,只有當呼叫 Singleton.getInstance() 方法的時候,才能得到單例物件 INSTANCE。
-
INSTANCE 物件初始化的時機並不是在單例類 Singleton 被載入的時候,而是在呼叫 getInstance 方法,使得靜態內部類 LazyHolder 被載入的時候。
-
因此這種實現方式是利用classloader的載入機制來實現懶載入,並保證構建單例的執行緒安全。
2、漏洞展示
很多種單例的寫法都有一個通病,就是無法防止反射機制的漏洞,從而無法保證物件的唯一性,如下舉例:
利用如下的反正程式碼對上文構造的單例進行物件的建立。
public static void main(String[] args) {
try {
//獲得構造器
Constructor con = Singleton.class.getDeclaredConstructor();
//設定為可訪問
con.setAccessible(true);
//構造兩個不同的物件
Singleton singleton1 = (Singleton)con.newInstance();
Singleton singleton2 = (Singleton)con.newInstance();
//驗證是否是不同物件
System.out.println(singleton1);
System.out.println(singleton2);
System.out.println(singleton1.equals(singleton2));
} catch (Exception e) {
e.printStackTrace();
}
}
複製程式碼
我們直接看結果:
結果很明顯,這顯然是兩個物件。
3、解決
使用列舉來實現單例模式。
實現很簡單,就三行程式碼:
public enum Singleton {
INSTANCE;
}
複製程式碼
上面所展示的就是一個單例,
why?
其實這就是 enum 的一塊語法糖,JVM 會阻止反射獲取列舉類的私有構造方法。
仍然使用上文的反射程式碼來進行測試,發現,報錯。嘿嘿,完美解決反射的問題。
4、缺點
使用列舉的方法是起到了單例的作用,但是也有一個弊端,
那就是 無法進行懶載入。