枚舉很適合用來實現單例模式。實際上,在 Effective Java 中也提到過(果然英雄所見略同):
單元素的枚舉類型經常成爲實現 Singleton 的最佳方法 。
首先什麼是單例?就一條基本原則,單例對象的類只會被初始化一次。在 Java 中,我們可以說在 JVM 中只存在該類的唯一一個對象實例。在 Android 中,我們可以說在程序運行期間,該類有且僅有一個對象實例。說到單例模式的實現,你們肯定信手拈來,什麼懶漢,餓漢,DCL,靜態內部類,門清。在說單例之前,考慮下面幾個問題:
你的單例線程安全嗎?
你的單例反射安全嗎?
你的單例序列化安全嗎?
今天,我就來鑽鑽牛角尖,看看你們的單例是否真的 “單例”。
一、單例的一般實現
1、餓漢式
public class HungrySingleton {
private static final HungrySingleton mInstance = new HungrySingleton();
private HungrySingleton() {
}
public static HungrySingleton getInstance() {
return mInstance;
}
}
私有構造器是單例的一般套路,保證不能在外部新建對象。餓漢式在類加載時期就已經初始化實例,由於類加載過程是線程安全的,所以餓漢式默認也是線程安全的。它的缺點也很明顯,我真正需要單例對象的時機是我調用 getInstance() 的時候,而不是類加載時期。如果單例對象是很耗資源的,如數據庫,socket 等等,無疑是不合適的。於是就有了懶漢式。
2、懶漢式
public class LazySingleton {
private static LazySingleton mInstance;
private LazySingleton() {
}
public static synchronized LazySingleton getInstance() {
if (mInstance == null)
mInstance = new LazySingleton();
return mInstance;
}
}
實例化的時機挪到了 getInstance() 方法中,做到了 lazy init ,但也失去了類加載時期初始化的線程安全保障。因此使用了 synchronized 關鍵字來保障線程安全。但這顯然是一個無差別攻擊,管你要不要同步,管你是不是多線程,一律給我加鎖。這也帶來了額外的性能消耗。這點問題肯定難不倒程序員們,於是,雙重檢查鎖定(DCL, Double Check Lock) 應運而生。
3、DCL
public class DCLSingleton {
private static DCLSingleton mInstance;
private DCLSingleton() {
}
public static DCLSingleton getInstance() {
if (mInstance == null) { // 1
synchronized (DCLSingleton.class) { // 2
if (mInstance == null) // 3
mInstance = new DCLSingleton(); // 4
}
}
return mInstance;
}
}
1 處做第一次判斷,如果已經實例化了,直接返回對象,避免無用的同步消耗。2 處僅對實例化過程做同步操作,保證單例。3 處做第二次判斷,只有 mInstance 爲空時再初始化。看起來時多麼的完美,保證線程安全的同時又兼顧性能。但是 DCL 存在一個致命缺陷,就是重排序導致的多線程訪問可能獲得一個未初始化的對象。
首先記住上面標記的 4 行代碼。其中第 4 行代碼 mInstance = new DCLSingleton(); 在 JVM 看來有這麼幾步:
爲對象分配內存空間
初始化對象
將 mInstance 引用指向第 1 步中分配的內存地址
在單線程內,在不影響執行結果的前提下,可能存在指令重排序。例如下列代碼:
int a = 1;
int b = 2;
在 JVM 中你是無法確保這兩行代碼誰先執行的,因爲誰先執行都不影響程序運行結果。同理,創建實例對象的三部中,第 2 步 初始化對象 和 第 3 步 將 mInstance 引用指向對象的內存地址 之間也是可能存在重排序的。
爲對象分配內存空間
將 mInstance 引用指向第 1 步中分配的內存地址
初始化對象
這樣的話,就存在這樣一種可能。線程 A 按上面重排序之後的指令執行,當執行到第 2 行 將 mInstance 引用指向對象的內存地址 時,線程 B 開始執行了,此時線程 A 已爲 mInstance 賦值,線程 B 進行 DCL 的第一次判斷 if (mInstance == null) ,結果爲 false,直接返回 mInstance 指向的對象,但是由於重排序的緣故,對象其實尚未初始化,這樣就出問題了。還挺繞口的,借用 《Java 併發編程藝術》 中的一張表格,會對執行流程更加清晰。
時間 | 線程 A | 線程 B |
---|---|---|
t1 | A1: 分配對象的內存空間 | |
t2 | A3: 設置 mInstance 指向內存空間 | |
t3 | B1: 判斷 mInstance 是否爲空 | |
t4 | B2: 由於 mInstance 不爲空,線程 B 將訪問 mInstance 指向的對象 | |
t5 | A2: 初始化對象 | |
t6 | A3: 訪問 mInstance 引用的對象 |
A3 和 A2 發生重排序導致線程 B 獲取了一個尚未初始化的對象。
說了半天,該怎麼改?其實很簡單,禁止多線程下的重排序就可以了,只需要用 volatile 關鍵字修飾 mInstance 。在 JDK 1.5 中,增強了 volatile 的內存語義,對一個volatile 域的寫,happens-before 於任意後續對這個 volatile 域的讀。volatile 會禁止一些處理器重排序,此時 DCL 就做到了真正的線程安全。
4、靜態內部類模式
public class StaticInnerSingleton {
private StaticInnerSingleton(){}
private static class SingletonHolder{
private static final StaticInnerSingleton mInstance=new StaticInnerSingleton();
}
public static StaticInnerSingleton getInstance(){
return SingletonHolder.mInstance;
}
}
鑑於 DCL 繁瑣的代碼,程序員又發明了靜態內部類模式,它和餓漢式一樣基於類加載時器的線程安全,但是又做到了延遲加載。SingletonHolder 是一個靜態內部類,當外部類被加載的時候並不會初始化。當調用 getInstance() 方法時,纔會被加載。
枚舉單例暫且不提,放在最後再說。先對上面的單例模式做個檢測。
二、真的是單例?
還記得開頭的提問嗎?
你的單例線程安全嗎?
你的單例反射安全嗎?
你的單例序列化安全嗎?
上面大篇幅的論述都在說明線程安全。下面看看反射安全和序列化安全。
1、反射安全
直接上代碼,我用 DCL 來做測試:
public static void main(String[] args) {
DCLSingleton singleton1 = DCLSingleton.getInstance();
DCLSingleton singleton2 = null;
try {
Class<DCLSingleton> clazz = DCLSingleton.class;
Constructor<DCLSingleton> constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true);
singleton2 = constructor.newInstance();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(singleton1.hashCode());
System.out.println(singleton2.hashCode());
}
執行結果:
1627674070
1360875712
很無情,通過反射破壞了單例。如何保證反射安全呢?只能以暴制暴,當已經存在實例的時候再去調用構造函數直接拋出異常,對構造函數做如下修改:
private DCLSingleton() {
if (mInstance!=null)
throw new RuntimeException("想反射我,沒門!");
}
上面的測試代碼會直接拋出異常。
2、序列化安全
將你的單例類實現 Serializable 持久化保存起來,日後再恢復出來,他還是單例嗎?
public static void main(String[] args) {
DCLSingleton singleton1 = DCLSingleton.getInstance();
DCLSingleton singleton2 = null;
try {
ObjectOutput output=new ObjectOutputStream(new FileOutputStream("singleton.ser"));
output.writeObject(singleton1);
output.close();
ObjectInput input=new ObjectInputStream(new FileInputStream("singleton.ser"));
singleton2= (DCLSingleton) input.readObject();
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(singleton1.hashCode());
System.out.println(singleton2.hashCode());
}
執行結果:
644117698
793589513
不堪一擊。反序列化時生成了新的實例對象。要修復也很簡單,只需要修改反序列化的邏輯就可以了,即重寫 readResolve() 方法,使其返回統一實例。
protected Object readResolve() {
return getInstance();
}
脆弱不堪的單例模式經過重重考驗,進化成了完全體,延遲加載,線程安全,反射安全,序列化安全。全部代碼如下:
public class DCLSingleton implements Serializable {
private static DCLSingleton mInstance;
private DCLSingleton() {
if (mInstance!=null)
throw new RuntimeException("想反射我,沒門!");
}
public static DCLSingleton getInstance() {
if (mInstance == null) {
synchronized (DCLSingleton.class) {
if (mInstance == null)
mInstance = new DCLSingleton();
}
}
return mInstance;
}
protected Object readResolve() {
return getInstance();
}
}
三、枚舉單例
枚舉看到 DCL 就開始嘲笑他了,“你瞅瞅你那是啥,寫個單例費那大勁呢?” 於是擼起袖子自己寫了一個枚舉單例:
public enum EnumSingleton {
INSTANCE;
}
DCL 反問,“你這啥玩意,你這就是單例了?我來扒了你的皮看看 !” 於是 DCL 掏出 jad ,扒了 Enum 的衣服,拉出來示衆:
public final class EnumSingleton extends Enum {
public static EnumSingleton[] values() {
return (EnumSingleton[])$VALUES.clone();
}
public static EnumSingleton valueOf(String s) {
return (EnumSingleton)Enum.valueOf(test/singleton/EnumSingleton, s);
}
private EnumSingleton(String s, int i) {
super(s, i);
}
public static final EnumSingleton INSTANCE;
private static final EnumSingleton $VALUES[];
static {
INSTANCE = new EnumSingleton("INSTANCE", 0);
$VALUES = (new EnumSingleton[] {
INSTANCE
});
}
}
我們依次來檢查枚舉單例的線程安全,反射安全,序列化安全。
首先枚舉單例無疑是線程安全的,類似餓漢式,INSTANCE 的初始化放在了 static 靜態代碼段中,在類加載階段執行。由此可見,枚舉單例並不是延時加載的。
對於反射安全,又要掏出上面的檢測代碼了,根據 EnumSingleton 的構造器,需要稍微做些改動:
public static void main(String[] args) {
EnumSingleton singleton1 = EnumSingleton.INSTANCE;
EnumSingleton singleton2 = null;
try {
Class<EnumSingleton> clazz = EnumSingleton.class;
Constructor<EnumSingleton> constructor = clazz.getDeclaredConstructor(String.class,int.class);
constructor.setAccessible(true);
singleton2 = constructor.newInstance("test",1);
} catch (Exception e) {
e.printStackTrace();
}
System.out.println(singleton1.hashCode());
System.out.println(singleton2.hashCode());
}
結果直接報錯,錯誤日誌如下:
java.lang.IllegalArgumentException: Cannot reflectively create enum objects
at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
at singleton.SingleTest.main(SingleTest.java:16)
錯誤發生在 Constructor.newInstance() 方法,又要從源碼中找答案了,在 newInstance() 源碼中,有這麼一句:
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
throw new IllegalArgumentException("Cannot reflectively create enum objects");
如果是枚舉修飾的,直接拋出異常。和之前的對抗反射的手段一致,壓根就不給你反射。所以,枚舉單例也是天生反射安全的。
最後枚舉單例也是序列化安全的,上篇文章中已經說明過,你可以運行測試代碼試試。
看起來枚舉單例的確是個不錯的選擇,代碼簡單,又能保證絕大多數情況下的單例實例唯一。但是真正在開發中大家好像用的並不多,更多的可能應該是枚舉在 Java 1.5 中才添加,大家默認已經習慣了其他的單例實現方式。
四、代碼最少的單例?
說到枚舉單例代碼簡單,Kotlin 第一個站出來不服了。我敢說第一,誰敢說第二,給你們獻醜了:
object KotlinSingleton { }
jad 反編譯一下:
public final class KotlinSingleton {
private KotlinSingleton(){
}
public static final KotlinSingleton INSTANCE;
static {
KotlinSingleton kotlinsingleton = new KotlinSingleton();
INSTANCE = kotlinsingleton;
}
}
可以看到,Kotlin 的單例其實也是餓漢式的一種,不鑽牛角尖的話,基本可以滿足大部分需求。
吹毛求疵的談了談單例模式,可以看見要完全的保證單例還是有很多坑點的。在開發中並沒有必要鑽牛角尖,例如 Kotlin 默認提供的單例實現就是餓漢式而已,其實已經可以滿足絕大多數的情況了。