01.單例模式設計思想

潇湘剑雨yc發表於2024-10-14

01.單例模式設計思想

目錄介紹

  • 01.單例模式基礎介紹
    • 1.1 模式的動機
    • 1.2 單例模式特點
    • 1.3 單例模式定義
    • 1.4 單例使用場景
    • 1.5 單例模式思考
  • 02.單例模式設計思考
    • 2.1 為何要用單例
    • 2.2 處理資源訪問衝突
    • 2.3 表示全域性唯一類
  • 03.如何實現單例模式
    • 3.1 如何實現一個單例
    • 3.2 餓漢式實現方式
    • 3.3 懶漢式實現方式
    • 3.4 雙重DCL校驗模式
    • 3.5 靜態內部類方式
    • 3.6 列舉方式單例
    • 3.7 容器實現單例模式
    • 3.8 優缺點分析
  • 04.單例模式有那些不友好
    • 4.1 單例是反模式嗎
    • 4.2 單例對OOP不友好
    • 4.3 隱藏類之間依賴
    • 4.4 程式碼擴充套件性不友好
    • 4.5 可測試性不友好
    • 4.6 不支援有參建構函式
    • 4.7 有何替代解決方案
  • 05.最後總結一下
    • 5.1 適用環境分析
    • 5.2 對單例總結下

01.單例模式基礎介紹

1.0 本部落格AI摘要

本文詳細介紹了單例模式的設計思想及其應用。首先闡述了單例模式的基本概念、特點與定義,並探討其適用場景與常見問題。接著深入分析了為何使用單例模式,包括處理資源訪問衝突和表示全域性唯一類。隨後詳細講解了幾種常見的單例實現方式,如餓漢式、懶漢式、雙重檢查鎖定、靜態內部類及列舉等,對比了各自優缺點。最後討論了單例模式可能帶來的問題,如對OOP不友好、隱藏依賴關係、擴充套件性差等,並提出了一些替代解決方案。文章內容豐富,適合希望深入瞭解單例模式及其應用的讀者。

1.1 模式的動機

對於系統中的某些類來說,只有一個例項很重要,例如,一個系統中可以存在多個列印任務,但是隻能有一個正在工作的任務;一個系統只能有一個視窗管理器或檔案系統;一個系統只能有一個計時工具或ID(序號)生成器。

如何保證一個類只有一個例項並且這個例項易於被訪問呢?定義一個全域性變數可以確保物件隨時都可以被訪問,但不能防止我們例項化多個物件

一個更好的解決辦法是讓類自身負責儲存它的唯一例項。這個類可以保證沒有其他例項被建立,並且它可以提供一個訪問該例項的方法。這就是單例模式的模式動機。

1.2 單例模式特點

單例模式是應用最廣的模式

也是最先知道的一種設計模式,在深入瞭解單例模式之前,每當遇到如:getInstance()這樣的建立例項的程式碼時,我都會把它當做一種單例模式的實現。

單例模式特點

  1. 建構函式不對外開放,一般為private
  2. 透過一個靜態方法或者列舉返回單例類物件
  3. 確保單例類的物件有且只有一個,尤其是在多執行緒的環境下
  4. 確保單例類物件在反序列化時不會重新構造物件

1.3 單例模式定義

保證一個類僅有一個例項,並提供一個訪問它的全域性訪問點。

單例模式有三個基本要點:一是這個類只能有一個例項;二是它必須自行建立這個例項;三是它必須自行向整個系統提供這個例項。

1.3 單例使用場景

應用中某個例項物件需要頻繁的被訪問。

應用中每次啟動只會存在一個例項。如賬號系統,資料庫系統。

一個具有自動編號主鍵的表可以有多個使用者同時使用,但資料庫中只能有一個地方分配下一個主鍵編號,否則會出現主鍵重複,因此該主鍵編號生成器必須具備唯一性,可以透過單例模式來實現。

1.4 思考幾個問題

網上有很多講解單例模式的文章,但大部分都側重講解,如何來實現一個執行緒安全的單例。重點還是希望搞清楚下面這樣幾個問題。

  1. 為什麼要使用單例?
  2. 單例存在哪些問題?
  3. 單例與靜態類的區別?
  4. 有何替代的解決方案?

02.單例模式設計思考

2.1 為什麼要使用單例

單例設計模式(Singleton Design Pattern)理解起來非常簡單。

一個類只允許建立一個物件(或者例項),那這個類就是一個單例類,這種設計模式就叫作單例設計模式,簡稱單例模式。

重點看一下,為什麼我們需要單例這種設計模式?它能解決哪些問題?接下來我透過兩個實戰案例來講解。

  1. 第一個是處理資源訪問衝突;
  2. 第二個是表示全域性唯一類;

2.2 處理資源訪問衝突

實戰案例一:處理資源訪問衝突

先來看第一個例子。在這個例子中,我們自定義實現了一個往檔案中列印日誌的 Logger 類。具體的程式碼實現如下所示:

public class Logger {
    private final FileWriter writer;
    public Logger() {
        File file = new File("/Users/yangchong/log.txt");
        try {
            //true表示追加寫入
            writer = new FileWriter(file, true);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
    public void log(String message) {
        try {
            writer.write(message);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

public class UserController {
    private final Logger logger = new Logger();

    public void login(String username, String password) {
        // ...省略業務邏輯程式碼...
        logger.log(username + " logined!");
    }
}

public class OrderController {
    private final Logger logger = new Logger();

    public void create(String order) {
        // ...省略業務邏輯程式碼...
        logger.log("created order: " + order);
    }
}

看完程式碼之後,先彆著急看我下面的講解,你可以先思考一下,這段程式碼存在什麼問題。

  1. 在上面的程式碼中,所有的日誌都寫入到同一個檔案 /Users/yangchong/log.txt 中。在 UserController 和 OrderController 中,我們分別建立兩個 Logger 物件。
  2. 如果兩個執行緒同時分別執行 login() 和 create() 兩個函式,並且同時寫日誌到 log.txt 檔案中,那就有可能存在日誌資訊互相覆蓋的情況。
  3. 為什麼會出現互相覆蓋呢?可以這麼類比著理解。在多執行緒環境下,如果兩個執行緒同時給同一個共享變數加 1,因為共享變數是競爭資源,所以,共享變數最後的結果有可能並不是加了 2,而是隻加了 1。
  4. 同理,這裡的 log.txt 檔案也是競爭資源,兩個執行緒同時往裡面寫資料,就有可能存在互相覆蓋的情況。

那如何來解決這個問題呢?

最先想到的就是透過加鎖的方式:給 log() 函式加互斥鎖(Java 中可以透過 synchronized 的關鍵字),同一時刻只允許一個執行緒呼叫執行 log() 函式。具體的程式碼實現如下所示:

public class Logger {
  private FileWriter writer;

  public Logger() {
    File file = new File("/Users/yangchong/log.txt");
    writer = new FileWriter(file, true); //true表示追加寫入
  }
  
  public void log(String message) {
    synchronized(this) {
      writer.write(message);
    }
  }
}

不過,你仔細想想,這真的能解決多執行緒寫入日誌時互相覆蓋的問題嗎?

答案是否定的。這是因為,這種鎖是一個物件級別的鎖,一個物件在不同的執行緒下同時呼叫 log() 函式,會被強制要求順序執行。

但是,不同的物件之間並不共享同一把鎖。在不同的執行緒下,透過不同的物件呼叫執行 log() 函式,鎖並不會起作用,仍然有可能存在寫入日誌互相覆蓋的問題。

2.3 表示全域性唯一類

從業務概念上,如果有些資料在系統中只應儲存一份,那就比較適合設計為單例類。

比如,配置資訊類。在系統中,我們只有一個配置檔案,當配置檔案被載入到記憶體之後,以物件的形式存在,也理所應當只有一份。

再比如,唯一遞增 ID 號碼生成器,如果程式中有兩個物件,那就會存在生成重複 ID 的情況,所以,我們應該將 ID 生成器類設計為單例。

public static class IdGenerator {
    private final AtomicLong id = new AtomicLong(0);
    private static final IdGenerator instance = new IdGenerator();
    private IdGenerator() {}

    public static IdGenerator getInstance() {
        return instance;
    }
    public long getId() {
        return id.incrementAndGet();
    }
}

public void test() {
    // IdGenerator使用舉例
    long id = IdGenerator.getInstance().getId();
}

實際上,講到的兩個程式碼例項(Logger、IdGenerator),設計的都並不優雅,還存在一些問題。

03.如何實現單例模式

3.1 如何實現一個單例

介紹如何實現一個單例模式的文章已經有很多了,但為了保證內容的完整性,這裡還是簡單介紹一下幾種經典實現方式。概括起來,要實現一個單例,需要關注的點無外乎下面幾個:

  1. 建構函式需要是 private 訪問許可權的,這樣才能避免外部透過 new 建立例項;
  2. 考慮物件建立時的執行緒安全問題;
  3. 考慮是否支援延遲載入;
  4. 考慮 getInstance() 效能是否高(是否加鎖)。

3.2 餓漢式實現方式

餓漢式的實現方式比較簡單。在類載入的時候,instance 靜態例項就已經建立並初始化好了,所以,instance 例項的建立過程是執行緒安全的。

不過,這樣的實現方式不支援延遲載入,從名字中我們也可以看出這一點。具體的程式碼實現如下所示:

//餓漢式單例類.在類初始化時,已經自行例項化
public static class Singleton2 {
    //static修飾的靜態變數在記憶體中一旦建立,便永久存在
    private static final Singleton2 singleton = new Singleton2();

    private Singleton2() {

    }

    public static Singleton2 getInstance() {
        return singleton;
    }

    public static void main(String[] args) {
        Singleton2 instance = Singleton2.getInstance();
    }
}

程式碼分析

使用了 static 修飾了成員變數 instance,所以該變數會在類初始化的過程中被收集進類構造器即方法中。在多執行緒場景下,JVM 會保證只有一個執行緒能執行該類的 方法,其它執行緒將會被阻塞等待。

等到唯一的一次方法執行完成,其它執行緒將不會再執行方法,轉而執行自己的程式碼。也就是說,static 修飾了成員變數 instance,在多執行緒的情況下能保證只例項化一次。

其中 instance = new Singleton()可以寫成:

static {
    instance = new Singleton();
}

懶漢式單例說明

  1. 在類初始化階段就已經在堆記憶體中開闢了一塊記憶體,用於存放例項化物件,所以也稱為餓漢模式。
  2. 餓漢模式實現的單例的優點是,可以保證多執行緒情況下例項的唯一性,而且 getInstance 直接返回唯一例項,效能非常高。

有人覺得這種實現方式不好,思考下是否認同這種觀點

因為不支援延遲載入,如果例項佔用資源多(比如佔用記憶體多)或初始化耗時長(比如需要載入各種配置檔案),提前初始化例項是一種浪費資源的行為。最好的方法應該在用到的時候再去初始化。不過,我個人並不認同這樣的觀點。如果初始化耗時長,那我們最好不要等到真正要用它的時候,才去執行這個耗時長的初始化過程,這會影響到系統的效能(比如,在響應客戶端介面請求的時候,做這個初始化操作,會導致此請求的響應時間變長,甚至超時)。

採用餓漢式實現方式,將耗時的初始化操作,提前到程式啟動的時候完成,這樣就能避免在程式執行的時候,再去初始化導致的效能問題。如果例項佔用資源多,按照 fail-fast 的設計原則(有問題及早暴露),那我們也希望在程式啟動時就將這個例項初始化好。如果資源不夠,就會在程式啟動的時候觸發報錯(比如 Java 中的 PermGen Space OOM),我們可以立即去修復。這樣也能避免在程式執行一段時間後,突然因為初始化這個例項佔用資源過多,導致系統崩潰,影響系統的可用性。

3.3 懶漢式實現方式

餓漢式,對應地,就有懶漢式。懶漢式相對於餓漢式的優勢是支援延遲載入。具體的程式碼實現如下所示:

//懶漢式單例類.在第一次呼叫的時候例項化自己
public static class Singleton3 {
    private static Singleton3 singleton;
    private Singleton3() {

    }
    public static Singleton3 getInstance() {
        if (singleton == null) {
            singleton = new Singleton3();
        }
        return singleton;
    }

    public static void main(String[] args) {
        Singleton3 instance = Singleton3.getInstance();
    }
}

程式碼分析:懶漢式(執行緒不安全)的單例模式分為三個部分:私有的構造方法,私有的全域性靜態變數,公有的靜態方法。起到重要作用的是靜態修飾符static關鍵字,在程式中,任何變數或者程式碼都是在編譯時由系統自動分配記憶體來儲存的,而所謂靜態就是指在編譯後所分配的記憶體會一直存在,直到程式退出記憶體才會釋放這個空間,因此也就保證了單例類的例項一旦建立,便不會被系統回收,除非手動設定為null。

優缺點說明

  1. 優點:延遲載入(需要的時候才去載入)
  2. 缺點:執行緒不安全,在多執行緒中很容易出現不同步的情況,如在資料庫物件進行的頻繁讀寫操作時。

以上程式碼在單執行緒下執行是沒有問題的,但要執行在多執行緒下,就會出現例項化多個類物件的情況。這是怎麼回事呢?

當執行緒 A 進入到 if 判斷條件後,開始例項化物件,此時 instance 依然為 null;又有執行緒 B 進入到 if 判斷條件中,之後也會透過條件判斷,進入到方法裡面建立一個例項物件。

所以我們需要對該方法進行加鎖,保證多執行緒情況下僅建立一個例項。這裡我們使用 Synchronized 同步鎖來修飾 getInstance 方法

上面這個可以看到是執行緒不安全的,其實還可以演變一下:

public static synchronized Singleton3 getInstance() {
    if (singleton == null) {
        singleton = new Singleton3();
    }
    return singleton;
}

程式碼分析:這種單例實現方式的getInstance()方法中新增了synchronized 關鍵字,也就是告訴Java(JVM)getInstance是一個同步方法。同步的意思是當兩個併發執行緒訪問同一個類中的這個synchronized同步方法時,一個時間內只能有一個執行緒得到執行,另一個執行緒必須等待當前執行緒執行完才能執行,因此同步方法使得執行緒安全,保證了單例只有唯一個例項。

優缺點

  1. 優點:解決了執行緒不安全的問題。
  2. 缺點:效率有點低,每次呼叫例項都要判斷同步鎖,它的缺點在於每次呼叫getInstance()都進行同步,造成了不必要的同步開銷。這種模式一般不建議使用。

不過懶漢式的缺點也很明顯

  1. 給 getInstance() 這個方法加了一把大鎖(synchronized),導致這個函式的併發度很低。量化一下的話,併發度是 1,也就相當於序列操作了。而這個函式是在單例使用期間,一直會被呼叫。
  2. 如果這個單例類偶爾會被用到,那這種實現方式還可以接受。但是,如果頻繁地用到,那頻繁加鎖、釋放鎖及併發度低等問題,會導致效能瓶頸,這種實現方式就不可取了。

3.4 雙重DCL校驗模式

餓漢式不支援延遲載入,懶漢式有效能問題,不支援高併發。那再來看一種既支援延遲載入、又支援高併發的單例實現方式,也就是雙重檢測實現方式。

  1. 在這種實現方式中,只要 instance 被建立之後,即便再呼叫 getInstance() 函式也不會再進入到加鎖邏輯中了。
  2. Double-Check,它可以大大提高支援多執行緒的懶漢模式的執行效能。所以,這種實現方式解決了懶漢式併發度低的問題。

具體的程式碼實現如下所示:

public static class Singleton4 {
    private static Singleton4 singleton;  //靜態變數

    private Singleton4() {
        //私有建構函式
    }  

    public static Singleton4 getInstance() {
        if (singleton == null) {  //第一層校驗
            synchronized (Singleton4.class) {
                if (singleton == null) {  //第二層校驗
                    singleton = new Singleton4();
                }
            }
        }
        return singleton;
    }

    public static void main(String[] args) {
        Singleton4 instance4 = Singleton4.getInstance();
    }
}

程式碼分析:這種模式的亮點在於getInstance()方法上,其中對singleton 進行了兩次判斷是否空,第一層判斷是為了避免不必要的同步,第二層的判斷是為了在null的情況下才建立例項。

優缺點

  1. 優點:在併發量不多,安全性不高的情況下或許能很完美執行單例模式
  2. 缺點:不同平臺編譯過程中可能會存在嚴重安全隱患。

那這樣做是不是就能保證萬無一失了呢?還會有什麼問題嗎?

這裡又跟 Happens-Before 規則和重排序扯上關係了,這裡我們先來簡單瞭解下 Happens-Before 規則和重排序。

編譯器為了儘可能地減少暫存器的讀取、儲存次數,會充分複用暫存器的儲存值,比如以下程式碼,如果沒有進行重排序最佳化,正常的執行順序是步驟 1/2/3,而在編譯期間進行了重排序最佳化之後,執行的步驟有可能就變成了步驟 1/3/2,這樣就能減少一次暫存器的存取次數。

在 JMM 中,重排序是十分重要的一環,特別是在併發程式設計中。如果 JVM 可以對它們進行任意排序以提高程式效能,也可能會給併發程式設計帶來一系列的問題。

int a = 1;//步驟1:載入a變數的記憶體地址到暫存器中,載入1到暫存器中,CPU透過mov指令把1寫入到暫存器指定的記憶體中
int b = 2;//步驟2 載入b變數的記憶體地址到暫存器中,載入2到暫存器中,CPU透過mov指令把2寫入到暫存器指定的記憶體中
a = a + 1;//步驟3 重新載入a變數的記憶體地址到暫存器中,載入1到暫存器中,CPU透過mov指令把1寫入到暫存器指定的記憶體中

模擬分析,假設執行緒A執行到了singleton = new Singleton(); 語句,這裡看起來是一句程式碼,但是它並不是一個原子操作,這句程式碼最終會被編譯成多條彙編指令,它大致會做三件事情:

  1. a)給Singleton的例項分配記憶體
  2. b)呼叫Singleton()的建構函式,初始化成員欄位;
  3. c)將singleton物件指向分配的記憶體空間(即singleton不為空了);
  4. 但是由於Java編譯器允許處理器亂序執行,以及在jdk1.5之前,JMM(Java Memory Model:java記憶體模型)中Cache、暫存器、到主記憶體的回寫順序規定,上面的步驟b 步驟c的執行順序是不保證了。
  5. 也就是說執行順序可能是a-b-c,也可能是a-c-b,如果是後者的指向順序,並且恰恰在c執行完畢,b尚未執行時,被切換到執行緒B中,這時候因為singleton線上程A中執行了步驟c了,已經非空了,所以,執行緒B直接就取走了singleton,再使用時就會出錯。這就是DCL失效問題。

如何解決DCL中可能出現的失效問題(指a-b-c,順序變成了a-c-b導致使用物件出錯)

volatile 在 JDK1.5 之後還有一個作用就是阻止區域性重排序的發生,也就是說,volatile 變數的操作指令都不會被重排序。

所以使用 volatile 修飾 instance 之後,Double-Check 懶漢單例模式就萬無一失了。將singleton定義的程式碼改成:

private volatile static Singleton singleton;  //使用volatile 關鍵字

網上有人說,這種實現方式有些問題。

因為指令重排序,可能會導致 singleton 物件被 new 出來,並且賦值給 instance 之後,還沒來得及初始化(執行建構函式中的程式碼邏輯),就被另一個執行緒使用了。

要解決這個問題,需要給 instance 成員變數加上 volatile 關鍵字,禁止指令重排序才行。

實際上,只有很低版本的 Java 才會有這個問題。現在用的高版本的 Java 已經在 JDK 內部實現中解決了這個問題(解決的方法很簡單,只要把物件 new 操作和初始化操作設計為原子操作,就自然能禁止重排序)。

3.5 靜態內部類方式

再來看一種比雙重檢測更加簡單的實現方法,那就是利用 Java 的靜態內部類。它有點類似餓漢式,但又能做到了延遲載入。

在餓漢模式中,我們使用了 static 修飾了成員變數 instance,所以該變數會在類初始化的過程中被收集進類構造器即 方法中。

在多執行緒場景下,JVM 會保證只有一個執行緒能執行該類的 方法,其它執行緒將會被阻塞等待。這種方式可以保證記憶體的可見性、順序性以及原子性。

如果我們在 Singleton 類中建立一個內部類來實現成員變數的初始化,則可以避免多執行緒下重複建立物件的情況發生。

這種方式,只有在第一次呼叫 getInstance() 方法時,才會載入 InnerSingleton 類,而只有在載入 InnerSingleton 類之後,才會例項化建立物件。

public static class Singleton5 {
    private Singleton5() {

    }

    public static Singleton5 getInstance() {
        return SingletonHolder.INSTANCE;
    }

    //定義的靜態內部類
    private static class SingletonHolder {
        private static final Singleton5 INSTANCE = new Singleton5();  //建立例項的地方
    }

    public static void main(String[] args) {
        Singleton5 singleton5 = Singleton5.getInstance();
    }
}

優缺點:優點:延遲載入,執行緒安全(java中class載入時互斥的),也減少了記憶體消耗

程式碼分析:當第一次載入Singleton 類的時候並不會初始化INSTANCE ,只有第一次呼叫Singleton 的getInstance()方法時才會導致INSTANCE 被初始化。因此,第一次呼叫getInstance()方法會導致虛擬機器載入SingletonHolder 類,這種方式不僅能夠確保單例物件的唯一性,同時也延遲了單例的例項化。

3.6 列舉方式單例

介紹一種最簡單的實現方式,基於列舉單例模式的單例實現。

這種實現方式透過 Java 列舉型別本身的特性,保證了例項建立的執行緒安全性和例項的唯一性。具體的程式碼如下所示:

public static Singleton6 getInstance6(){
    return Singleton6.INSTANCE;
}

public enum Singleton6 {

    INSTANCE;

    public void whateverMethod() {

    }
}

程式碼分析

列舉單例模式最大的優點就是寫法簡單,列舉在java中與普通的類是一樣的,不僅能夠有欄位,還能夠有自己的方法,最重要的是預設列舉例項是執行緒安全的,並且在任何情況下,它都是一個單例。

即使是在反序列化的過程,列舉單例也不會重新生成新的例項。而其他幾種方式,必須加入如下方法:才能保證反序列化時不會生成新的物件。

private Object readResolve()  throws ObjectStreamException{
    return INSTANCE;
}

3.7 容器實現單例模式

這一種比較少見,直接上程式碼,如下所示:

public static class SingletonContainer {
    private static final Map<String, Singleton6> container = new HashMap<>();

    private SingletonContainer() {
        // 私有建構函式,防止外部例項化
    }
    
    public static Singleton6 getInstance(String key) {
        if (!container.containsKey(key)) {
            synchronized (SingletonContainer.class) {
                if (!container.containsKey(key)) {
                    Singleton6 instance = new Singleton6(); // 根據需要建立例項的方法
                    container.put(key, instance);
                }
            }
        }
        return container.get(key);
    }

    public static class Singleton6 {
        //單例類
    }
}

程式碼分析:在程式的初始,將多種單例模式注入到一個統一的管理類中,在使用時根據key獲取對應型別的物件。

3.8 優缺點分析

優點

  1. 提供了對唯一例項的受控訪問。因為單例類封裝了它的唯一例項,所以它可以嚴格控制客戶怎樣以及何時訪問它,併為設計及開發團隊提供了共享的概念。
  2. 由於在系統記憶體中只存在一個物件,因此可以節約系統資源,對於一些需要頻繁建立和銷燬的物件,單例模式無疑可以提高系統的效能。
  3. 允許可變數目的例項。我們可以基於單例模式進行擴充套件,使用與單例控制相似的方法來獲得指定個數的物件例項。

缺點

  1. 由於單例模式中沒有抽象層,因此單例類的擴充套件有很大的困難。
  2. 單例類的職責過重,在一定程度上違背了“單一職責原則”。因為單例類既充當了工廠角色,提供了工廠方法,同時又充當了產品角色,包含一些業務方法,將產品的建立和產品的本身的功能融合到一起。
  3. 濫用單例將帶來一些負面問題,如為了節省資源將資料庫連線池物件設計為單例類,可能會導致共享連線池物件的程式過多而出現連線池溢位;現在很多物件導向語言(如Java、C#)的執行環境都提供了自動垃圾回收的技術,因此,如果例項化的物件長時間不被利用,系統會認為它是垃圾,會自動銷燬並回收資源,下次利用時又將重新例項化,這將導致物件狀態的丟失。

04.單例模式有那些不友好

4.1 單例是反模式嗎

儘管單例是一個很常用的設計模式,在實際的開發中,我們也確實經常用到它,但是,有些人認為單例是一種反模式(anti-pattern),並不推薦使用。

所以,就針對這個說法詳細地講講這幾個問題:單例這種設計模式存在哪些問題?為什麼會被稱為反模式?如果不用單例,該如何表示全域性唯一類?有何替代的解決方案?

4.2 單例對OOP不友好

OOP 的四大特性是封裝、抽象、繼承、多型

單例這種設計模式對於其中的抽象、繼承、多型都支援得不好。為什麼這麼說呢?我們還是透過 IdGenerator 這個例子來講解。

public class Order {
  public void create(...) {
    //...
    long id = IdGenerator.getInstance().getId();
    //...
  }
}

public class User {
  public void create(...) {
    // ...
    long id = IdGenerator.getInstance().getId();
    //...
  }
}

IdGenerator 的使用方式違背了基於介面而非實現的設計原則,也就違背了廣義上理解的 OOP 的抽象特性。

如果未來某一天,我們希望針對不同的業務採用不同的 ID 生成演算法。比如,訂單 ID 和使用者 ID 採用不同的 ID 生成器來生成。

為了應對這個需求變化,我們需要修改所有用到 IdGenerator 類的地方,這樣程式碼的改動就會比較大。

public class Order {
  public void create(...) {
    //...
    long id = IdGenerator.getInstance().getId();
    // 需要將上面一行程式碼,替換為下面一行程式碼
    long id = OrderIdGenerator.getIntance().getId();
    //...
  }
}

public class User {
  public void create(...) {
    // ...
    long id = IdGenerator.getInstance().getId();
    // 需要將上面一行程式碼,替換為下面一行程式碼
    long id = UserIdGenerator.getIntance().getId();
  }
}

除此之外,單例對繼承、多型特性的支援也不友好。

這裡之所以會用“不友好”這個詞,而非“完全不支援”,是因為從理論上來講,單例類也可以被繼承、也可以實現多型,只是實現起來會非常奇怪,會導致程式碼的可讀性變差。

不明白設計意圖的人,看到這樣的設計,會覺得莫名其妙。所以,一旦你選擇將某個類設計成到單例類,也就意味著放棄了繼承和多型這兩個強有力的物件導向特性,也就相當於損失了可以應對未來需求變化的擴充套件性。

4.3 隱藏類之間依賴

程式碼的可讀性非常重要。

在閱讀程式碼的時候,我們希望一眼就能看出類與類之間的依賴關係,搞清楚這個類依賴了哪些外部類。透過建構函式、引數傳遞等方式宣告的類之間的依賴關係,我們透過檢視函式的定義,就能很容易識別出來。

但是,單例類不需要顯示建立、不需要依賴引數傳遞,在函式中直接呼叫就可以了。如果程式碼比較複雜,這種呼叫關係就會非常隱蔽。在閱讀程式碼的時候,我們就需要仔細檢視每個函式的程式碼實現,才能知道這個類到底依賴了哪些單例類。

4.4 程式碼擴充套件性不友好

單例類只能有一個物件例項。如果未來某一天,我們需要在程式碼中建立兩個例項或多個例項,那就要對程式碼有比較大的改動。你可能會說,會有這樣的需求嗎?既然單例類大部分情況下都用來表示全域性類,怎麼會需要兩個或者多個例項呢?

實際上,這樣的需求並不少見。我們拿資料庫連線池來舉例解釋一下。

在系統設計初期,我們覺得系統中只應該有一個資料庫連線池,這樣能方便我們控制對資料庫連線資源的消耗。所以,我們把資料庫連線池類設計成了單例類。但之後我們發現,系統中有些 SQL 語句執行得非常慢。這些 SQL 語句在執行的時候,長時間佔用資料庫連線資源,導致其他 SQL 請求無法響應。為了解決這個問題,我們希望將慢 SQL 與其他 SQL 隔離開來執行。為了實現這樣的目的,我們可以在系統中建立兩個資料庫連線池,慢 SQL 獨享一個資料庫連線池,其他 SQL 獨享另外一個資料庫連線池,這樣就能避免慢 SQL 影響到其他 SQL 的執行。

如果我們將資料庫連線池設計成單例類,顯然就無法適應這樣的需求變更,也就是說,單例類在某些情況下會影響程式碼的擴充套件性、靈活性。所以,資料庫連線池、執行緒池這類的資源池,最好還是不要設計成單例類。實際上,一些開源的資料庫連線池、執行緒池也確實沒有設計成單例類。

4.5 可測試性不友好

單例模式的使用會影響到程式碼的可測試性。如果單例類依賴比較重的外部資源,比如 DB,我們在寫單元測試的時候,希望能透過 mock 的方式將它替換掉。而單例類這種硬編碼式的使用方式,導致無法實現 mock 替換。

除此之外,如果單例類持有成員變數(比如 IdGenerator 中的 id 成員變數),那它實際上相當於一種全域性變數,被所有的程式碼共享。如果這個全域性變數是一個可變全域性變數,也就是說,它的成員變數是可以被修改的,那我們在編寫單元測試的時候,還需要注意不同測試用例之間,修改了單例類中的同一個成員變數的值,從而導致測試結果互相影響的問題。

4.6 不支援有參建構函式

單例不支援有引數的建構函式,比如我們建立一個連線池的單例物件,我們沒法透過引數來指定連線池的大小。針對這個問題,我們來看下都有哪些解決方案。

第一種解決思路是:建立完例項之後,再呼叫 init() 函式傳遞引數。需要注意的是,我們在使用這個單例類的時候,要先呼叫 init() 方法,然後才能呼叫 getInstance() 方法,否則程式碼會丟擲異常。具體的程式碼實現如下所示:

public class Singleton {
  private static Singleton instance = null;
  private final int paramA;
  private final int paramB;

  private Singleton(int paramA, int paramB) {
    this.paramA = paramA;
    this.paramB = paramB;
  }

  public static Singleton getInstance() {
    if (instance == null) {
       throw new RuntimeException("Run init() first.");
    }
    return instance;
  }

  public synchronized static Singleton init(int paramA, int paramB) {
    if (instance != null){
       throw new RuntimeException("Singleton has been created!");
    }
    instance = new Singleton(paramA, paramB);
    return instance;
  }
}

Singleton.init(10, 50); // 先init,再使用
Singleton singleton = Singleton.getInstance();

第二種解決思路是:將引數放到 getIntance() 方法中。具體的程式碼實現如下所示:

public class Singleton {
  private static Singleton instance = null;
  private final int paramA;
  private final int paramB;

  private Singleton(int paramA, int paramB) {
    this.paramA = paramA;
    this.paramB = paramB;
  }

  public synchronized static Singleton getInstance(int paramA, int paramB) {
    if (instance == null) {
      instance = new Singleton(paramA, paramB);
    }
    return instance;
  }
}

Singleton singleton = Singleton.getInstance(10, 50);

不知道你有沒有發現,上面的程式碼實現稍微有點問題。如果我們如下兩次執行 getInstance() 方法,那獲取到的 singleton1 和 signleton2 的 paramA 和 paramB 都是 10 和 50。也就是說,第二次的引數(20,30)沒有起作用,而構建的過程也沒有給與提示,這樣就會誤導使用者。這個問題如何解決呢?

Singleton singleton1 = Singleton.getInstance(10, 50);
Singleton singleton2 = Singleton.getInstance(20, 30);

第三種解決思路是:將引數放到另外一個全域性變數中。具體的程式碼實現如下。Config 是一個儲存了 paramA 和 paramB 值的全域性變數。裡面的值既可以像下面的程式碼那樣透過靜態常量來定義,也可以從配置檔案中載入得到。實際上,這種方式是最值得推薦的。

public class Config {
  public static final int PARAM_A = 123;
  public static fianl int PARAM_B = 245;
}

public class Singleton {
  private static Singleton instance = null;
  private final int paramA;
  private final int paramB;

  private Singleton() {
    this.paramA = Config.PARAM_A;
    this.paramB = Config.PARAM_B;
  }

  public synchronized static Singleton getInstance() {
    if (instance == null) {
      instance = new Singleton();
    }
    return instance;
  }
}

4.7 有何替代解決方案

提到了單例的很多問題,你可能會說,即便單例有這麼多問題,但我不用不行啊。業務上有表示全域性唯一類的需求,如果不用單例,怎麼才能保證這個類的物件全域性唯一呢?

為了保證全域性唯一,除了使用單例,我們還可以用靜態方法來實現。這也是專案開發中經常用到的一種實現思路。比如,上一節課中講的 ID 唯一遞增生成器的例子,用靜態方法實現一下,就是下面這個樣子:

// 靜態方法實現方式
public class IdGenerator {
  private static AtomicLong id = new AtomicLong(0);

  public static long getId() {
    return id.incrementAndGet();
  }
}
// 使用舉例
long id = IdGenerator.getId();

不過,靜態方法這種實現思路,並不能解決我們之前提到的問題。實際上,它比單例更加不靈活,比如,它無法支援延遲載入。我們再來看看有沒有其他辦法。實際上,單例除了我們之前講到的使用方法之外,還有另外一個種使用方法。具體的程式碼如下所示:

// 1. 老的使用方式
public demofunction() {
  //...
  long id = IdGenerator.getInstance().getId();
  //...
}

// 2. 新的使用方式:依賴注入
public demofunction(IdGenerator idGenerator) {
  long id = idGenerator.getId();
}
// 外部呼叫demofunction()的時候,傳入idGenerator
IdGenerator idGenerator = IdGenerator.getInsance();
demofunction(idGenerator);

基於新的使用方式,我們將單例生成的物件,作為引數傳遞給函式(也可以透過建構函式傳遞給類的成員變數),可以解決單例隱藏類之間依賴關係的問題。不過,對於單例存在的其他問題,比如對 OOP 特性、擴充套件性、可測性不友好等問題,還是無法解決。

05.最後總結一下

5.1 適用環境分析

在以下情況下可以使用單例模式:

  1. 系統只需要一個例項物件,如系統要求提供一個唯一的序列號生成器,或者需要考慮資源消耗太大而只允許建立一個物件。
  2. 客戶呼叫類的單個例項只允許使用一個公共訪問點,除了該公共訪問點,不能透過其他途徑訪問該例項。
  3. 在一個系統中要求一個類只有一個例項時才應當使用單例模式。反過來,如果一個類可以有幾個例項共存,就需要對單例模式進行改進,使之成為多例模式

5.2 對單例總結下

01.單例模式基礎介紹

對於系統某些需求來說,保證一個例項很重要,比如檔案管理系統,ID生成器等。為了保證例項只能被建立一次,因此這才有了單例模式!

單例模式特點,構造私有,單例類只有一個,反序列化不會重新構建物件,透過靜態返回單例物件。

使用場景:應用中只存在一個例項,比如賬號系統,資料庫等。思考幾個問題:為何使用單例,它存在什麼問題,跟靜態類有何區別,是否有替代方案?

02.單例模式設計思考

為何要使用單例?一個類只允許建立一個物件(或者例項),表示全域性唯一的類,比如Android中資料庫,所有資料操作都是指向一個資料庫!

03.單例模式實現方式

如何實現單例:構造必須私有,避免外部建立;要考慮執行緒安全問題,避免多執行緒下建立多個物件;是否支援延遲載入;效能

  1. 方式1: 熟悉單例模式各自的優缺點和使用場景。
  2. 方式2: 餓漢式實現方式。
  3. 方式3: 懶漢式實現方式。
  4. 方式4: 雙重DCL校驗模式。這種用的最為廣泛!
  5. 方式5: 靜態內部類方式。
  6. 方式6: 列舉方式單例。
  7. 方式7: 容器實現單例模式。

有什麼優點:1.提供全域性訪問【共享】;2.只有一個物件【對於高頻率比較好】;3.使用簡單。缺點也很明顯:1.擴充難;2.指責不清晰;3.濫用單例導致物件狀態丟失。

04.單例模式有那些不友好

單例對OOP不友好。單例違背了物件導向設計思想,不能搞封裝,繼承,多型等。如果強行實現物件導向,則會讓人感到奇怪!

對程式碼類之間的依賴和可讀性要注意。避免單例中內容太過於龐大,程式碼邏輯複雜導致維護比較難。

對程式碼擴充不友好。對可測試不夠友好。不支援有引數的構造。

5.3 更多內容推薦

模組 描述 備註
GitHub 多個YC系列開源專案,包含Android元件庫,以及多個案例 GitHub
部落格彙總 匯聚Java,Android,C/C++,網路協議,演算法,程式設計總結等 YCBlogs
設計模式 六大設計原則,23種設計模式,設計模式案例,物件導向思想 設計模式
Java進階 資料設計和原理,物件導向核心思想,IO,異常,執行緒和併發,JVM Java高階
網路協議 網路實際案例,網路原理和分層,Https,網路請求,故障排查 網路協議
計算機原理 計算機組成結構,框架,儲存器,CPU設計,記憶體設計,指令程式設計原理,異常處理機制,IO操作和原理 計算機基礎
學習C程式設計 C語言入門級別系統全面的學習教程,學習三到四個綜合案例 C程式設計
C++程式設計 C++語言入門級別系統全面的教學教程,併發程式設計,核心原理 C++程式設計
演算法實踐 專欄,陣列,連結串列,棧,佇列,樹,雜湊,遞迴,查詢,排序等 Leetcode
Android 基礎入門,開源庫解讀,效能最佳化,Framework,方案設計 Android

23種設計模式

23種設計模式 & 描述 & 核心作用 包括
建立型模式
提供建立物件用例。能夠將軟體模組中物件的建立和物件的使用分離
工廠模式(Factory Pattern)
抽象工廠模式(Abstract Factory Pattern)
單例模式(Singleton Pattern)
建造者模式(Builder Pattern)
原型模式(Prototype Pattern)
結構型模式
關注類和物件的組合。描述如何將類或者物件結合在一起形成更大的結構
介面卡模式(Adapter Pattern)
橋接模式(Bridge Pattern)
過濾器模式(Filter、Criteria Pattern)
組合模式(Composite Pattern)
裝飾器模式(Decorator Pattern)
外觀模式(Facade Pattern)
享元模式(Flyweight Pattern)
代理模式(Proxy Pattern)
行為型模式
特別關注物件之間的通訊。主要解決的就是“類或物件之間的互動”問題
責任鏈模式(Chain of Responsibility Pattern)
命令模式(Command Pattern)
直譯器模式(Interpreter Pattern)
迭代器模式(Iterator Pattern)
中介者模式(Mediator Pattern)
備忘錄模式(Memento Pattern)
觀察者模式(Observer Pattern)
狀態模式(State Pattern)
空物件模式(Null Object Pattern)
策略模式(Strategy Pattern)
模板模式(Template Pattern)
訪問者模式(Visitor Pattern)

相關文章