設計模式,創建型模式之單例模式

1 概述

單例模式應該是最簡單,同時又是最複雜的一種創建型模式。因爲大家都知道這個模式:無非就是保證某個對象在系統中只存在一個實例。然而想要真正實現一個完美的單例模式,卻不簡單。

2 單例模式

一般單例模式的實現,都需要包含兩個步驟:

  1. 將類的構造函數私有化。
  2. 提供一個public的方法,以供外界獲取唯一的實例。

下面將一一介紹單例模式的各種實現方式。

3 案例

3.1 註冊表式

提供一個註冊表類,來維護所有單例的實例

public class Test {
    public static void main(String[] args) {
        SampleClass singleton1 = Registry.getInstance(SampleClass.class);
        SampleClass singleton2 = Registry.getInstance(SampleClass.class);
        System.out.println("Registry singleton instance1: " + singleton1.hashCode());
        System.out.println("Registry singleton instance2: " + singleton2.hashCode());

        System.out.println("We can broke singleton by new a instance through class's construct method");
        SampleClass singleton3 = new SampleClass();
        System.out.println("Registry singleton instance3: " + singleton3.hashCode());
    }
}

public class Registry {
    private static Map<Class, Object> registry = new ConcurrentHashMap<>();
    private Registry() {};
    public static synchronized <T> T getInstance(Class<T> type) {
        Object obj = registry.get(type);
        if (obj == null) {
            try {
                obj = type.newInstance();
            } catch (IllegalAccessException | InstantiationException e) {
                e.printStackTrace();
            }
            registry.put(type, obj);
        }
        return (T) obj;
    }
}

public class SampleClass {
}

輸出:

Registry singleton instance1: 21685669
Registry singleton instance2: 21685669
We can broke singleton by new a instance through class's construct method
Registry singleton instance3: 2133927002

註冊表實現的單例其實是僞單例,因爲它只能保證從註冊表中獲取的對象是全局唯一的。如果我們不從註冊表獲取,而是直接new一個實例,這顯然破壞了單例模式。我們熟悉的Spring框架,就是用這種模式實現的單例,其中的Registry就是BeanFactory

要從根本上實現實例的全局唯一,我們必須在單例類本身下功夫。

3.1 餓漢式----靜態屬性

將實例作爲類的一個靜態變量,來實現唯一性:

public class StaticFieldTest {
    public static void main(String[] args) {
        StaticFieldSingleton fieldSingleton1 = StaticFieldSingleton.getInstance();
        StaticFieldSingleton fieldSingleton2 = StaticFieldSingleton.getInstance();
        System.out.println("StaticFieldSingleton instance1: " + fieldSingleton1.hashCode());
        System.out.println("StaticFieldSingleton instance1: " + fieldSingleton2.hashCode());
    }
}

public class StaticFieldSingleton {
    private static StaticFieldSingleton singletonInstance = new StaticFieldSingleton();

    // 將構造方法私有化
    private StaticFieldSingleton(){};

    // 提供唯一的接口,供外部獲取唯一的變量
    public static StaticFieldSingleton getInstance() {
        return singletonInstance;
    }
}

輸出:

StaticFieldSingleton in multi-thread instance: 837048303
StaticFieldSingleton in multi-thread instance: 837048303

當類StaticFieldSingleton被加載進JVM的時候,類的實例會作爲類的靜態屬性,隨着類一起初始化。這種實現方式其實是依靠類加載器來保證實例的唯一性。優點是,不需要考慮多線程加鎖,實現起來比較簡單。缺點是,無論後續是否會用到,實例都會在class被加載的時候被創建好。這對於內存資源比較寶貴的場景,或者目標是某些如File System的大對象的時候,會導致資源的浪費。同時,這種方式也無法提供對異常的處理,在某些情況下,會導致程序出錯。

3.2 餓漢式----靜態塊

將類實例的初始化放在類的靜態塊中:

public class StaticBlockTest {
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                StaticBlockSingleton staticBlockSingleton = StaticBlockSingleton.getInstance();
                System.out.println("StaticBlockSingleton in multi-thread instance: " + staticBlockSingleton.hashCode());
            }).start();
        }
    }
}

public class StaticBlockSingleton {
    private static StaticBlockSingleton singletonInstance;

    private StaticBlockSingleton(){};

    // 靜態塊會在類被加載進內存的時候被執行
    static {
        try {
            singletonInstance = new StaticBlockSingleton();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static StaticBlockSingleton getInstance() {
        return singletonInstance;
    }
}

輸出:

StaticBlockSingleton in multi-thread instance: 2132107705
StaticBlockSingleton in multi-thread instance: 2132107705

靜態塊中初始化與靜態變量上初始化本質上是一樣的,都是通過類加載器來保證實例只會被初始化一次。區別是,靜態塊初始化可以做異常的捕獲與處理,同時還允許我們在靜態塊中做一些額外的事情,比靜態變量的方式更自由。

但兩種餓漢式都不可避免地會造成額外內存的佔用,於是出現了按需加載懶漢式創建方式。

3.3 懶漢式----基礎版

將類實例的初始化放在方法中。只有當方法第一次被訪問的時候,去初始化實例:

public class SynchronizedTest {
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                SynchronizedSingleton synchronizedSingleton = SynchronizedSingleton.getInstance();
                System.out.println("SynchronizedSingleton in multi-thread instance: " + synchronizedSingleton.hashCode());
            }).start();
        }
    }
}

public class SynchronizedSingleton {
    private static SynchronizedSingleton singletonInstance;

    private SynchronizedSingleton(){};

    // 加了同步鎖,保證new SynchronizedSingleton()只會被第一個線程訪問
    public static synchronized SynchronizedSingleton getInstance() {
        if (singletonInstance == null) {
            singletonInstance = new SynchronizedSingleton();
        }
        return singletonInstance;
    }
}

輸出:

SynchronizedSingleton in multi-thread instance: 554449003
SynchronizedSingleton in multi-thread instance: 554449003

懶漢式解決了餓漢式存在的最大問題:可能導致的內存浪費。只有當getInstance()方法第一次被訪問的時候,實例纔會去真正創建。而方法上加了synchronized,保證了後續對方法的訪問,都只會返回之前創建好的實例,保證了唯一性。
這種方式的不足是,每次對getInstance()方法的訪問,都需要獲取鎖,衆所周知,鎖的獲取與釋放是一筆昂貴的開銷。而事實上只有當第一次實例創建的時候需要加鎖。於是有了改進的方式:雙檢鎖

3.4 懶漢式----雙檢鎖

雙檢鎖(Double Check Lock)是一個很多人都熟悉的概念,是上述模式的增強版。實現如下:

public class DoubleCheckLockTest {
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                DoubleCheckLockSingleton doubleCheckLockSingleton = DoubleCheckLockSingleton.getInstance();
                System.out.println("DoubleCheckLockSingleton in multi-thread instance: " + doubleCheckLockSingleton.hashCode());
            }).start();
        }
    }
}

public class DoubleCheckLockSingleton {
    // 變量必須聲明爲volatile,否則可能會得到一個“半初始化”的實例
    private static volatile DoubleCheckLockSingleton singletonInstance;

    private DoubleCheckLockSingleton(){};

    // 若實例已經被創建,則不需要再進入同步塊
    // 若實例還沒創建,則在同步塊中檢查並創建實例
    public static DoubleCheckLockSingleton getInstance() {
        DoubleCheckLockSingleton instance = singletonInstance;
        if (instance == null) {
            synchronized (DoubleCheckLockSingleton.class) {
                instance = singletonInstance;
                if (instance == null) {
                    instance = singletonInstance = new DoubleCheckLockSingleton();
                }
            }
        }
        return instance;
    }
}

輸出:

DoubleCheckLockSingleton in multi-thread instance: 837048303
DoubleCheckLockSingleton in multi-thread instance: 837048303

