banner
lMingyul

lMingyul

记录穿过自己的万物
jike

這次我真的能搞懂單例模式了嗎?

學習一下常見的設計模式,作為最” 簡單 “入門的設計模式,先拿它練練手

靈魂拷問#

什麼是單例模式?#

只允許只存在這個類的唯一一個實例對象

什麼意思呢?平時我們在 Java 中新建一個對象實例一般是這樣的

// 類名 實例名稱 = new 類名();
Demo demo = new Demo();

如果只能允許只有一個對象實例,即 new Demo() 只能被調用一次。
那這個怎麼保證呢?使用單例模式!

為什麼需要單例模式#

什麼情況下我們需要只有一個對象的情況呢?

可以考慮一下這個問題,在一個複雜系統中每天都存在著大量的數據和消息需要處理,這個時候就需要對大量的數據和消息用一個唯一的標識做區分,這個時候我們的主要常見的實現手段:UUID、自定義遞增 ID 生成器等。

使用 UUID 生成的 ID 太長,不易於存儲,其次我們是為了研究單例模式,所以就考慮一下自定義遞增 ID 生成器怎麼實現,可以看一下以下代碼

public static void main(String[] args) {
    IDGenerator idGenerator = new IDGenerator();
    long firstId = idGenerator.getId();
    long secondId = idGenerator.getId();
    printWithTreadName("firstId: " + firstId);
    printWithTreadName("secondId: " + secondId);
}

public static void printWithTreadName(String msg) {
    System.out.println("TreadName: " + Thread.currentThread().getName() + ", " + msg);
}
public class IDGenerator {

    private AtomicLong id = new AtomicLong(0);

    public IDGenerator() {
        printWithTreadName("IDGenerator init");
        printWithTreadName("id hashcode: " + System.identityHashCode(id));
        printWithTreadName("id value: " + id.get());
    }

    public long getId() {
        return id.incrementAndGet();
    }
}

輸出結果:

TreadName: main, IDGenerator init
TreadName: main, id hashcode: 1808253012
TreadName: main, id value: 0
TreadName: main, firstId: 1
TreadName: main, secondId: 2
  • AtomicLong 是 Java 中 java.util.concurrent.atomic 包下的一個類,它用於對 long 類型的值進行原子操作。AtomicLong 通過使用底層的無鎖機制(在 Java 中稱為 CAS,Compare and Swap),來實現對一個 long 類型的變量進行並發控制。這意味著 AtomicLong 中的多個線程可以在不使用 synchronized 或 Lock 的情況下安全地進行操作。
  • incrementAndGet 是 AtomicLong 的一個方法,它對當前的值進行原子加 1 操作,並返回增加後的值。原子性保證了在多線程環境中,這個增加操作不會被其他線程中斷。
  • System.identityHashCode 會返回一個基於對象地址的(但不是實際的內存地址)整數哈希碼,可以簡單通過這個哈希值判斷對象是否相等(不考慮哈希衝突的情況)

可以看到當 IDGenerator 這個類實例化的時候 AtomicLong 類也已經實例化完成,id 的值為 0,然後通過 IDGenerator 實例調用 2 次 getId() 方法,實現了 ID 自增唯一的目的。

但是現在如果這個 IDGenerator 被實例多次會發生什麼現象呢?AtomicLong 類也會重新實例化多次,這樣就會出現重複的 ID,這樣這個 ID 生成器就做不到唯一性標識了!
那什麼情況下 IDGenerator 類會被實例多次呢?

  • 不同人進行開發,不清楚 IDGenerator 的實際用法,直接實例化進行使用
    • 有些人說可以將這個 IDGenerator 類實例化定義為全局靜態變量,這樣直接使用這個靜態變量就可以避免實例化多次 IDGenerator 啦
    • 這個只能靠程序員之間互相約定,這個太不可靠了,有沒有一些強制的手段只允許實例化一次呢?
  • 在多線程的情況下,2 個線程同時執行一個方法,這個方法裡面會調用生成 ID,這個時候就很容易出現 ID 重複的現象

下面就看一下多線程並發的情況

public class Main {
    public static void main(String[] args) {
        testWithoutSingleton();
    }

    public static void testWithoutSingleton() {
        // 創建 2 個線程
        DemoTread t1 = new DemoTread();
        DemoTread t2 = new DemoTread();
        t1.start();
        t2.start();
    }

    public static void doSomething() {
        printWithTreadName("generator id: " + new IDGenerator().getId());
    }

    public static void printWithTreadName(String msg) {
        System.out.println("TreadName: " + Thread.currentThread().getName() + ", " + msg);
    }
}

