01 設計模式之單例模式

陳皮的JavaLib發表於2021-08-01

1 場景分析

平常開發中,呼叫其他系統的介面是很常見的,呼叫一般需要用到一些配置資訊,而這些配置資訊一般在配置檔案中,程式啟動時讀取到記憶體中使用。

例如有如下配置檔案。

# 檔名 ThirdApp.properties
appId=188210
secret=MIVD587A12FE7E

程式直接讀取配置檔案,解析將配置資訊儲存在一個物件中,呼叫其他系統介面時使用這個物件即可。

package com.chenpi.singleton;

import java.io.IOException;
import java.io.InputStream;
import java.util.Properties;

/**
 * @Description 第三方系統相關配置資訊
 * @Author 陳皮
 * @Date 2021/5/16
 * @Version 1.0
 */
public class ThirdConfig {

  private String appId;
  private String secret;

  public ThirdConfig() {
    // 初始化
    init();
  }

  /**
   * 讀取配置檔案,進行初始化
   */
  private void init() {
    Properties p = new Properties();
    InputStream in = ThirdConfig.class.getResourceAsStream("ThirdApp.properties");
    try {
      p.load(in);
      this.appId = p.getProperty("appId");
      this.secret = p.getProperty("secret");
    } catch (IOException e) {
      e.printStackTrace();
    } finally {
      try {
        if (null != in) {
          in.close();
        }
      } catch (IOException e) {
        e.printStackTrace();
      }
    }
  }

  public String getAppId() {
    return appId;
  }

  public String getSecret() {
    return secret;
  }
}

使用的時候,直接建立物件,進行使用。

package com.chenpi.singleton;

/**
 * @Description
 * @Author 陳皮
 * @Date 2021/5/16
 * @Version 1.0
 */
public class ChenPiMain {

  public static void main(String[] args) {
    ThirdConfig thirdConfig = new ThirdConfig();
    System.out.println("appId=" + thirdConfig.getAppId());
    System.out.println("secret=" + thirdConfig.getSecret());
  }
}

// 輸出結果如下
appId=1007
secret=陳皮的JavaLib

通過以上分析,需要使用配置資訊時,只要獲取 ThirdConfig 類的例項即可。但是如果專案是多人協作的,其他人使用這個配置資訊的時候選擇每次都去 new 一個新的例項,這樣就會導致每次都會生成新的例項,並且重複讀取配置檔案的資訊,多次 IO 操作,系統中存在多個 AppConfig 例項物件,嚴重浪費記憶體資源,如果配置檔案內容很多的話,浪費系統資源更加嚴重。

針對以上問題,因為配置資訊是不變的,共享的,所以我們只要保證系統執行期間,只有一個類例項存在就可以了。單例模式就用來解決類似這種問題的。

2 單例模式(Singleton)

在系統執行期間,一個類僅有一個例項,並提供一個對外訪問這個例項的全域性訪問點。

將類的構造方法私有化,只能類自身來負責建立例項,並且只能建立一個例項,然後提供一個對外訪問這個例項的靜態方法,這就是單例模式的實現方式。

在 Java 中,單例模式的實現一般分為兩種,懶漢式和餓漢式,它們之間主要的區別是在建立例項的時機上,一種是提前建立例項,一種是使用時才建立例項。

2.1 餓漢式

餓漢式、顧名思義,很飢餓很著急,所以在類載入器裝載類的時候就建立例項,由 JVM 保證執行緒安全,只建立一次,餓漢式實現示例程式碼如下:

package com.chenpi.singleton;

/**
 * @Description 餓漢式單例模式
 * @Author 陳皮
 * @Date 2021/5/16
 * @Version 1.0
 */
public class HungerSingleton {

  /**
   * 提前例項化,由JVM保證例項化一次 使用static關鍵字修飾,使它在類載入器裝載後,初始化此變數,並且能讓靜態方法getInstance使用
   */
  private static final HungerSingleton INSTANCE = new HungerSingleton();

  /**
   * 私有化構造方法,只能內部呼叫,外部呼叫不了則避免了多次例項化的問題
   */
  private HungerSingleton() {}

  /**
   * 外部訪問這個類例項的方法,使用static關鍵字修飾,則外部可以直接通過類來呼叫這個方法
   *
   * @return HungerSingleton 例項
   */
  public static HungerSingleton getInstance() {
    return INSTANCE;
  }
}

2.2 懶漢式

懶漢式、顧名思義,既然懶就不著急,即等到要使用物件例項的時候才建立例項,更準確的說是延遲載入。懶漢式實現示例程式碼如下:

package com.chenpi.singleton;

/**
 * @Description 懶漢式單例模式
 * @Author 陳皮
 * @Date 2021/5/16
 * @Version 1.0
 */
public class LazySingleton {

