設計模式篇之一文搞懂如何實現單例模式

JanYork_小簡發表於2023-03-04

設計模式篇之一文搞懂如何實現單例模式

大家好,我是小簡,這一篇文章,6種單例方法一網打盡,雖然單例模式很簡單,但是也是設計模式入門基礎,我也來詳細講講。

image.png

DEMO倉庫:https://github.com/JanYork/DesignPattern ,歡迎PR,共建。

單例模式

單例模式(SingletonPattern)是 Java 中最簡單的設計模式之一。

單例模式一共存在 --> 懶漢式、餓漢式、懶漢+同步鎖、雙重校驗鎖、靜態內部類、列舉這六種方式。

這種型別的設計模式屬於建立型模式,它提供了一種建立物件的最佳方式。

這種模式涉及到一個單一的類,該類負責建立自己的物件,同時確保只有單個物件被建立。

這個類提供了一種訪問其唯一的物件的方式,可以直接訪問,不需要例項化該類的物件。

要求

  • 單例類只能有一個例項。
  • 單例類必須自己建立自己的唯一例項。
  • 單例類必須給所有其他物件提供這一例項。

為什麼需要使用單例模式

  1. 只允許建立一個物件,因此節省記憶體,加快物件訪問速度,因此物件需要被公用的場合適合使用,如多個模組使用同一個資料來源連線物件等等。
  2. 解決一個全域性使用的類頻繁地建立與銷燬問題。
  3. 其他場景自行腦部,單例即全域性唯一物件,比如我們所熟悉的SpringBean預設就是單例的,全域性唯一。

單例原理

單例的原理非常簡單,我們讓他唯一的方法就是讓他不可用被new,那我們只需要私有化類的構造即可:

private ClassName() {
}

但是私有化後,我們不能new又如何建立物件呢?

我們首先要明白,private他是私有的,也就是不讓外部其他類訪問,那我們自己還是可以訪問的,所以在上文的要求中就說到了:單例類必須自己建立自己的唯一例項。

同時我們還需要丟擲單例的獲取方法。

單例模式之懶漢式

建立單例類

public class SlackerStyle {

}

建立一個屬性儲存自身物件

public class SlackerStyle {
    private static SlackerStyle instance;
}

私有化構造

public class SlackerStyle {
    private static SlackerStyle instance;

    /**
     * 私有化構造方法(防止外部new新的物件)
     */
    private SlackerStyle() {
    }
}

自身建立物件與獲取物件方法

public class SlackerStyle {
    private static SlackerStyle instance;

    /**
     * 私有化構造方法(防止外部new新的物件)
     */
    private SlackerStyle() {
    }

    /**
     * 提供一個靜態的公有方法,當使用到該方法時,才去建立instance
     * 即懶漢式
     *
     * @return instance(單例物件)
     */
    public static SlackerStyle getInstance() {
        if (instance == null) {
            instance = new SlackerStyle();
        }
        return instance;
    }
}
當我們呼叫靜態方法,它便會判斷上面的靜態屬性instance中有無自身物件,無 --> 建立物件並賦值給instance,有 --> 返回instance

優缺分析

優點:延遲載入,效率較高。

缺點:執行緒不安全,可能會造成多個例項。

解釋:延遲載入 --> 懶漢式只有在需要時才會建立單例物件,可以節約資源並提高程式的啟動速度。

單例模式之懶漢式+鎖

在以上的類中,對getInstance()方法新增synchronized鎖,即可彌補執行緒不安全缺陷。

    /**
     * 注意,此段為補充,為了解決執行緒不安全的問題,可以在方法上加上synchronized關鍵字,但是這樣會導致效率下降
     * 提供一個靜態的公有方法,加入同步處理的程式碼,解決執行緒安全問題
     * 此方法為執行緒安全的懶漢式,即懶漢+同步鎖,就不額外寫一個類了
     *
     * @return instance(單例物件)
     */
    public static synchronized SlackerStyle getInstance2() {
        if (instance == null) {
            instance = new SlackerStyle();
        }
        return instance;
    }
雖然彌補了執行緒不安全的缺陷,但是也失去了一部分效率,所以需要根據業務環境去選擇適合的方法,魚和熊掌不可兼得。

單利模式之餓漢式

還是如開始一樣,建立好單例類,私有化構造方法。

public class HungryManStyle {
    /**
     * 私有化構造方法(防止外部new新的物件)
     */
    private HungryManStyle() {
    }
}

靜態初始化物件

我們餓漢式是延遲載入的,即要用,然後第一次去呼叫時才會建立物件,而餓漢式恰恰相反,他在初始化類的時候就去建立。

