鑽鑽 “單例模式” 的牛角尖!你寫的安全嗎?

枚舉很適合用來實現單例模式。實際上,在 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 默認提供的單例實現就是餓漢式而已,其實已經可以滿足絕大多數的情況了。

From https://www.jianshu.com/p/40388996ad5a

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章