  /**
   * 儲存建立好的類例項,賦值null,使用時才建立賦值 因為靜態方法getInstance使用了此變數,所以使用static關鍵字修飾
   */
  private static LazySingleton instance = null;

  /**
   * 私有化構造方法,只能內部呼叫,外部呼叫不了則避免了多次例項化的問題
   */
  private LazySingleton() {}

  /**
   * 外部訪問這個類例項的方法,使用static關鍵字修飾,則外部可以直接通過類來呼叫這個方法
   *
   * @return LazySingleton 例項
   */
  public static LazySingleton getInstance() {
    // 判斷例項是否存在
    if (instance == null) {
        instance = new LazySingleton();
    }
    // 如果已建立過,則直接使用
    return instance;
  }
}

3 優缺點比較

餓漢式,當類裝載的時候就建立類例項,不管用不用,後續使用的時候直接獲取即可,不需要判斷是否已經建立,節省時間,典型的空間換時間。

懶漢式,等到使用的時候才建立類例項,每次獲取例項都要進行判斷是否已經建立,浪費時間,但是如果未使用前,則能達到節約記憶體空間的效果,典型的時間換空間。

從執行緒安全性上講, 餓漢式是類載入器載入類到 JVM 記憶體中後,就例項化一個這個類的例項,由 JVM 保證了執行緒安全。而不加同步的懶漢式是執行緒不安全的,在併發的情況下可能會建立多個例項。

如果有兩個執行緒 A 和 B,它們同時呼叫 getInstance 方法時,可能導致併發問題,如下:

public static LazySingleton getInstance() {
    // 判斷物件例項是否已被建立
    if (instance == null) {
        // 執行緒A執行到這裡了,正準備建立例項,或者例項還未建立完,
        // 此時執行緒B判斷instance還是為null,則執行緒B也進入此,
        // 最終執行緒A和B都會建立自己的例項,從而出現了多例項
        instance = new LazySingleton();
    }
    // 如果已建立過,則直接使用
    return instance;
}

所以我們一般推薦餓漢式單例模式,因為由 JVM 例項化,保證了執行緒安全,實現簡單。而且這個例項總會用到的時候,提前例項化準備好也未嘗不可。

4 高階實現

4.1 雙重檢查加鎖

前面說到,懶漢式單例模式在併發情況下可能會出現執行緒安全問題,那我們可以通過加鎖,保證只能一個執行緒去建立例項即可,只要加上 synchronized 即可,如下所示:

public static synchronized LazySingleton getInstance() {
    // 判斷物件例項是否已被建立
    if (instance == null) {
        // 第一次使用,沒用被建立,則先建立物件,並且儲存在類變數中
        instance = new LazySingleton();
    }
    // 如果已建立過,則直接使用
    return instance;
}

如果對整個方法加鎖,會降低訪問效能,即每次都要獲取鎖,才能進入執行方法。可以使用雙重檢查加鎖的方式來實現,就可以既實現執行緒安全,又能夠使效能不受到很大的影響。

雙重檢查加鎖機制:每次進入方法不需要同步,進入方法後,先檢查例項是否存在,如果不存在才進入加鎖的同步塊,這是第一重檢查。進入同步塊後,再次檢查例項是否存在,如果不存在,就在同步的情況下建立一個例項,這是第二重檢查。

使用雙重檢查加鎖機制時,需要藉助 volatile 關鍵字,被它修飾的變數,變數的值就不會被本地執行緒快取,所有對該變數的讀寫都是直接操作共享記憶體,所以能確保多個執行緒能正確的處理該變數。

package com.chenpi.singleton;

/**
 * @Description 懶漢式單例模式
 * @Author 陳皮
 * @Date 2021/5/16
 * @Version 1.0
 */
public class LazySingleton {

  /**
   * 儲存建立好的類例項,賦值null,使用時才建立賦值 因為靜態方法getInstance使用了此變數,所以使用static關鍵字修飾
   */
  private volatile static LazySingleton instance = null;

  /**
   * 私有化構造方法,只能內部呼叫,外部呼叫不了則避免了多次例項化的問題
   */
  private LazySingleton() {
  }

  /**
   * 外部訪問這個類例項的方法,使用static關鍵字修飾,則外部可以直接通過類來呼叫這個方法
   *
   * @return LazySingleton 例項
   */
  public static synchronized LazySingleton getInstance() {
    // 第一重檢查,判斷例項是否存在,如果不存在則進入同步塊
    if (instance == null) {
      // 同步塊,能保證同時只能有一個執行緒訪問
      synchronized (LazySingleton.class) {
        // 第二重檢查,保證只建立一次物件例項
        if (instance == null) {
          instance = new LazySingleton();
        }
      }
    }
    // 如果已建立過,則直接使用
    return instance;
  }
}

4.2 靜態內部類實現

有一種方式,既有餓漢式的優點(執行緒安全),又有懶漢式的優點(延遲載入),那就是使用靜態內部類。