靜態初始化?

我們的static關鍵詞修飾的方法或屬性,在類載入之初遍開闢記憶體建立好了相關的內容了。

包括每個類的:

static{

}

中也一樣的。

所以我們直接使用static修飾。

public class HungryManStyle {
    /**
     * 靜態變數(單例物件),類載入時就初始化物件(不存線上程安全問題)
     */
    private static final HungryManStyle INSTANCE = new HungryManStyle();

    /**
     * 私有化構造方法(防止外部new新的物件)
     */
    private HungryManStyle() {
    }

    /**
     * 提供一個靜態的公有方法,直接返回INSTANCE
     *
     * @return instance(單例物件)
     */
    public static HungryManStyle getInstance() {
        return INSTANCE;
    }
}

而且我們在類的靜態屬性建立時就new了一個自身物件了。

優缺分析

餓漢式的優點如下:

  1. 執行緒安全:由於在類載入時就建立單例物件,因此不存在多執行緒環境下的同步問題。
  2. 沒有加鎖的效能問題:餓漢式沒有使用同步鎖,因此不存在加鎖帶來的效能問題。
  3. 實現簡單:餓漢式的實現比較簡單,不需要考慮多執行緒環境下的同步問題。

餓漢式的缺點如下:

  1. 立即載入:由於在類載入時就建立單例物件,因此可能會影響程式的啟動速度。
  2. 浪費資源:如果單例物件很大,並且程式中很少使用,那麼餓漢式可能會浪費資源。

綜上所述,餓漢式的優點是執行緒安全、沒有加鎖的效能問題和實現簡單,缺點是可能影響程式的啟動速度和浪費資源。

在選擇單例模式的實現方式時,需要根據實際情況綜合考慮各種因素,選擇最適合的方式。

單例模式之雙重檢查鎖

初始化基本單例類

老規矩。

public class DoubleLockStyle {
    /**
     * volatile關鍵字,使得instance變數在多個執行緒間可見,禁止指令重排序最佳化
     * volatile是一個輕量級的同步機制,即輕量鎖
     */
    private static volatile DoubleLockStyle instance;

    /**
     * 私有化構造方法(防止外部new新的物件)
     */
    private DoubleLockStyle() {
    }
}

不一樣的是,我在屬性上使用volatile關鍵詞修飾了。

volatile?

補充知識啦!

在這個程式碼中,使用了 volatile 關鍵字來確保 instance 變數的可見性,避免出現空指標異常等問題。

  1. volatile是一種修飾符,用於修飾變數。
  2. 當一個變數被宣告為volatile時,執行緒在訪問該變數時會強制從主記憶體中讀取變數的值,而不是從執行緒的本地快取中讀取。
  3. 使用volatile關鍵字可以保證多執行緒之間的變數訪問具有可見性和有序性。
  4. 在對該變數進行修改時,執行緒也會將修改後的值強制刷回主記憶體,而不是僅僅更新執行緒的本地快取。

補充:

volatile的主要作用是保證共享變數的可見性和有序性。共享變數是指在多個執行緒之間共享的變數,例如單例模式中的 instance 變數。如果不使用volatile關鍵字修飾 instance 變數,在多執行緒環境下可能會出現空指標異常等問題。

這是因為當一個執行緒修改了 instance 變數的值時,其他執行緒可能無法立即看到修改後的值,從而出現空指標異常等問題。

使用 volatile 關鍵字可以解決這個問題,因為它可以保證對共享變數的修改對其他執行緒是可見的。

除了可見性和有序性之外,volatile 還可以防止指令重排序。指令重排序是指 CPU 為了提高程式執行的效率而對指令執行的順序進行調整的行為。在單例模式中,如果 instance 變數沒有被宣告為 volatile,那麼在多執行緒環境下可能會出現單例物件被重複建立的問題。這是因為在多執行緒環境下,某些執行緒可能會在 instance 變數被初始化之前就呼叫 getInstance() 方法,從而導致多次建立單例物件。透過將 instance 變數宣告為 volatile,可以保證在建立單例物件之前,instance 變數已經被正確地初始化了。

雙重鎖

/**
 * 提供一個靜態的公有方法,加入雙重檢查程式碼,解決執行緒安全問題,同時解決懶載入問題
 * 即雙重檢查鎖模式
 *
 * @return instance(單例物件)
 */
public static DoubleLockStyle getInstance() {
    if (instance == null) {
        // 同步程式碼塊,執行緒安全的建立例項
        synchronized (DoubleLockStyle.class) {
            //之所以要再次判斷,是因為可能有多個執行緒同時進入了第一個if判斷
            if (instance == null) {
                instance = new DoubleLockStyle();
            }
        }
    }
    return instance;
}

