天天看點

設計模式 - Java中單例模式的6種寫法及優缺點對比

本篇通過代碼講解6種不同的單例模式的實作方式, 分别是: 饑餓模式、懶惰模式、雙重檢查鎖模式、靜态内部類模式、枚舉類模式和ThreadLocal模式, 并分析了它們之間的優缺點, 最後還介紹了JDK中常見的單例模式的應用, 以及破壞單例模式的多種方法.

目錄

  • 1 為什麼要用單例模式
    • 1.1 什麼是單例模式
    • 1.2 單例模式的思路和優勢
  • 2 寫法① - 饑餓模式
    • 2.1 代碼示例
    • 2.2 優缺點比較
  • 3 寫法② - 懶惰模式
    • 3.1 代碼示例
    • 3.2 優缺點比較
    • 3.3 線程是否安全的測試
    • 3.4 線程安全的懶惰模式
  • 4 寫法③ - 雙重檢查鎖模式
    • 4.1 代碼示例
    • 4.2 DCL存在的問題
    • 4.3 解決方法
  • 5 寫法④ - 靜态内部類實作單例
    • 5.1 代碼示例
    • 5.2 靜态内部類的優勢
  • 6 寫法⑤ - 枚舉類實作單例
    • 6.1 代碼示例
    • 6.2 優缺點比較
  • 7 寫法⑥ - 通過ThreadLocal實作單例
  • 8 擴充: JDK中的單例 以及 如何破壞單例模式
    • 8.1 JDK中常見的單例模式
    • 8.2 破壞單例模式的方法
  • 9 擴充 - 性能對比
  • 參考資料
  • 版權聲明

單例模式就是: 在程式運作期間, 某些類有且最多隻有一個執行個體對象.

我們的應用中可能存在這樣的需求: 某些類沒有自己的狀态, 在程式運作期間它們隻需要有一個執行個體, 換句話說, 無論為這些類建立多少個執行個體, 對程式的運作狀态、運作結果都不會産生影響.

更重要的一點是: 有些類如果存在兩個或者兩個以上的執行個體, 應用程式就會發生某些匪夷所思的錯誤, 不同于空指針、數組越界、非法參數等錯誤, 這樣的問題一般都很難提前發覺和定位.

這個時候, 我們就應該把這樣的類控制為單例結構 —— 確定程式運作期間最多隻有一個相對應的執行個體對象.

關于類的狀态的了解:

① 比如有一個 Person 類, 它有成員變量name、age等等, 不同的姓名和年齡就是不同的人, 也就是說這些變量都是不确定的, 這樣的類就是有狀态的類.

② 而像一些配置類, 比如 RedisProps (Redis的配置資訊)類, 它的所有屬性和方法都是static的, 沒有不确定的屬性, 這樣的類就可以認為是沒有狀态的類.

—— 純屬個人看法, 若了解有誤, 還請讀者朋友們提出, 歡迎批評和交流😁

(1) 單例模式的實作思路是:

① 靜态化執行個體對象, 讓執行個體對象與Class對象互相綁定, 通過Class類對象就可以直接通路;

② 私有化構造方法, 禁止通過構造方法建立多個執行個體 —— 最重要的一步;

③ 提供一個公共的靜态方法, 用來傳回這個類的唯一執行個體.

(2) 單例模式的優勢:

單例模式的好處是: 盡可能節約記憶體空間(不用為一個類建立多個執行個體對象), 減少GC(垃圾回收)的消耗, 并使得程式正常運作.

接下來就較長的描述單例模式的6種不同寫法.

饑餓模式又稱為餓漢模式, 指的是JVM在加載類的時候就完成類對象的建立:

/**
 * 饑餓模式: 類加載時就初始化
 */
final class HungrySingleton {
    /** 執行個體對象 */
    private static HungrySingleton instance = new HungrySingleton();

    /** 禁用構造方法 */
    private HungrySingleton() { }

    /**
     * 擷取單例對象, 直接傳回已建立的執行個體
     * @return instance 本類的執行個體
     */
    public static HungrySingleton getInstance() {
        return instance;
    }
}
           

(1) 優點: JVM層面的線程安全.

JVM在加載這個類的時候就會對它進行初始化, 這裡包含對靜态變量的初始化;

Java的語義包證了在引用這個字段之前并不會初始化它, 并且通路這個字段的任何線程都将看到初始化這個字段所産生的所有寫入操作.

