深入理解單例設計模式
一、概述
===========
單例模式是面試中經常會被問到的一個問題,網上有大量的文章介紹單例模式的實現,本文也是參考那些優秀的文章來做一個總結,通過自己在學習過程中的理解進行記錄,並補充完善一些內容,一方面鞏固自己所學的內容,另一方面希望能對其他同學提供一些幫助。
本文主要從以下幾個方面介紹單例模式:
-
單例模式是什麼
-
單例模式的使用場景
-
單例模式的優缺點
-
單例模式的實現(重點)
-
總結
二、單例模式是什麼
23 種設計模式可以分爲三大類:創建型模式、行爲型模式、結構型模式。單例模式屬於創建型模式的一種,單例模式是最簡單的設計模式之一:單例模式只涉及一個類,確保在系統中一個類只有一個實例,並提供一個全局訪問入口。許多時候整個系統只需要擁有一個全局對象,這樣有利於我們協調系統整體的行爲。
三、單例模式的使用場景
1、 日誌類
日誌類通常作爲單例實現,並在所有應用程序組件中提供全局日誌訪問點,而無需在每次執行日誌操作時創建對象。
2、 配置類
將配置類設計爲單例實現,比如在某個服務器程序中,該服務器的配置信息存放在一個文件中,這些配置數據由一個單例對象統一讀取,然後服務進程中的其他對象再通過這個單例對象獲取這些配置信息,這種方式簡化了在複雜環境下的配置管理。
3、工廠類
假設我們設計了一個帶有工廠的應用程序,以在多線程環境中生成帶有 ID 的新對象(Acount、Customer、Site、Address 對象)。如果工廠在 2 個不同的線程中被實例化兩次,那麼 2 個不同的對象可能有 2 個重疊的 id。如果我們將工廠實現爲單例,我們就可以避免這個問題,結合抽象工廠或工廠方法和單例設計模式是一種常見的做法。
4、以共享模式訪問資源的類
比如網站的計數器,一般也是採用單例模式實現,如果你存在多個計數器,每一個用戶的訪問都刷新計數器的值,這樣的話你的實計數的值是難以同步的。但是如果採用單例模式實現就不會存在這樣的問題,而且還可以避免線程安全問題。
5、在 Spring 中創建的 Bean 實例默認都是單例模式存在的。
適用場景:
-
需要生成唯一序列的環境
-
需要頻繁實例化然後銷燬的對象。
-
創建對象時耗時過多或者耗資源過多,但又經常用到的對象。
-
方便資源相互通信的環境
四、單例模式的優缺點
優點:
-
在內存中只有一個對象,節省內存空間;
-
避免頻繁的創建銷燬對象,減輕 GC 工作,同時可以提高性能;
-
避免對共享資源的多重佔用,簡化訪問;
-
爲整個系統提供一個全局訪問點。
缺點:
-
不適用於變化頻繁的對象;
-
濫用單例將帶來一些負面問題,如爲了節省資源將數據庫連接池對象設計爲的單例類,可能會導致共享連接池對象的程序過多而出現連接池溢出;
-
如果實例化的對象長時間不被利用,系統會認爲該對象是垃圾而被回收,這可能會導致對象狀態的丟失;
五、單例模式的實現(重點)
實現單例模式的步驟如下:
-
私有化構造方法,避免外部類通過 new 創建對象
-
定義一個私有的靜態變量持有自己的類型
-
對外提供一個靜態的公共方法來獲取實例
-
如果實現了序列化接口需要保證反序列化不會重新創建對象
1、餓漢式,線程安全
餓漢式單例模式,顧名思義,類一加載就創建對象,這種方式比較常用,但容易產生垃圾對象,浪費內存空間。
優點:線程安全,沒有加鎖,執行效率較高
缺點:不是懶加載,類加載時就初始化,浪費內存空間
懶加載 (lazy loading):使用的時候再創建對象
餓漢式單例是如何保證線程安全的呢?它是基於類加載機制避免了多線程的同步問題,但是如果類被不同的類加載器加載就會創建不同的實例。
代碼實現,以及使用反射破壞單例:
/**
* 餓漢式單例測試
*
* @className: Singleton
* @date: 2021/6/7 14:32
*/
public class Singleton {
// 1、私有化構造方法
private Singleton(){}
// 2、定義一個靜態變量指向自己類型
private final static Singleton instance = new Singleton();
// 3、對外提供一個公共的方法獲取實例
public static Singleton getInstance() {
return instance;
}
}
使用反射破壞單例,代碼如下:
public class Test {
public static void main(String[] args) throws Exception{
// 使用反射破壞單例
// 獲取空參構造方法
Constructor<Singleton> declaredConstructor = Singleton.class.getDeclaredConstructor(null);
// 設置強制訪問
declaredConstructor.setAccessible(true);
// 創建實例
Singleton singleton = declaredConstructor.newInstance();
System.out.println("反射創建的實例" + singleton);
System.out.println("正常創建的實例" + Singleton.getInstance());
System.out.println("正常創建的實例" + Singleton.getInstance());
}
}
輸出結果如下:
反射創建的實例com.example.spring.demo.single.Singleton@6267c3bb
正常創建的實例com.example.spring.demo.single.Singleton@533ddba
正常創建的實例com.example.spring.demo.single.Singleton@533ddba
2、懶漢式,線程不安全
這種方式在單線程下使用沒有問題,對於多線程是無法保證單例的,這裏列出來是爲了和後面使用鎖保證線程安全的單例做對比。
優點:懶加載
缺點:線程不安全
代碼實現如下:
/**
* 懶漢式單例,線程不安全
*
* @className: Singleton
* @date: 2021/6/7 14:32
*/
public class Singleton {
// 1、私有化構造方法
private Singleton(){ }
// 2、定義一個靜態變量指向自己類型
private static Singleton instance;
// 3、對外提供一個公共的方法獲取實例
public static Singleton getInstance() {
// 判斷爲 null 的時候再創建對象
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
使用多線程破壞單例,測試代碼如下:
public class Test {
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(() -> {
System.out.println("多線程創建的單例:" + Singleton.getInstance());
}).start();
}
}
}
輸出結果如下:
多線程創建的單例:com.example.spring.demo.single.Singleton@18396bd5
多線程創建的單例:com.example.spring.demo.single.Singleton@7f23db98
多線程創建的單例:com.example.spring.demo.single.Singleton@5000d44
3、懶漢式,線程安全
懶漢式單例如何保證線程安全呢?通過 synchronized
關鍵字加鎖保證線程安全,synchronized
可以添加在方法上面,也可以添加在代碼塊上面,這裏演示添加在方法上面,存在的問題是每一次調用 getInstance
獲取實例時都需要加鎖和釋放鎖,這樣是非常影響性能的。
優點:懶加載,線程安全
缺點:效率較低
代碼實現如下:
/**
* 懶漢式單例,方法上面添加 synchronized 保證線程安全
*
* @className: Singleton
* @date: 2021/6/7 14:32
*/
public class Singleton {
// 1、私有化構造方法
private Singleton(){ }
// 2、定義一個靜態變量指向自己類型
private static Singleton instance;
// 3、對外提供一個公共的方法獲取實例
public synchronized static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
4、雙重檢查鎖(DCL, 即 double-checked locking)
實現代碼如下:
/**
* 雙重檢查鎖(DCL, 即 double-checked locking)
*
* @className: Singleton
* @date: 2021/6/7 14:32
*/
public class Singleton {
// 1、私有化構造方法
private Singleton() {
}
// 2、定義一個靜態變量指向自己類型
private volatile static Singleton instance;
// 3、對外提供一個公共的方法獲取實例
public synchronized static Singleton getInstance() {
// 第一重檢查是否爲 null
if (instance == null) {
// 使用 synchronized 加鎖
synchronized (Singleton.class) {
// 第二重檢查是否爲 null
if (instance == null) {
// new 關鍵字創建對象不是原子操作
instance = new Singleton();
}
}
}
return instance;
}
}
優點:懶加載,線程安全,效率較高
缺點:實現較複雜
這裏的雙重檢查是指兩次非空判斷,鎖指的是 synchronized 加鎖,爲什麼要進行雙重判斷,其實很簡單,第一重判斷,如果實例已經存在,那麼就不再需要進行同步操作,而是直接返回這個實例,如果沒有創建,纔會進入同步塊,同步塊的目的與之前相同,目的是爲了防止有多個線程同時調用時,導致生成多個實例,有了同步塊,每次只能有一個線程調用訪問同步塊內容,當第一個搶到鎖的調用獲取了實例之後,這個實例就會被創建,之後的所有調用都不會進入同步塊,直接在第一重判斷就返回了單例。
關於內部的第二重空判斷的作用,當多個線程一起到達鎖位置時,進行鎖競爭,其中一個線程獲取鎖,如果是第一次進入則爲 null,會進行單例對象的創建,完成後釋放鎖,其他線程獲取鎖後就會被空判斷攔截,直接返回已創建的單例對象。
其中最關鍵的一個點就是 volatile
關鍵字的使用,關於 volatile
的詳細介紹可以直接搜索 volatile 關鍵字即可,有很多寫的非常好的文章,這裏不做詳細介紹,簡單說明一下,雙重檢查鎖中使用 volatile
的兩個重要特性:可見性、禁止指令重排序
這裏爲什麼要使用 volatile
?
這是因爲 new
關鍵字創建對象不是原子操作,創建一個對象會經歷下面的步驟:
-
在堆內存開闢內存空間
-
調用構造方法,初始化對象
-
引用變量指向堆內存空間
對應字節碼指令如下:
爲了提高性能,編譯器和處理器常常會對既定的代碼執行順序進行指令重排序,從源碼到最終執行指令會經歷如下流程:
最終執行指令序列
所以經過指令重排序之後,創建對象的執行順序可能爲 1 2 3
或者 1 3 2
,因此當某個線程在亂序運行 1 3 2
指令的時候,引用變量指向堆內存空間,這個對象不爲 null,但是沒有初始化,其他線程有可能這個時候進入了 getInstance
的第一個 if(instance == null)
判斷不爲 nulll ,導致錯誤使用了沒有初始化的非 null 實例,這樣的話就會出現異常,這個就是著名的 DCL 失效問題。
當我們在引用變量上面添加 volatile
關鍵字以後,會通過在創建對象指令的前後添加內存屏障來禁止指令重排序,就可以避免這個問題,而且對 volatile
修飾的變量的修改對其他任何線程都是可見的。
5、靜態內部類
代碼實現如下:
/**
* 靜態內部類實現單例
*
* @className: Singleton
* @date: 2021/6/7 14:32
*/
public class Singleton {
// 1、私有化構造方法
private Singleton() {
}
// 2、對外提供獲取實例的公共方法
public static Singleton getInstance() {
return InnerClass.INSTANCE;
}
// 定義靜態內部類
private static class InnerClass{
private final static Singleton INSTANCE = new Singleton();
}
}
優點:懶加載,線程安全,效率較高,實現簡單
靜態內部類單例是如何實現懶加載的呢?首先,我們先了解下類的加載時機。
虛擬機規範要求有且只有 5 種情況必須立即對類進行初始化(加載、驗證、準備需要在此之前開始):
-
遇到
new
、getstatic
、putstatic
、invokestatic
這 4 條字節碼指令時。生成這 4 條指令最常見的 Java 代碼場景是:使用new
關鍵字實例化對象的時候、讀取或設置一個類的靜態字段(final 修飾除外,被 final 修飾的靜態字段是常量,已在編譯期把結果放入常量池)的時候,以及調用一個類的靜態方法的時候。 -
使用
java.lang.reflect
包方法對類進行反射調用的時候。 -
當初始化一個類的時候,如果發現其父類還沒有進行過初始化,則需要先觸發其父類的初始化。
-
當虛擬機啓動時,用戶需要指定一個要執行的主類(包含 main() 的那個類),虛擬機會先初始化這個主類。
-
當使用 JDK 1.7 的動態語言支持時,如果一個
java.lang.invoke.MethodHandle
實例最後的解析結果是REF_getStatic
、REF_putStatic
、REF_invokeStatic
的方法句柄,則需要先觸發這個方法句柄所對應的類的初始化。
這 5 種情況被稱爲是類的主動引用,注意,這裏《虛擬機規範》中使用的限定詞是 " 有且僅有 ",那麼,除此之外的所有引用類都不會對類進行初始化,稱爲被動引用。靜態內部類就屬於被動引用的情況。
當 getInstance() 方法被調用時,InnerClass 纔在 Singleton 的運行時常量池裏,把符號引用替換爲直接引用,這時靜態對象 INSTANCE 也真正被創建,然後再被 getInstance() 方法返回出去,這點同餓漢模式。
那麼 INSTANCE
在創建過程中又是如何保證線程安全的呢?在《深入理解 JAVA 虛擬機》中,有這麼一句話:
虛擬機會保證一個類的 <clinit>()
方法在多線程環境中被正確地加鎖、同步,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的 <clinit>()
方法,其他線程都需要阻塞等待,直到活動線程執行 <clinit>()
方法完畢。如果在一個類的 <clinit>()
方法中有耗時很長的操作,就可能造成多個進程阻塞 (需要注意的是,其他線程雖然會被阻塞,但如果執行<clinit>()
方法後,其他線程喚醒之後不會再次進入<clinit>()
方法。同一個加載器下,一個類型只會初始化一次。),在實際應用中,這種阻塞往往是很隱蔽的。
從上面的分析可以看出 INSTANCE 在創建過程中是線程安全的,所以說靜態內部類形式的單例可保證線程安全,也能保證單例的唯一性,同時也延遲了單例的實例化。
6、枚舉單例
代碼實現如下:
/**
* 枚舉實現單例
*
* @className: Singleton
* @date: 2021/6/7 14:32
*/
public enum Singleton {
INSTANCE;
public void doSomething(String str) {
System.out.println(str);
}
}
優點:簡單,高效,線程安全,可以避免通過反射破壞枚舉單例
枚舉在 java 中與普通類一樣,都能擁有字段與方法,而且枚舉實例創建是線程安全的,在任何情況下,它都是一個單例,可以直接通過如下方式調用獲取實例:
Singleton singleton = Singleton.INSTANCE;
使用下面的命令反編譯枚舉類
javap Singleton.class
得到如下內容
Compiled from "Singleton.java"
public final class com.spring.demo.singleton.Singleton extends java.lang.Enum<com.spring.demo.singleton.Singleton> {
public static final com.spring.demo.singleton.Singleton INSTANCE;
public static com.spring.demo.singleton.Singleton[] values();
public static com.spring.demo.singleton.Singleton valueOf(java.lang.String);
public void doSomething(java.lang.String);
static {};
}
從枚舉的反編譯結果可以看到,INSTANCE 被 static final
修飾,所以可以通過類名直接調用,並且創建對象的實例是在靜態代碼塊中創建的,因爲 static 類型的屬性會在類被加載之後被初始化,當一個 Java 類第一次被真正使用到的時候靜態資源被初始化、Java 類的加載和初始化過程都是線程安全的,所以創建一個 enum 類型是線程安全的。
通過反射破壞枚舉,實現代碼如下:
public class Test {
public static void main(String[] args) throws Exception {
Singleton singleton = Singleton.INSTANCE;
singleton.doSomething("hello enum");
// 嘗試使用反射破壞單例
// 枚舉類沒有空參構造方法,反編譯後可以看到枚舉有一個兩個參數的構造方法
Constructor<Singleton> declaredConstructor = Singleton.class.getDeclaredConstructor(String.class, int.class);
// 設置強制訪問
declaredConstructor.setAccessible(true);
// 創建實例,這裏會報錯,因爲無法通過反射創建枚舉的實例
Singleton enumSingleton = declaredConstructor.newInstance();
System.out.println(enumSingleton);
}
}
運行結果報如下錯誤:
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:492)
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)
at com.spring.demo.singleton.Test.main(Test.java:24)
查看反射創建實例的 newInstance()
方法,有如下判斷:
所以無法通過反射創建枚舉的實例。
六、總結
在 java 中,如果一個 Singleton 類實現了 java.io.Serializable 接口,當這個 singleton 被多次序列化然後反序列化時,就會創建多個 Singleton 類的實例。爲了避免這種情況,應該實現 readResolve 方法。請參閱 javadocs 中的 Serializable () 和 readResolve Method () 。
public class Singleton implements Serializable {
// 1、私有化構造方法
private Singleton() {
}
// 2、對外提供獲取實例的公共方法
public static Singleton getInstance() {
return InnerClass.instance;
}
// 定義靜態內部類
private static class InnerClass{
private final static Singleton instance = new Singleton();
}
// 對象被反序列化之後,這個方法立即被調用,我們重寫這個方法返回單例對象.
protected Object readResolve() {
return getInstance();
}
}
使用單例設計模式需要注意的點:
-
多線程 - 在多線程應用程序中必須使用單例時,應特別小心。
-
序列化 - 當單例實現 Serializable 接口時,他們必須實現 readResolve 方法以避免有 2 個不同的對象。
-
類加載器 - 如果 Singleton 類由 2 個不同的類加載器加載,我們將有 2 個不同的類,每個類加載一個。
-
由類名錶示的全局訪問點 - 使用類名獲取單例實例。這是一種訪問它的簡單方法,但它不是很靈活。如果我們需要替換 Sigleton 類,代碼中的所有引用都應該相應地改變。
本文簡單介紹了單例設計模式的幾種實現方式,除了枚舉單例,其他的所有實現都可以通過反射破壞單例模式,在《effective java》中推薦枚舉實現單例模式,在實際場景中使用哪一種單例實現,需要根據自己的情況選擇,適合當前場景的纔是比較好的方式。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/_MLibEqz0HzmTcnC_b2gmA