在獲取方法中,使用synchronized來同步,使它執行緒安全。

有缺分析

雙重鎖模式是一種用於延遲初始化的最佳化模式,在第一次呼叫時建立單例物件,並在之後的訪問中直接返回該物件。它透過使用雙重檢查鎖定(double checked locking)來保證在多執行緒環境下只有一個執行緒可以建立單例物件,並且不會加鎖影響程式效能。

優點:

  1. 執行緒安全:使用雙重鎖模式可以保證在多執行緒環境下只有一個執行緒可以建立單例物件,並且不會加鎖影響程式效能。
  2. 延遲初始化:在第一次呼叫時建立單例物件,可以避免不必要的資源浪費和記憶體佔用。
  3. 效能最佳化:透過使用雙重檢查鎖定,可以避免不必要的鎖競爭,從而提高程式效能。

缺點:

  1. 實現複雜:雙重鎖模式的實現相對複雜,需要考慮執行緒安全和效能等因素,容易出現錯誤。
  2. 可讀性差:由於雙重鎖模式的實現比較複雜,程式碼可讀性較差,不易於理解和維護。
  3. 難以除錯:由於雙重鎖模式涉及到多執行緒併發訪問,因此在除錯過程中可能會出現一些難以定位和復現的問題。

一個synchronized為何叫雙重鎖?

在雙重鎖模式中,確實只有一個 synchronized 關鍵字,但是這個 synchronized 關鍵字是在程式碼中被使用了兩次,因此被稱為“雙重鎖”。

具體來說,雙重鎖模式通常會在 getInstance 方法中使用 synchronized 關鍵字來保證執行緒安全,但是這會影響程式的效能,因為每次訪問 getInstance 方法都需要獲取鎖。為了避免這個問題,雙重鎖模式使用了一個最佳化技巧,即只有在第一次呼叫 getInstance 方法時才會獲取鎖並建立單例物件,以後的呼叫都直接返回已經建立好的單例物件,不需要再獲取鎖。

具體實現時,雙重鎖模式會在第一次呼叫 getInstance 方法時進行兩次檢查,分別使用外部的 if 語句和內部的 synchronized 關鍵字。外部的 if 語句用於判斷單例物件是否已經被建立,如果已經被建立則直接返回單例物件,否則進入內部的 synchronized 關鍵字塊,再次檢查單例物件是否已經被建立,如果沒有被建立則建立單例物件並返回,否則直接返回已經建立好的單例物件。

這樣做的好處是,在多執行緒環境下,只有一個執行緒可以進入內部的 synchronized 關鍵字塊,從而保證了執行緒安全,同時避免了每次訪問 getInstance 方法都需要獲取鎖的效能問題。

單例模式之靜態內部類

因為已經熟悉了這個設計模式原理,我就直接放程式碼了。

public class StaticInnerClassStyle {
    /**
     * 私有化構造方法(防止外部new新的物件)
     */
    private StaticInnerClassStyle() {
    }

    /**
     * 靜態內部類
     */
    private static class SingletonInstance {
        // 靜態內部類中的靜態變數(單例物件)
        private static final StaticInnerClassStyle INSTANCE = new StaticInnerClassStyle();
    }

    /**
     * 提供一個靜態的公有方法,直接返回SingletonInstance.INSTANCE
     *
     * @return instance(單例物件)
     */
    public static StaticInnerClassStyle getInstance() {
        return SingletonInstance.INSTANCE;
    }
}

優缺分析

優點:

  1. 執行緒安全:靜態內部類在第一次使用時才會被載入,因此在多執行緒環境下也可以保證只有一個執行緒建立單例物件,避免了執行緒安全問題。
  2. 延遲載入:靜態內部類模式可以實現延遲載入,即只有在第一次呼叫 getInstance 方法時才會載入內部類並建立單例物件,避免了在程式啟動時就建立單例物件的開銷。

缺點:

  1. 需要額外的類:靜態內部類模式需要定義一個額外的類來實現單例模式,如果專案中有大量的單例物件,則會增加程式碼量。
  2. 無法傳遞引數:靜態內部類模式無法接受引數,因此無法在建立單例物件時傳遞引數,這可能會對某些場景造成限制。

總的來說,靜態內部類模式是一種效能高、執行緒安全的單例模式實現方式,適用於大部分場景。

如果需要傳遞引數或者需要頻繁建立單例物件,則可能需要考慮其他的實現方式。