public class DemoTread extends Thread {

    static int threadNo = 1;

    public DemoTread () {
        super("DemoTread-"  + threadNo++);
    }

    @Override
    public void run () {
        doSomething();
    }
}

先定義一個樣例線程,線程執行的任務就是調用 Main 類中的 doSomething() 方法,而doSomething() 方法就是生成 ID
輸出結果:

TreadName: DemoTread-2, IDGenerator init
TreadName: DemoTread-1, IDGenerator init
TreadName: DemoTread-2, id hashcode: 359513719
TreadName: DemoTread-1, id hashcode: 2107584424
TreadName: DemoTread-1, id value: 0
TreadName: DemoTread-2, id value: 0
TreadName: DemoTread-1, generator id: 1
TreadName: DemoTread-2, generator id: 1

從輸出結果可以看出:

  • hashcode 不同,代表 2 個線程分別生成了 2 個不同的 AtomicLong 類對象
  • 生成 id 重複一樣
    2 個不同的線程分別生成 2 次 IDGenerator 類對象 -> 導致生成了 2 個不同的 AtomicLong 類對象 -> 出現了生成 id 重複一樣的現象

那單例模式到底是怎麼做到只生成一個實例給不同的線程使用的呢?

單例模式怎麼實現?#

首先需要先修改 IDGenerator 類

public class IDGenerator {

    private AtomicLong id = new AtomicLong(0);
    // 初始化唯一的 IDGenerator 實例對象
    private static final IDGenerator instance = new IDGenerator();
    // 構造函數改為 private 私有
    private IDGenerator() {
        printWithTreadName("IDGenerator init");
        printWithTreadName("id hashcode: " + System.identityHashCode(id));
        printWithTreadName("id value: " + id.get());
    }
    // 新增一個公共可以訪問的方法,返回唯一的 IDGenerator 實例對象
    public static IDGenerator getInstance() {
        return instance;
    }

    public long getId() {
        return id.incrementAndGet();
    }
}

修改點:

  • 定義一個靜態常量,將 IDGenerator 實例對象賦給這個靜態常量,注意是常量,有 final 修飾的
  • 將 IDGenerator 的構造函數改為 private 私有
  • 新增一個公共可以訪問的方法,返回唯一的 IDGenerator 實例對象

然後修改一下外部訪問調用生成 ID 的方式

public static void doSomething() {
    printWithTreadName("generator id: " + IDGenerator.getInstance().getId());
}

輸出結果:

TreadName: DemoTread-2, IDGenerator init
TreadName: DemoTread-2, id hashcode: 359513719
TreadName: DemoTread-2, id value: 0
TreadName: DemoTread-2, generator id: 1
TreadName: DemoTread-1, generator id: 2

可以看出 IDGenerator 類只被線程 DemoTread-2 初始化了一次,而且 2 個線程生成 ID 沒有出現重複了

這個就是單例模式起的作用:

  1. 靜態常量 instance 只會在 IDGenerator 類加載的時候進行初始化,而且只會初始化一次,不能被修改,這保證了全局只能有一個 IDGenerator 對象和 AtomicLong 對象
  2. 將 IDGenerator 的構造函數改為 private 私有是不讓這個類之外的其他類訪問,拒絕了其他外部類主動實例化 IDGenerator 對象的情況
  3. 只提供一個唯一公共訪問的靜態方法給外部調用返回唯一的 IDGenerator 對象

簡單來說就是:我不讓你來創建了,我創建好唯一的一個對象,你用我這個就可以啦


不同實現單例的方式#

實現單例的方式有很多種,包括:餓漢式、懶漢式、雙重檢測、靜態內部類、枚舉

餓漢式#

其實上面實現就是餓漢式,為什麼叫餓漢式呢?
那是因為對象是在類加載的時候就被創建好了,沒等到對象需要用的時候就初始化好了,看起來很迫不及待,所以稱為”餓漢式

餓漢式的經典寫法:

public class IDGenerator {

    private static final IDGenerator instance = new IDGenerator();

    private IDGenerator() {}

    public static IDGenerator getInstance() {
        return instance;
    }
}

餓漢式的優點#

  • 線程安全:對象在類加載的時候就完成了創建,不存在其他線程能創建多個對象,線程安全是由 JVM 來保證的,無需額外處理多線程同步問題。
  • 代碼簡單

餓漢式的缺點#

  • 資源浪費:對象在類加載的時候就創建好了,那如果我後續程序運行過程中不用了,就會造成資源的浪費
  • 引起類加載變慢:如果這個類初始化操作複雜,那可能會增加類加載耗費的時間,影響程序啟動速度
  • 無法處理突發的異常:如果在類加載過程中,構造方法執行拋異常了,這個異常就沒法進行捕獲處理