何為靜態內部類呢?即在類中定義的並且使用 static 修飾的類。可以認為靜態內部類是外部類的靜態成員,靜態內部類物件與外部類物件間不存在依賴關係,因此可直接建立。

靜態內部類中的靜態方法可以使用外部類中的靜態方法和靜態變數;而且靜態內部類只有在第一次被使用的時候才會被裝載,達到了延遲載入的效果。然後我們在靜態內部類中定義一個靜態的外部類的物件,並進行初始化,由 JVM 保證執行緒安全,進行建立。

package com.chenpi.singleton;

/**
 * @Description 靜態內部類實現單例模式
 * @Author Mr.nobody
 * @Date 2021/5/16
 * @Version 1.0
 */
public class StaticInnerClassSingleton {

  /**
   * 靜態內部類,外部內未使用內部類時,類載入器不會載入內部類
   */
  private static class SingletonHolder {

    /**
     * 靜態初始化,由JVM保證執行緒安全
     */
    private static final StaticInnerClassSingleton INSTANCE = new StaticInnerClassSingleton();
  }

  /**
   * 私有化構造方法
   */
  private StaticInnerClassSingleton() {
  }

  /**
   * 外部訪問這個類例項的方法,使用static關鍵字修飾,則外部可以直接通過類來呼叫這個方法
   *
   * @return StaticInnerClassSingleton 例項
   */
  public static StaticInnerClassSingleton getInstance() {
    return SingletonHolder.INSTANCE;
  }
}

4.3 列舉實現

還有一種單例模式的最佳實現,就是藉助列舉。實現簡潔,高效安全。沒有構造方法,還能防止反序列化,並由 JVM 保障單例。例項程式碼如下:

package com.chenpi.singleton;

/**
 * @Description 列舉實現單例模式,沒有構造方法,能防止反序列化
 * @Author 陳皮
 * @Date 2021/5/16
 * @Version 1.0
 */
public enum EnumSingleton {
  /**
   * 定義一個列舉的元素,代表要實現類的一個例項
   */
  INSTANCE;

  // 可以定義方法
  public void test() {
    System.out.println("Hello ChenPi!");
  }
}

如果要使用,直接使用即可,如下:

package com.chenpi.singleton;

/**
 * @Description
 * @Author 陳皮
 * @Date 2021/5/16
 * @Version 1.0
 */
public class ChenPiMain {

  public static void main(String[] args) {
    EnumSingleton instance = EnumSingleton.INSTANCE;
    instance.test();
  }
}

// 輸出結果
Hello ChenPi!

5 指定數量例項模式

單例模式,只有一個類例項。如果要求指定數量的類例項,例如指定2個或者3個,或者任意多個?

其實萬變不離其宗,單例模式是隻建立一個類例項,並且儲存下來。那指定數量多例模式,就建立指定的數量類例項,也儲存下來,使用時根據策略(例如輪詢)取指定的例項即可。以下演示指定5個例項的情況:

package com.chenpi.singleton;

import java.util.HashMap;
import java.util.Map;

/**
 * @Description 指定數量的多例項模式
 * @Author 陳皮
 * @Date 2021/5/16
 * @Version 1.0
 */
public class ExtendSingleton {

  /**
   * 指定的例項數
   */
  private final static int INSTANCE_NUM = 5;
  /**
   * key字首
   */
  private final static String PREFIX_KEY = "instance_";
  /**
   * 快取例項的容器
   */
  private static Map<String, ExtendSingleton> map = new HashMap<>();
  /**
   * 記錄當前正在使用第幾個例項
   */
  private static int num = 1;

  /**
   * 私有化構造方法,只能內部呼叫,外部呼叫不了則避免了多次例項化的問題
   */
  private ExtendSingleton() {
  }

  /**
   * 外部訪問這個類例項的方法,使用static關鍵字修飾,則外部可以直接通過類來呼叫這個方法
   *
   * @return ExtendSingleton 例項
   */
  public static ExtendSingleton getInstance() {
    // 快取key
    String key = PREFIX_KEY + num;
    // 優先從快取中取
    ExtendSingleton extendSingleton = map.get(key);
    // 如果指定key的例項不存在,則建立,並放入快取容器中
    if (extendSingleton == null) {
      extendSingleton = new ExtendSingleton();
      map.put(key, extendSingleton);
    }
    // 當前例項的序號加1
    num++;
    if (num > INSTANCE_NUM) {
      // 例項的序號達到最大值重新從1開始
      num = 1;
    }
    return extendSingleton;
  }
}

本次分享到此結束啦~~

如果覺得文章對你有幫助,點贊、收藏、關注、評論,您的支援就是我創作最大的動力!

我是陳皮,一個在網際網路 Coding 的 ITer,微信搜尋「陳皮的JavaLib」第一時間閱讀最新文章。

相關文章