1 為什么要用單例模式
1.1 什么是單例模式
單例模式就是: 在程序運行期間, 某些類有且最多只有一個實例對象.
我們的應用中可能存在這樣的需求: 某些類沒有自己的狀態, 在程序運行期間它們只需要有一個實例, 換句話說, 無論為這些類創建多少個實例, 對程序的運行狀態、運行結果都不會產生影響.
更重要的一點是: 有些類如果存在兩個或者兩個以上的實例, 應用程序就會發生某些匪夷所思的錯誤, 不同於空指針、數組越界、非法參數等錯誤, 這樣的問題一般都很難提前發覺和定位.
這個時候, 我們就應該把這樣的類控制為單例結構 —— 確保程序運行期間最多只有一個相對應的實例對象.
關於類的狀態的理解:
① 比如有一個 Person 類, 它有成員變量name、age等等, 不同的姓名和年齡就是不同的人, 也就是說這些變量都是不確定的, 這樣的類就是有狀態的類.
② 而像一些配置類, 比如 RedisProps (Redis的配置信息)類, 它的所有屬性和方法都是static的, 沒有不確定的屬性, 這樣的類就可以認為是沒有狀態的類.
—— 純屬個人看法, 若理解有誤, 還請讀者朋友們提出, 歡迎批評和交流😁
1.2 單例模式的思路和優勢
(1) 單例模式的實現思路是:
① 靜態化實例對象, 讓實例對象與Class對象互相綁定, 通過Class類對象就可以直接訪問;
② 私有化構造方法, 禁止通過構造方法創建多個實例 —— 最重要的一步;
③ 提供一個公共的靜態方法, 用來返回這個類的唯一實例.
(2) 單例模式的優勢:
單例模式的好處是: 盡可能節約內存空間(不用為一個類創建多個實例對象), 減少GC(垃圾回收)的消耗, 並使得程序正常運行.
接下來就詳細描述單例模式的6種不同寫法.
2 寫法① - 飢餓模式
2.1 代碼示例
飢餓模式又稱為餓漢模式, 指的是JVM在加載類的時候就完成類對象的創建:
/**
* 飢餓模式: 類加載時就初始化
*/
final class HungrySingleton {
/** 實例對象 */
private static HungrySingleton instance = new HungrySingleton();
/** 禁用構造方法 */
private HungrySingleton() { }
/**
* 獲取單例對象, 直接返回已創建的實例
* @return instance 本類的實例
*/
public static HungrySingleton getInstance() {
return instance;
}
}
2.2 優缺點比較
(1) 優點: JVM層面的線程安全.
JVM在加載這個類的時候就會對它進行初始化, 這里包含對靜態變量的初始化;
Java的語義包證了在引用這個字段之前並不會初始化它, 並且訪問這個字段的任何線程都將看到初始化這個字段所產生的所有寫入操作.
—— 參考自 The "Double-Checked Locking is Broken" Declaration, 原文如下:
If the singleton you are creating is static (i.e., there will only be one Helper created), as opposed to a property of another object (e.g., there will be one Helper for each Foo object, there is a simple and elegant solution. Just define the singleton as a static field in a separate class. The semantics of Java guarantee that the field will not be initialized until the field is referenced, and that any thread which accesses the field will see all of the writes resulting from initializing that field.
==> 所以這就在JVM層面包證了線程安全.
(2) 缺點: 造成空間的浪費.
飢餓模式是典型的以空間換時間思想的實現: 不用判斷就直接創建, 但創建之后如果不使用這個實例, 就造成了空間的浪費. 雖然只是一個類實例, 但如果是體積比較大的類, 這樣的消耗也不容忽視.
—— 不過在有些時候, 直接初始化單例的實例對項目的影響也微乎其微, 比如我們在應用啟動時就需要加載的配置文件信息, 就可以采取這種方式去保證單例.
3 寫法② - 懶惰模式
3.1 代碼示例
懶惰模式又稱為懶漢模式, 指的是在真正需要的時候再完成類對象的創建:
/**
* 懶惰模式: 用到時再初始化, 線程不安全, 可以在方法上使用synchronized關鍵字實現線程安全
*/
final class LazySingleton {
/** 實例對象 */
private static LazySingleton instance = null;
/** 禁用構造方法 */
private LazySingleton() { }
/**
* 線程不安全, 可以在方法上使用synchronized關鍵字實現線程安全
* @return instance 本類的實例
*/
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
3.2 優缺點比較
(1) 優點: 節省空間, 用到的時候再創建實例對象.
需要這個實例的時候, 先判斷它是否為空, 如果為空, 再創建單例對象.
用到的時候再去創建, 與JVM加載類的思路一致: 都是需要的時候再處理.
(2) 缺點: 線程不安全.
① 在並發獲取實例的時候, 線程A調用getInstance(), 在判斷
singleton == null
時得到true的結果, 之后進入if語句, 准備創建instance實例;② 恰好在這個時候, 另一個線程B來了, CPU將執行權切換給了B —— 此時A還沒來得及創建出實例, 所以線程B在判斷
singleton == null
的時候, 結果還是true, 所以線程B也會進入if語句去創建實例;③ 問題來了: 兩個線程都進入了if語句, 結果就是: 創建了2個實例對象.
3.3 線程是否安全的測試
/**
* 測試懶惰模式的線程安全
*/
public static void main(String[] args) {
// 同步的Set, 用來保存創建的實例
Set<String> instanceSet = Collections.synchronizedSet(new HashSet<>());
// 創建100個線程, 將每個線程獲得的實例添加到Set中
for (int i = 0; i < 100; i++) {
new Thread(() -> {
instanceSet.add(LazySingleton.getInstance().toString());
}).start();
}
for (String instance : instanceSet) {
System.out.println(instance);
}
}
(1) 代碼說明: 上述循環中的Lambda表達式的作用, 等同於:
new Thread(new Runnable() { @Override public void run() { instanceSet.add(LazySingleton.getInstance().toString()); } }).start();
(2) 輸出結果說明: 由於Set集合能夠自動去重, 所以如果輸出的結果中有2個或2個以上的對象, 就足以說明在並發訪問的過程中出現了線程安全問題. 當然如果沒有出現的話, 不妨多運行幾次, 或者把循環次數調大一點再試試😜
3.4 線程安全的懶惰模式
(1) 通過synchronized
關鍵字對獲取實例的方法進行同步限制, 實現了線程安全:
/**
* 在獲取實例的公共方法上使用synchronized關鍵字實現線程安全
* @return instance 本類的實例
*/
public synchronized static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
(2) 優缺點比較:
上面的做法是把整個獲取實例的方法同步. 這樣一來, 當某個線程訪問這個方法時, 其它所有的線程都要處於掛起等待狀態.
① 優點: 避免了同步訪問創建多個實例的問題;
② 缺點: 很明顯, 這樣的做法對所有線程的訪問都會進行同步操作, 有很嚴重的性能問題.
4 寫法③ - 雙重檢查鎖模式
4.1 代碼示例
在上述代碼中, 我們不難發現, 其實同步操作只需要發生在實例還未創建的時候, 在實例創建以后, 獲取實例的方法就沒必要再進行同步控制了.
這個思路就是 雙重檢查鎖(Double Checked Locking, 簡稱DCL)模式 的實現思路, 是在線程安全的懶惰模式的基礎上改進得來的. 下面我們通過代碼剖析這種模式:
/**
* 雙重檢查鎖模式: 對線程安全的懶惰模式的改進: 方法上的synchronized在每次調用時都要加鎖, 性能太低.
*/
final class DoubleCheckedLockingSingleton {
/** 實例對象, 這里還沒有添加volatile關鍵字 */
private static DoubleCheckedLockingSingleton instance = null;
/** 禁用構造方法 */
private DoubleCheckedLockingSingleton() { }
/**
* 獲取對象: 將方法上的synchronized移至內部
* @return instance 本類的實例
*/
public static DoubleCheckedLockingSingleton getInstance() {
// 先判斷實例是否存在
if (instance == null) {
// 加鎖創建實例
synchronized (DoubleCheckedLockingSingleton.class) {
// 再次判斷, 因為可能出現某個線程拿了鎖之后, 還沒來得及執行初始化就釋放了鎖,
// 而此時其他的線程拿到了鎖又執行到此處 ==> 這些線程都會創建一個實例, 從而創建多個實例對象
if (instance == null) {
instance = new DoubleCheckedLockingSingleton();
}
}
}
return instance;
}
}
實現過程中需要注意的事項, 都在注視中作了說明.
4.2 DCL存在的問題
你以為到這里, 單例模式就安全了嗎? 不是的!
在多處理器的共享內存、或者編譯器的優化下, DCL模式並不一定線程 —— 可能 (注意: 只是可能出現) 會發生指令的重排序, 出現半個對象的問題.
(1) JVM在創建實例的時候, 是分為如下步驟創建的:
① 在堆內存中, 為新的實例開辟空間;
② 初始化構造器, 對實例中的成員進行初始化;
③ 把這個實例的引用 (也就是這里的instance) 指向①中空間的起始地址.
==> 也就是說, Java中創建一個對象的過程並不是原子性操作.
(2) 上述過程不是原子性的, 所以就可能出現:
JVM在優化代碼的過程中, 可能對①-③這三個過程進行重排序 —— 因為 JVM會對字節碼進行優化, 其中就包括了指令的重排序.
如果重排序后變為①③②, 就會出現一些難以捕捉的問題.
(3) 再來說說半個對象:
構造方法中有其他非原子性操作, 創建對象時只是得到了對象的正確引用, 而對象內部的成員變量可能還沒有來得及賦值, 這個時候就可能訪問到 "不正確(陳舊)" 的成員變量.
對引用類型 (包括對象和數組) 變量的非同步訪問, 即使得到該引用的最新值, 也並不能保證能得到其成員變量 (對數組而言就是每個數組中的元素) 的最新值;
4.3 解決方法
在聲明對象時通過關鍵字volatile
, 禁止JVM對這個對象涉及到的代碼重排序:
private static volatile DoubleCheckedLockingSingleton instance = null;
這里我們用volatile
關鍵字修飾了instance
變量, JVM就不會對instance
的創建過程進行優化, 只要我們訪問這個類的任意一個靜態域, 就會創建這個類的對象.
關於
volatile
關鍵字的作用:
volatile
關鍵字禁止了JVM的指令重排序, 並且保證線程中對這個變量所做的任何寫入操作對其他線程都是即時可見的 (也就是保證了內存的可見性).需要注意的是, 這兩個特性是在JDK 5 之后才支持的.
—— 關於類的加載機制、volitale關鍵字的詳細作用, 后續會有播客輸出, 讀者盆友們可以先去各大博客、論壇搜索研究下, 也可以查看文末的參考博客鏈接.
5 寫法④ - 靜態內部類實現單例
5.1 代碼示例
靜態內部類也稱作Singleton Holder, 也就是單持有者模式, 是線程安全的, 也是懶惰模式的變形.
JVM加載類的時候, 有這么幾個步驟:
①加載 -> ②驗證 -> ③准備 -> ④解析 -> ⑤初始化
需要注意的是: JVM在加載外部類的過程中, 是不會加載靜態內部類的, 只有內部類(SingletonHolder)的屬性/方法被調用時才會被加載, 並初始化其靜態屬性(instance).
/**
* 靜態內部類模式, 也稱作Singleton Holder(單持有者)模式: 線程安全, 懶惰模式的一種, 用到時再加載
*/
final class StaticInnerSingleton {
/** 禁用構造方法 */
private StaticInnerSingleton() { }
/**
* 通過靜態內部類獲取單例對象, 沒有加鎖, 線程安全, 並發性能高
* @return SingletonHolder.instance 內部類的實例
*/
public static StaticInnerSingleton getInstance() {
return SingletonHolder.instance;
}
/** 靜態內部類創建單例對象 */
private static class SingletonHolder {
private static StaticInnerSingleton instance = new StaticInnerSingleton();
}
}
5.2 靜態內部類的優勢
比較推薦這種方式, 沒有加鎖, 線程安全, 用到時再加載, 並發行能高.
6 寫法⑤ - 枚舉類實現單例
6.1 代碼示例
JDK 5開始, 提供了枚舉(enum), 其實就是一個語法糖: 我們寫很少的代碼, JVM在編譯的時候幫我們添加很多額外的信息.
通過對枚舉類的反編譯可以知道: 枚舉類也是在JVM層面保證的線程安全.
/**
* 枚舉類單例模式
*/
enum EnumSingleton {
/** 此枚舉類的一個實例, 可以直接通過EnumSingleton.INSTANCE來使用 */
INSTANCE
}
6.2 優缺點比較
(1) 優點: JVM對枚舉類的特殊規定決定了:
① 不需要考慮序列化的問題: 枚舉序列化是由JVM保證的, 每一個枚舉類型和枚舉變量在JVM中都是唯一的, 在枚舉類型的序列化和反序列化上Java做了特殊的規定: 在序列化時Java僅僅是將枚舉對象的name屬性輸出到結果中, 反序列化時只是通過
java.lang.Enum#valueOf()
方法來根據名字查找枚舉對象 —— 編譯器不允許對這種序列化機制進行定制、並且禁用了writeObject、readObject、readObjectNoData、writeReplace、readResolve等方法, 從而保證了枚舉實例的唯一性;② 不需要考慮反射的問題: 在通過反射方法
java.lang.reflect.Constructor#newInstance()
創建枚舉實例時, JDK源碼對調用者的類型進行了判斷:// 判斷調用者clazz的類型是不是Modifier.ENUM(枚舉修飾符), 如果是就拋出參數異常: if ((clazz.getModifiers() & Modifier.ENUM) != 0) throw new IllegalArgumentException("Cannot reflectively create enum objects");
所以, 我們是不能通過反射創建枚舉實例的, 也就是說創建枚舉實例只有編譯器能夠做到.
關於JVM對枚舉類的處理, 可以參考我的這篇文章: Java中枚舉類型的使用 - enum.
(2) 缺點: 所有的屬性都必須在創建時指定, 也就意味着不能延遲加載; 並且使用枚舉時占用的內存比靜態變量的2倍還多, 這在性能要求嚴苛的應用中是不可忽視的.
7 寫法⑥ - 通過ThreadLocal實現單例
還是在 The "Double-Checked Locking is Broken" Declaration 這篇文章中, 發現了通過 ThreadLocal 修正DCL問題的思路: 每個線程都持有一個 ThreadLocal 標志, 用來確定該線程是否已完成所需的同步. 具體代碼如下:
/**
* 通過ThreadLocal實現單例模式, 性能可能比較低
*/
class ThreadLocalSingleton {
/** 如果 perThreadInstance.get() 返回一個非空值, 說明當前線程已經被同步了: 它要看到instance變量的初始化 */
private static ThreadLocal perThreadInstance = new ThreadLocal();
private static ThreadLocalSingleton instance = null;
public static ThreadLocalSingleton getInstance() {
if (perThreadInstance.get() == null) {
createInstance();
}
return instance;
}
private static final void createInstance() {
synchronized (ThreadLocalSingleton.class) {
if (instance == null) {
instance = new ThreadLocalSingleton();
}
}
// 任何非空的值都可以作為這里的參數
perThreadInstance.set(perThreadInstance);
}
/**
* 阿里代碼規范提示: ThreadLocal變量應該至少調用一次remove()方法, 原因如下:
* 必須回收自定義的ThreadLocal變量, 尤其在線程池場景下, 因為線程經常會被復用,
* 如果不清理自定義的 ThreadLocal變量, 可能會影響后續業務邏輯和造成內存泄露等問題.
* 盡量在代理中使用try-finally塊進行回收.
*/
public static void remove() {
perThreadInstance.remove();
}
}
這種技術的性能在很大程度上取決於的JDK的版本. 在Sun JDK 1.2中, ThreadLocal性能非常慢, 而在1.3中性能明顯提升了. 具體的性能對比, 參見下一節.
8 擴展: JDK中的單例 以及 如何破壞單例模式
8.1 JDK中常見的單例模式
(1) java.lang.Runtime類中的getRuntime()方法;
(2) java.awt.Toolkit類中的getDefaultToolkit()方法;
(3) java.awt.Desktop類中的getDesktop()方法;
(4) 另外, RuntimeException也是單例的 —— 因為一個Java應用只有一個Java Runtime Environment.
8.2 破壞單例模式的方法
(1) 除枚舉方式外, 其他方法都會通過反射的方式破壞單例, 解決方法:
反射是通過調用構造方法生成新的對象, 可以在構造方法中進行判斷 —— 若已有實例, 則阻止生成新的實例, 如:
private Singleton() throws Exception { if (instance != null) { throw new Exception("Singleton already initialized, 此類是單例類, 不允許生成新對象, 請通過getInstance()獲取本類對象"); } }
(2) 如果單例類實現了序列化接口Serializable, 就可以通過反序列化破壞單例, 解決方法:
不實現序列化接口, 或者重寫反序列化方法
readResolve()
, 反序列化時直接返回相關單例對象:// 反序列化時直接返回當前實例 public Object readResolve() { return instance; }
(3) Object#clone()方法也會破壞單例, 即使你沒有實現Cloneable接口 —— 因為clone()方法是Object類中的. 解決方法是:
重寫clone()方法, 並在其中拋出異常信息“Can not create clone of Singleton class”
9 擴展 - 性能對比
(1) 測試用的代碼:
創建100個線程, 每個線程中循環獲取10,000次單例對象, 統計各個類所用的時間.
public static void main(String[] args) throws InterruptedException {
// 創建的線程數
int threadNum = 100;
// 循環獲取對象的次數
int objectNum = 10000;
Long beginTime = System.currentTimeMillis();
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
for (int j = 0; j < objectNum; j++) {
Object o = HungrySingleton.getInstance();
}
}).start();
}
Long endTime = System.currentTimeMillis();
System.out.println("HungrySingleton --- " + (endTime - beginTime) + " ms");
// 省去一大串其他類的測試代碼
beginTime = System.currentTimeMillis();
for (int i = 0; i < threadNum; i++) {
new Thread(() -> {
for (int j = 0; j < objectNum; j++) {
Object o = EnumSingleton.INSTANCE;
}
}).start();
}
endTime = System.currentTimeMillis();
System.out.println("EnumSingleton --- " + (endTime - beginTime) + " ms");
}
說明:
這個測試代碼的重復性太高了, 本來想封裝成方法、通過反射進行不同類和方法的調用的, 可考慮到反射的性能損耗, 一時又想不到其他好點的方法, 所以不得已采取了這種. 各位看官請別噴, 有好點的方法可以在留言區交流下🙏
(2) 測試結果, 單位是毫秒(ms):
不同的模式 | 第一次 | 第二次 | 第三次 | 平均耗時 |
---|---|---|---|---|
飢餓模式 (HungrySingleton) | 59 | 61 | 62 | 61 |
線程安全的懶惰模式 (LazySingleton) | 27 | 10 | 41 | 26 |
雙重檢查鎖模式 (DoubleCheckedLockingSingleton) | 12 | 14 | 12 | 13 |
靜態內部類模式 (StaticInnerSingleton) | 12 | 13 | 22 | 16 |
枚舉類模式 (EnumSingleton) | 8 | 10 | 10 | 9 |
線程本地變量 (ThreadLocalSingleton) | 21 | 26 | 24 | 24 |
運行多次, 發現結果不太穩定, 暫時未找到原因, 所以就不總結了, 各位看官權當參考, 還請存疑🤨
呼, Java中的單例模式終於整理完了, 由於個人經驗有限, 肯定存在很多疏漏, 如果你在瀏覽的時候發現問題, 請直接在評論區指出來, 拜謝各位.
參考資料
版權聲明
作者: 馬瘦風
出處: 博客園 馬瘦風的博客
感謝閱讀, 如果文章有幫助或啟發到你, 點個[好文要頂👆] 或 [推薦👍] 吧😜
本文版權歸博主所有, 歡迎轉載, 但 [必須在文章頁面明顯位置給出原文鏈接], 否則博主保留追究相關人員法律責任的權利.