既然提前初始化有問題,那有沒有什麼方法可以延遲加載呢?
有,懶漢式。


懶漢式#

跟餓漢式反著來,要等到這個對象要被用到的時候再進行初始化,那怎麼做呢?

public class IDGenerator {

    private AtomicLong id = new AtomicLong(0);

    private static IDGenerator instance;

    private IDGenerator() {
        Instant instant = Instant.ofEpochMilli(System.currentTimeMillis());
        DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
                .withZone(ZoneId.systemDefault());
        String formattedTime = formatter.format(instant);
        printWithTreadName("當前時間:" + formattedTime);
        printWithTreadName("IDGenerator init");
        printWithTreadName("id hashcode: " + System.identityHashCode(id));
        printWithTreadName("id value: " + id.get());
    }

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

    public long getId() {
        return id.incrementAndGet();
    }
}

修改點:

  • 將靜態常量 instance 改為靜態變量,這個變量的初始化改到 getInstance() 方法中
  • getInstance()方法加上 null 判斷,如果 instance 變量為空則進行實例化
    輸出結果
TreadName: DemoTread-2, 當前時間:2023-07-25 21:28:09
TreadName: DemoTread-2, IDGenerator init
TreadName: DemoTread-1, 當前時間:2023-07-25 21:28:09
TreadName: DemoTread-1, IDGenerator init
TreadName: DemoTread-1, id hashcode: 1457402119
TreadName: DemoTread-2, id hashcode: 822717100
TreadName: DemoTread-2, id value: 0
TreadName: DemoTread-1, id value: 0
TreadName: DemoTread-2, generator id: 1
TreadName: DemoTread-1, generator id: 1

哎,為什麼會生成出 2 個實例對象出來的呢?
其實以上的代碼是有點問題的,看上起我們加了是否為 null 的判斷,防止會多次實例化 IDGenerator 對象,但是在多線程並發的情況下,如果一個線程正在初始化 IDGenerator 這個對象的同時,還沒完成初始化,另一個線程也調用了 getInstance() 方法,就會導致 IDGenerator 對象初始化多次。
從輸出結果我們可以看到 DemoTread-2 和 DemoTread-1 線程都在 2023-07-25 21:28:09 開始初始化 IDGenerator 對象,這就會導致發生出錯。

那怎麼避免呢?在 getInstance() 方法上加 synchronized 關鍵字,保證同一時刻只有一個線程訪問這個方法。

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

懶漢式的優點#

  • 可以做到延遲加載,需要才創建,避免了不必要的資源浪費

懶漢式的缺點#

  • 需要加上 Synchronized 關鍵字來確保線程安全,這就導致了每次訪問時都需要進行同步,頻繁加鎖、釋放鎖及並發度低等問題,會影響性能。

那能不能有兩全其美的方法,既要有延遲加載,又沒有性能問題呢?有,雙重檢測!


雙重檢測#

直接看代碼:

public static IDGenerator getInstance() {
    if (instance == null) {
        synchronized (IDGenerator.class) {
            if (instance == null) {
                instance = new IDGenerator();
            }
        }
    }
    return instance;
}

修改點:將修飾方法的 synchronized 改為修改類,實現一個類級別的鎖

這個方法怎麼做到不會執行多次的 synchronized 的線程同步操作?

  • 首先,線程進入到 getInstance()方法內部,會檢測當前狀態:只有 2 種狀態,IDGenerator 已經實例化完了(當前 instance 不為 null)、IDGenerator 還沒實例化(當前 instance 為 null)
    • 如果 instance 為 null,則進行一次 synchronized 同步初始化操作,實例化對象
    • 如果 instance 不為 null,則直接返回,則不進行 synchronized 同步初始化操作
      這樣就把懶漢式每次調用都會進行同步初始化操作的問題解決掉了

第一層 null 檢測知道是幹什麼用的,那第 2 層 null 判斷是幹嘛用的呢?去掉行行不行呢,試一下。我們將代碼改為下面的

public static IDGenerator getInstance() {
    if (instance == null) {
        printWithTreadName("進入第一層 null 判斷");
        synchronized (IDGenerator.class) {
            printWithTreadName("進入 synchronized 代碼塊");
            instance = new IDGenerator();
        }
    }
    return instance;
}

輸出結果

TreadName: DemoTread-2, 進入第一層 null 判斷
TreadName: DemoTread-1, 進入第一層 null 判斷
  