—— 參考自 The "Double-Checked Locking is Broken" Declaration, 原文如下:

If the singleton you are creating is static (i.e., there will only be one Helper created), as opposed to a property of another object (e.g., there will be one Helper for each Foo object, there is a simple and elegant solution.

Just define the singleton as a static field in a separate class. The semantics of Java guarantee that the field will not be initialized until the field is referenced, and that any thread which accesses the field will see all of the writes resulting from initializing that field.
           
==> 是以這就在JVM層面包證了線程安全.

(2) 缺點: 造成空間的浪費.

饑餓模式是典型的以空間換時間思想的實作: 不用判斷就直接建立, 但建立之後如果不使用這個執行個體, 就造成了空間的浪費. 雖然隻是一個類執行個體, 但如果是體積比較大的類, 這樣的消耗也不容忽視.

—— 不過在有些時候, 直接初始化單例的執行個體對項目的影響也微乎其微, 比如我們在應用啟動時就需要加載的配置檔案資訊, 就可以采取這種方式去保證單例.

懶惰模式又稱為懶漢模式, 指的是在真正需要的時候再完成類對象的建立:

/**
 * 懶惰模式: 用到時再初始化, 線程不安全, 可以在方法上使用synchronized關鍵字實作線程安全
 */
final class LazySingleton {
    /** 執行個體對象 */
    private static LazySingleton instance = null;

    /** 禁用構造方法 */
    private LazySingleton() { }

    /**
     * 線程不安全, 可以在方法上使用synchronized關鍵字實作線程安全
     * @return instance 本類的執行個體
     */
    public static LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
}
           

(1) 優點: 節省空間, 用到的時候再建立執行個體對象.

需要這個執行個體的時候, 先判斷它是否為空, 如果為空, 再建立單例對象.

用到的時候再去建立, 與JVM加載類的思路一緻: 都是需要的時候再處理.

(2) 缺點: 線程不安全.

① 在并發擷取執行個體的時候, 線程A調用getInstance(), 在判斷

singleton == null

時得到true的結果, 之後進入if語句, 準備建立instance執行個體;

② 恰好在這個時候, 另一個線程B來了, CPU将執行權切換給了B —— 此時A還沒來得及建立出執行個體, 是以線程B在判斷

singleton == null

的時候, 結果還是true, 是以線程B也會進入if語句去建立執行個體;

③ 問題來了: 兩個線程都進入了if語句, 結果就是: 建立了2個執行個體對象.

/**
 * 測試懶惰模式的線程安全
 */
public static void main(String[] args) {
    // 同步的Set, 用來儲存建立的執行個體
    Set<String> instanceSet = Collections.synchronizedSet(new HashSet<>());
    
    // 建立100個線程, 将每個線程獲得的執行個體添加到Set中
    for (int i = 0; i < 100; i++) {
        new Thread(() -> {
            instanceSet.add(LazySingleton.getInstance().toString());
        }).start();
    }

    for (String instance : instanceSet) {
        System.out.println(instance);
    }
}
           

(1) 代碼說明: 上述循環中的Lambda表達式的作用, 等同于:

new Thread(new Runnable() {
     @Override
     public void run() {
         instanceSet.add(LazySingleton.getInstance().toString());
     }
 }).start();
           

(2) 輸出結果說明: 由于Set集合能夠自動去重, 是以如果輸出的結果中有2個或2個以上的對象, 就足以說明在并發通路的過程中出現了線程安全問題. 當然如果沒有出現的話, 不妨多運作幾次, 或者把循環次數調大一點再試試😜

(1) 通過

synchronized

關鍵字對擷取執行個體的方法進行同步限制, 實作了線程安全:

/**
     * 在擷取執行個體的公共方法上使用synchronized關鍵字實作線程安全
     * @return instance 本類的執行個體
     */
    public synchronized static LazySingleton getInstance() {
        if (instance == null) {
            instance = new LazySingleton();
        }
        return instance;
    }
           

(2) 優缺點比較:

上面的做法是把整個擷取執行個體的方法同步. 這樣一來, 當某個線程通路這個方法時, 其它所有的線程都要處于挂起等待狀态.

① 優點: 避免了同步通路建立多個執行個體的問題;

② 缺點: 很明顯, 這樣的做法對所有線程的通路都會進行同步操作, 有很嚴重的性能問題.

在上述代碼中, 我們不難發現, 其實同步操作隻需要發生在執行個體還未建立的時候, 在執行個體建立以後, 擷取執行個體的方法就沒必要再進行同步控制了.

這個思路就是 雙重檢查鎖(Double Checked Locking, 簡稱DCL)模式 的實作思路, 是線上程安全的懶惰模式的基礎上改進得來的. 下面我們通過代碼剖析這種模式:

/**
 * 雙重檢查鎖模式: 對線程安全的懶惰模式的改進: 方法上的synchronized在每次調用時都要加鎖, 性能太低.
 */
final class DoubleCheckedLockingSingleton {
    /** 執行個體對象, 這裡還沒有添加volatile關鍵字 */
    private static DoubleCheckedLockingSingleton instance = null;

    /** 禁用構造方法 */
    private DoubleCheckedLockingSingleton() { }

    /**
     * 擷取對象: 将方法上的synchronized移至内部
     * @return instance 本類的執行個體
     */
    public static DoubleCheckedLockingSingleton getInstance() {
        // 先判斷執行個體是否存在
        if (instance == null) {
            // 加鎖建立執行個體
            synchronized (DoubleCheckedLockingSingleton.class) {
                // 再次判斷, 因為可能出現某個線程拿了鎖之後, 還沒來得及執行初始化就釋放了鎖,
                // 而此時其他的線程拿到了鎖又執行到此處 ==> 這些線程都會建立一個執行個體, 進而建立多個執行個體對象
                if (instance == null) {
                    instance = new DoubleCheckedLockingSingleton();
                }
            }
        }
        return instance;
    }
}
           
實作過程中需要注意的事項, 都在注視中作了說明.

你以為到這裡, 單例模式就安全了嗎? 不是的!

在多處理器的共享記憶體、或者編譯器的優化下, DCL模式并不一定線程 —— 可能 (注意: 隻是可能出現) 會發生指令的重排序, 出現半個對象的問題.

(1) JVM在建立執行個體的時候, 是分為如下步驟建立的:

① 在堆記憶體中, 為新的執行個體開辟空間;

② 初始化構造器, 對執行個體中的成員進行初始化;

③ 把這個執行個體的引用 (也就是這裡的instance) 指向①中空間的起始位址.

==> 也就是說, Java中建立一個對象的過程并不是原子性操作.

(2) 上述過程不是原子性的, 是以就可能出現:

JVM在優化代碼的過程中, 可能對①-③這三個過程進行重排序 —— 因為 JVM會對位元組碼進行優化, 其中就包括了指令的重排序.

如果重排序後變為①③②, 就會出現一些難以捕捉的問題.

(3) 再來說說半個對象:

構造方法中有其他非原子性操作, 建立對象時隻是得到了對象的正确引用, 而對象内部的成員變量可能還沒有來得及指派, 這個時候就可能通路到 "不正确(陳舊)" 的成員變量.

對引用類型 (包括對象和數組) 變量的非同步通路, 即使得到該引用的最新值, 也并不能保證能得到其成員變量 (對數組而言就是每個數組中的元素) 的最新值;

在聲明對象時通過關鍵字

volatile

, 禁止JVM對這個對象涉及到的代碼重排序:

private static volatile DoubleCheckedLockingSingleton instance = null;
           

這裡我們用

volatile

關鍵字修飾了

instance

變量, JVM就不會對

instance

的建立過程進行優化, 隻要我們通路這個類的任意一個靜态域, 就會建立這個類的對象.

關于

volatile

關鍵字的作用:

volatile

關鍵字禁止了JVM的指令重排序, 并且保證線程中對這個變量所做的任何寫入操作對其他線程都是即時可見的 (也就是保證了記憶體的可見性).

需要注意的是, 這兩個特性是在JDK 5 之後才支援的.

—— 關于類的加載機制、volitale關鍵字的詳細作用, 後續會有播客輸出, 讀者盆友們可以先去各大部落格、論壇搜尋研究下, 也可以檢視文末的參考部落格連結.

靜态内部類也稱作Singleton Holder, 也就是單持有者模式, 是線程安全的, 也是懶惰模式的變形.

JVM加載類的時候, 有這麼幾個步驟:

①加載 -> ②驗證 -> ③準備 -> ④解析 -> ⑤初始化

需要注意的是: JVM在加載外部類的過程中, 是不會加載靜态内部類的, 隻有内部類(SingletonHolder)的屬性/方法被調用時才會被加載, 并初始化其靜态屬性(instance).

/**
 * 靜态内部類模式, 也稱作Singleton Holder(單持有者)模式: 線程安全, 懶惰模式的一種, 用到時再加載
 */
final class StaticInnerSingleton {
    /** 禁用構造方法 */
    private StaticInnerSingleton() { }

    /**
     * 通過靜态内部類擷取單例對象, 沒有加鎖, 線程安全, 并發性能高
     * @return SingletonHolder.instance 内部類的執行個體
     */
    public static StaticInnerSingleton getInstance() {
        return SingletonHolder.instance;
    }

    /** 靜态内部類建立單例對象 */
    private static class SingletonHolder {
        private static StaticInnerSingleton instance = new StaticInnerSingleton();
    }
}
           

比較推薦這種方式, 沒有加鎖, 線程安全, 用到時再加載, 并發行能高.

JDK 5開始, 提供了枚舉(enum), 其實就是一個文法糖: 我們寫很少的代碼, JVM在編譯的時候幫我們添加很多額外的資訊.

通過對枚舉類的反編譯可以知道: 枚舉類也是在JVM層面保證的線程安全.

/**
 * 枚舉類單例模式
 */
enum EnumSingleton {
    /** 此枚舉類的一個執行個體, 可以直接通過EnumSingleton.INSTANCE來使用 */
    INSTANCE
}
           

(1) 優點: JVM對枚舉類的特殊規定決定了:

① 不需要考慮序列化的問題: 枚舉序列化是由JVM保證的, 每一個枚舉類型和枚舉變量在JVM中都是唯一的, 在枚舉類型的序列化和反序列化上Java做了特殊的規定: 在序列化時Java僅僅是将枚舉對象的name屬性輸出到結果中, 反序列化時隻是通過

java.lang.Enum#valueOf()

方法來根據名字查找枚舉對象 —— 編譯器不允許對這種序列化機制進行定制、并且禁用了writeObject、readObject、readObjectNoData、writeReplace、readResolve等方法, 進而保證了枚舉執行個體的唯一性;

② 不需要考慮反射的問題: 在通過反射方法

java.lang.reflect.Constructor#newInstance()

建立枚舉執行個體時, JDK源碼對調用者的類型進行了判斷:
// 判斷調用者clazz的類型是不是Modifier.ENUM(枚舉修飾符), 如果是就抛出參數異常:
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
    throw new IllegalArgumentException("Cannot reflectively create enum objects");
           
是以, 我們是不能通過反射建立枚舉執行個體的, 也就是說建立枚舉執行個體隻有編譯器能夠做到.
關于JVM對枚舉類的處理, 可以參考我的這篇文章: Java中枚舉類型的使用 - enum.

(2) 缺點: 所有的屬性都必須在建立時指定, 也就意味着不能延遲加載; 并且使用枚舉時占用的記憶體比靜态變量的2倍還多, 這在性能要求嚴苛的應用中是不可忽視的.

還是在 The "Double-Checked Locking is Broken" Declaration 這篇文章中, 發現了通過 ThreadLocal 修正DCL問題的思路: 每個線程都持有一個 ThreadLocal 标志, 用來确定該線程是否已完成所需的同步. 具體代碼如下:

/**
 * 通過ThreadLocal實作單例模式, 性能可能比較低
 */
class ThreadLocalSingleton {
    /** 如果 perThreadInstance.get() 傳回一個非空值, 說明目前線程已經被同步了: 它要看到instance變量的初始化 */
    private static ThreadLocal perThreadInstance = new ThreadLocal();
    private static ThreadLocalSingleton instance = null;

    public static ThreadLocalSingleton getInstance() {
        if (perThreadInstance.get() == null) {
            createInstance();
        }
        return instance;
    }

    private static final void createInstance() {
        synchronized (ThreadLocalSingleton.class) {
            if (instance == null) {
                instance = new ThreadLocalSingleton();
            }
        }
        // 任何非空的值都可以作為這裡的參數
        perThreadInstance.set(perThreadInstance);
    }

    /**
     * 阿裡代碼規範提示: ThreadLocal變量應該至少調用一次remove()方法, 原因如下:
     * 必須回收自定義的ThreadLocal變量, 尤其線上程池場景下, 因為線程經常會被複用, 
     * 如果不清理自定義的 ThreadLocal變量, 可能會影響後續業務邏輯和造成記憶體洩露等問題.
     * 盡量在代理中使用try-finally塊進行回收.
     */
    public static void remove() {
        perThreadInstance.remove();
    }

}
           

這種技術的性能在很大程度上取決于的JDK的版本. 在Sun JDK 1.2中, ThreadLocal性能非常慢, 而在1.3中性能明顯提升了. 具體的性能對比, 參見下一節.

(1) java.lang.Runtime類中的getRuntime()方法;

(2) java.awt.Toolkit類中的getDefaultToolkit()方法;

(3) java.awt.Desktop類中的getDesktop()方法;

(4) 另外, RuntimeException也是單例的 —— 因為一個Java應用隻有一個Java Runtime Environment.

(1) 除枚舉方式外, 其他方法都會通過反射的方式破壞單例, 解決方法:

反射是通過調用構造方法生成新的對象, 可以在構造方法中進行判斷 —— 若已有執行個體, 則阻止生成新的執行個體, 如:
private Singleton() throws Exception {
    if (instance != null) {
      throw new Exception("Singleton already initialized, 此類是單例類, 不允許生成新對象, 請通過getInstance()擷取本類對象");
    }
}
           

(2) 如果單例類實作了序列化接口Serializable, 就可以通過反序列化破壞單例, 解決方法:

不實作序列化接口, 或者重寫反序列化方法

readResolve()

, 反序列化時直接傳回相關單例對象:
// 反序列化時直接傳回目前執行個體
public Object readResolve() {
    return instance;
}
           

(3) Object#clone()方法也會破壞單例, 即使你沒有實作Cloneable接口 —— 因為clone()方法是Object類中的. 解決方法是:

重寫clone()方法, 并在其中抛出異常資訊“Can not create clone of Singleton class”

(1) 測試用的代碼:

建立100個線程, 每個線程中循環擷取10,000次單例對象, 統計各個類所用的時間.

public static void main(String[] args) throws InterruptedException {

    // 建立的線程數
    int threadNum = 100;
    // 循環擷取對象的次數
    int objectNum = 10000;
  
    Long beginTime = System.currentTimeMillis();
    for (int i = 0; i < threadNum; i++) {
        new Thread(() -> {
            for (int j = 0; j < objectNum; j++) {
                Object o = HungrySingleton.getInstance();
            }
        }).start();
    }
    Long endTime = System.currentTimeMillis();
    System.out.println("HungrySingleton --- " + (endTime - beginTime) + " ms");

    // 省去一大串其他類的測試代碼

    beginTime = System.currentTimeMillis();
    for (int i = 0; i < threadNum; i++) {
        new Thread(() -> {
            for (int j = 0; j < objectNum; j++) {
                Object o = EnumSingleton.INSTANCE;
            }
        }).start();
    }
    endTime = System.currentTimeMillis();
    System.out.println("EnumSingleton --- " + (endTime - beginTime) + " ms");

}
           

說明:

這個測試代碼的重複性太高了, 本來想封裝成方法、通過反射進行不同類和方法的調用的, 可考慮到反射的性能損耗, 一時又想不到其他好點的方法, 是以不得已采取了這種. 各位看官請别噴, 有好點的方法可以在留言區交流下🙏

(2) 測試結果, 機關是毫秒(ms):

不同的模式 第一次 第二次 第三次 平均耗時
饑餓模式 (HungrySingleton) 59 61 62
線程安全的懶惰模式 (LazySingleton) 27 10 41 26
雙重檢查鎖模式 (DoubleCheckedLockingSingleton) 12 14 13
靜态内部類模式 (StaticInnerSingleton) 22 16
枚舉類模式 (EnumSingleton) 8 9
線程本地變量 (ThreadLocalSingleton) 21 24
運作多次, 發現結果不太穩定, 暫時未找到原因, 是以就不總結了, 各位看官權當參考, 還請存疑🤨

呼, Java中的單例模式終于整理完了, 由于個人經驗有限, 肯定存在很多疏漏, 如果你在浏覽的時候發現問題, 請直接在評論區指出來, 拜謝各位.

(一)單例模式詳解

volatile關鍵字到底做了什麼?

The "Double-Checked Locking is Broken" Declaration

深入了解Java枚舉類型(enum)

作者: 馬瘦風

出處: 部落格園 馬瘦風的部落格

感謝閱讀, 如果文章有幫助或啟發到你, 點個[好文要頂👆] 或 [推薦👍] 吧😜

本文版權歸部落客所有, 歡迎轉載, 但 [必須在文章頁面明顯位置給出原文連結], 否則部落客保留追究相關人員法律責任的權利.