它不是static修飾嗎?為什麼也可以懶載入

懶載入即延時載入 --> 使用時採取建立物件。

在靜態內部類模式中,單例物件是在靜態內部類中被建立的。靜態內部類只有在第一次被使用時才會被載入,因此單例物件也是在第一次使用時被建立的。這樣就實現了延遲載入的效果,即在需要時才建立單例物件,避免了在程式啟動時就建立單例物件的開銷。

此外,靜態內部類中的靜態變數和靜態方法是在類載入時被初始化的,而靜態內部類本身是非常輕量級的,載入和初始化的時間和開銷都非常小。因此,靜態內部類模式既能夠實現懶載入,又不會帶來太大的效能損失。

總之,它在靜態初始化意料之外,我相信也在你意料之外。

單例模式之列舉單例

/**
 * @author JanYork
 * @date 2023/3/1 17:54
 * @description 設計模式之單例模式(列舉單例)
 * 優點:避免序列化和反序列化攻擊破壞單例,避免反射攻擊破壞單例(列舉型別建構函式是私有的),執行緒安全,延遲載入,效率較高。
 * 缺點:程式碼複雜度較高。
 */
public enum EnumerateSingletons {
    /**
     * 列舉單例
     */
    INSTANCE;

    public void whateverMethod() {
        // TODO:do something ,在這裡實現單例物件的功能
    }
}

在上述程式碼中,INSTANCEEnumSingleton 型別的一個列舉常量,表示單例物件的一個例項。由於列舉型別的特性,INSTANCE 會被自動初始化為單例物件的一個例項,並且保證在整個應用程式的生命週期中只有一個例項。

使用列舉單例的方式非常簡單,只需要透過 EnumSingleton.INSTANCE 的方式來獲取單例物件即可。例如:

EnumerateSingletons singleton = EnumerateSingletons.INSTANCE;
singleton.doSomething();

使用列舉單例的好處在於,它是執行緒安全、序列化安全、反射安全的,而且程式碼簡潔明瞭,不容易出錯。另外,列舉單例還可以透過列舉型別的特性來新增其他方法和屬性,非常靈活。

優缺分析

  1. 執行緒安全:列舉型別的例項建立是在類載入的時候完成的,因此不會出現多個執行緒同時訪問建立單例例項的問題,保證了執行緒安全。
  2. 序列化安全:列舉型別預設實現了序列化,因此可以保證序列化和反序列化過程中單例的一致性。
  3. 反射安全:由於列舉型別的特殊性,不會被反射機制建立多個例項,因此可以保證反射安全。
  4. 簡潔明瞭:列舉單例的程式碼非常簡潔,易於理解和維護。

列舉單例的缺點相對來說比較少,但是也存在一些限制:

  1. 不支援懶載入:列舉型別的例項建立是在類載入的時候完成的,因此無法實現懶載入的效果。
  2. 無法繼承:列舉型別不能被繼承,因此無法透過繼承來擴充套件單例類的功能。
  3. 有些情況下不太方便使用:例如需要傳遞引數來建立單例物件的場景,使用列舉單例可能不太方便。

總之,列舉單例是一種非常優秀的單例實現方式,它具有執行緒安全、序列化安全、反射安全等優點,適用於大多數單例場景,但也存在一些限制和侷限性。需要根據具體的場景來選擇合適的單例實現方式。

這麼多方式我該怎麼選?

設計模式本就是業務中最佳化一些設計帶來的概念性設計,我們需要結合業務分析:

  1. 餓漢式:適用於單例物件較小、建立成本低、不需要懶載入的場景。
  2. 懶漢式:

    • 雙重鎖:適用於多執行緒環境,對效能要求較高的場景。
    • 靜態內部類:適用於多執行緒環境,對效能要求較高的場景。
  3. 列舉:適用於單例物件建立成本較高,且需要考慮執行緒安全、序列化安全、反射安全等問題的場景。

如果你的單例物件建立成本低、不需要考慮執行緒安全、序列化安全、反射安全等問題,建議使用餓漢式實現單例;如果需要考慮執行緒安全和效能問題,可以選擇懶漢式的雙重鎖或靜態內部類實現方式;如果需要考慮單例物件建立成本較高,需要考慮執行緒安全、序列化安全、反射安全等問題,建議選擇列舉單例實現方式。

當然,在實際的開發中,還需要考慮其他一些因素,如單例物件的生命週期、多執行緒訪問情況、效能要求、併發訪問壓力等等,才能綜合選擇最合適的單例實現方式。

Java程式設計師身邊的單例模式

來自某AI(敏感詞):

相關文章