TreadName: DemoTread-2, 進入 synchronized 代碼塊
TreadName: DemoTread-2, 當前時間:2023-07-26 00:48:24
TreadName: DemoTread-2, IDGenerator init
TreadName: DemoTread-2, id hashcode: 26262847
TreadName: DemoTread-2, id value: 0
TreadName: DemoTread-1, 進入 synchronized 代碼塊
TreadName: DemoTread-1, 當前時間:2023-07-26 00:48:24
TreadName: DemoTread-1, IDGenerator init
TreadName: DemoTread-1, id hashcode: 1846522864
TreadName: DemoTread-1, id value: 0
TreadName: DemoTread-1, generator id: 1

出現異常現象了,去掉第 2 層判斷之後對象初始化了 2 次。

  • 可以看出 DemoTread-2 、DemoTread-1 線程幾乎同時通過第一層 null 檢測
  • 然後 DemoTread-2 線程拿到了鎖進行了一次對象的實例化
  • 那這個時候 DemoTread-1 線程在幹嘛呢,它在等待 DemoTread-2 釋放鎖,等 DemoTread-2 創建新的實例後,它會釋放鎖
  • 接下來,線程 DemoTread-1 也會獲取到鎖進行實例的創建!

所以,第二次檢查if (instance == null)是為了在當前線程獲取到鎖之後,再次確認 instance 是否仍然為 null。如果不為 null,則直接返回這個實例,避免了創建多個實例的情況。

好了,到這裡是不是以為這個就是完美的單例模式實現了?
還不是,上面的代碼還有點問題。需要給靜態變量 instance 加上個 volatile 關鍵字,加這個有什麼用呢?

private static volatile IDGenerator instance;

保證這個靜態變量的可見性禁止指令重排序

  • 可見性 : volatile 關鍵字保證了一個線程寫入的值,其他線程會立馬看到,防止生成多次實例對象
  • 禁止指令重排序:JVM 在優化代碼時,可能將對象的初始化和實例的引用賦值這兩個操作進行重排序,導致其他線程在讀取 instance 時,看到的是一個已經非空對象但還沒有完成初始化(執行構造函數中的其餘代碼邏輯),如果其餘線程直接使用就會是使用不完整的對象,所以要加上 volatile 關鍵字來禁止指令重排序

靜態內部類#

雙重檢測的方法是能解決問題,但是基於加同步操作,又要加判斷,有點複雜,有沒有代碼實現起來簡單一點的呢?有,使用靜態內部類
具體實現的方式是:在需要實現單例的類中額外定義另一個靜態內部類

public class IDGenerator {

    private AtomicLong id = new AtomicLong(0);

    private static class SingletonHolder {
        private static final IDGenerator INSTANCE = new IDGenerator();
    }

    private IDGenerator() {
        printWithTreadName("IDGenerator init");
        printWithTreadName("id hashcode: " + System.identityHashCode(id));
        printWithTreadName("id value: " + id.get());
    }

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

    public long getId() {
        return id.incrementAndGet();
    }
}

這又是怎麼保證實現單例的呢?

  • 基於靜態內部類的特性,只會被加載一次,這能保證 INSTANCE 只會被初始化一次
  • 其次也保證了懶加載,因為只有在調用 getInstance() 方法的時候才會被加載
    那會不會有線程安全的問題呢,不會,因為靜態內部類的加載由 JVM 實現的,所以也是線程安全的

使用靜態內部類的方式實現單例,既高效、避免了不必要的線程同步操作,又實現了懶加載,還保證了線程安全,完美!
唯一美中不足的是它需要多定義一個類,那還有沒有更簡單的呢?有的,枚舉!


枚舉#

直接看代碼

public enum IdGenerator {
  INSTANCE;
  private AtomicLong id = new AtomicLong(0);
 
  public long getId() { 
    return id.incrementAndGet();
  }
}

你看,是不是代碼很簡單,為什麼說枚舉可以實現單例呢
因為 Java 在處理枚舉類型的時候採用了類加載的機制來保證枚舉類的唯一和線程安全

  • 線程安全:Java 的類加載過程中,在加載一個類的時候,Java 虛擬機會對這個類加鎖,防止其他線程同時加載,只有在類加載完成之後,這個鎖才會被釋放,保證了線程安全
  • 唯一:枚舉類型的實例是在枚舉類型加載的時候一次性全部創建的,後續不會再改變,保證了唯一性

但是這個實現看起來簡單,它也有不完美的地方,就是它不支持懶加載,因為它都是在類加載階段把所有的類都實例化完了,無法做到 "隨叫隨到"。

所以,有這麼多種實現方式,應該使用哪一種最好呢?
沒有最好的,最有最適合的,不同場景具體分析


參考資料#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。