單例模式中,水最深的應該就是是雙檢鎖了。在上述實現中,有幾個要點:

  1. 爲什麼需要兩次if檢查:第一次if檢查在synchronized塊之外,當實例已經被創建好之後,可以立即返回。第二次if檢查,是因爲在高併發的情況下,可能會有好多線程走到第一個if塊中,去爭搶synchronized鎖,我們必須保證只有第一個搶到鎖的線程能創建實例,所以後面的線程必須再進行一次if判斷,發現實例已經被第一個搶到鎖的線程初始化好了,直接返回該實例。這也是雙檢名字的由來。
  2. 爲什麼成員變量singletonInstance要聲明爲volatile:因爲new DoubleCheckLockSingleton()其實並不是一個原子操作,主要可以分爲給實例分配堆內存執行類的構造函數將實例引用賦給調用者三步。而由於重排序的存在,在某一些機器上,第三步會先於第二步發生,於是可能出現,線程A走到了new DoubleCheckLockSingleton(),但並未執行完構造函數時,線程B發現instance != null了,於是對instance的屬性進行訪問,結果看到的屬性都是默認值。而JMMJava1.5之後進行了增強,volatile關鍵字可以禁止編譯器的重排序,並會在volatile關鍵字修飾的變量前後適當位置添加內存屏障,保證程序不會讀到半初始化的實例。關於JMM的增強,可以擴展閱讀Doug Lea大神的文章
  3. 爲什麼要加局部變量instance:加這個局部變量,主要是爲了提高程序的性能。因爲成員變量singletonInstance是聲明爲volatile的,而所有對volatile變量的操作(讀寫)都必須與主內存交互,開銷相對較大。加局部變量可以減少與volatile變量的交互。這也是java.util.concurrent包中很多工具類的常見做法。

到這裏,似乎雙檢鎖的方案已經很完美了,確實,這也是被很多人所採用的單例模式實現方案。但其實懶漢式還有一種更爲通用的實現方式。

3.5 懶漢式----靜態內部類

引入一個靜態內部類,來實現對靜態變量的延遲加載

public class InnerClassWrappedTest {
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                InnerClassWrappedSingleton innerClassWrappedSingleton = InnerClassWrappedSingleton.getInstance();
                System.out.println("InnerClassWrappedSingleton in multi-thread instance: " + innerClassWrappedSingleton.hashCode());
            }).start();
        }
    }
}
public class InnerClassWrappedSingleton {

    private InnerClassWrappedSingleton(){};

    // 內部類持有單例,僅當getInstance()方法被調用的時候,SingletonHolder類纔會被加載
    // final關鍵字保證了不會得到“半初始化”的實例
    private static class SingletonHolder {
        private static final InnerClassWrappedSingleton instance = new InnerClassWrappedSingleton();
    }

    public static InnerClassWrappedSingleton getInstance() {
        return SingletonHolder.instance;
    }
}

輸出:

InnerClassWrappedSingleton in multi-thread instance: 2132107705
InnerClassWrappedSingleton in multi-thread instance: 2132107705

上述實現其實可以看作是餓漢式----靜態塊的升級版,只不過把實例的初始化,放到了靜態內部類中。而該靜態內部類只有在getInstance()被調用的時候,纔會被加載,從而對單例進行初始化。同樣,由類加載器保證了,只有一個實例會被創建。同時,final關鍵字在Java1.5之後也進行了增強,可以保證得到的一定是一個完整的單例。
這種方式是本人覺得比較好的方式,因爲實現簡單線程安全,而且適用性很強。

3.6 破壞單例----序列化

其實所有上述的實現方式,都不可能完全保證類的唯一,因爲儘管我們把類的構造器設爲了private,但仍然有辦法用其他方式創建新的實例。比如不巧,單例的類正好實現了Serializable接口,那麼黑客們可以通過序列化的方式,得到一個新的“單例”:

public class SingletonDestroyerSerialization {
    public static void main(String[] args) throws IOException, ClassNotFoundException {
        InnerClassWrappedSingleton instance1 = InnerClassWrappedSingleton.getInstance();
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        // 將單例序列化
        oos.writeObject(instance1);

        ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(bais);
        // 反序列化,創建一個新的“單例”
        InnerClassWrappedSingleton instance2 = (InnerClassWrappedSingleton) ois.readObject();

        System.out.println("singleton instance1: " + instance1.hashCode());
        System.out.println("singleton instance2: " + instance2.hashCode());
    }
}

public static class InnerClassWrappedSingleton implements Serializable {

    private InnerClassWrappedSingleton(){};
    private static class SingletonHolder {
        private static final InnerClassWrappedSingleton instance = new InnerClassWrappedSingleton();
    }
    public static InnerClassWrappedSingleton getInstance() {
        return SingletonHolder.instance;
    }
}

輸出:

singleton instance1: 1173230247
singleton instance2: 764977973

顯然,輸出了不同的hashcodeJVM中存在了兩個“單例”對象。
爲了防止以上情況出現,我們可以在單例類中,添加一個readResolve()方法,並返回單例實例。這樣,在反序列化之後,我們得到的依然是原先的實例:

public static class InnerClassWrappedSingleton implements Serializable {
    private InnerClassWrappedSingleton(){};
    private static class SingletonHolder {
        private static final InnerClassWrappedSingleton instance = new InnerClassWrappedSingleton();
    }
    public static InnerClassWrappedSingleton getInstance() {
        return SingletonHolder.instance;
    }
    // 添加此方法,防止序列化與反序列化創建新的實例
    private Object readResolve() {
        return SingletonHolder.instance;
    }
}

3.7 破壞單例----反射

如果說序列化與反序列化我們還有應對的辦法,那麼對於反射攻擊,上述所有的實現方案,都無可奈何:

public class SingletonDestroyerRefelct {
    public static void main(String[] args) throws IllegalAccessException, InvocationTargetException, InstantiationException {
        InnerClassWrappedSingleton instance1 = InnerClassWrappedSingleton.getInstance();
        System.out.println("singleton instance1: " + instance1.hashCode());

        Constructor[] constructors = InnerClassWrappedSingleton.class.getDeclaredConstructors();
        for (Constructor constructor : constructors) {
            // 利用反射,創建一個新的“單例”變量
            constructor.setAccessible(true);
            InnerClassWrappedSingleton instance2 = (InnerClassWrappedSingleton) constructor.newInstance();
            System.out.println("singleton instance2: " + instance2.hashCode());
            break;
        }
    }
}

public static class InnerClassWrappedSingleton {

    private InnerClassWrappedSingleton(){};
    private static class SingletonHolder {
        private static final InnerClassWrappedSingleton instance = new InnerClassWrappedSingleton();
    }
    public static InnerClassWrappedSingleton getInstance() {
        return SingletonHolder.instance;
    }
}

輸出:

singleton instance1: 1735600054
singleton instance2: 21685669

反射其實類似Java中的一個後門,非常強大,它能破壞單例模式也是情理之中。類似JSON序列化與反序列化,也能創建多個不同的“單例”,利用的也是反射機制。

3.8 究極單例----Enum

有沒有辦法防止反射調用破壞單例呢?答案是肯定的,即用enum創建單例

public enum EnumSingleton {
    INSTANCE;

    public void doSomething() {

    }
}

首先,JVMenum型變量的序列化與反序列化做了特殊處理,保證反序列化之後得到的依然是內存中的那個enum
第二,Java從語言層面保證,無法通過反射創建enum類型變量。
所以,如果說要選一種最安全的單例模式實現方案,那非Enum模式莫屬。這也是「Effective Java」的作者Joshua Bloch所推薦的方式。

4 總結

本文介紹了形形色色很多的單例模式,其實也並不是越到後面的實現越好,而是要看每個版本的特性,選擇最適合自己項目的那個版本。

文中例子的github地址

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