設計模式 – 單例模式(詳解)看看和你理解的是否一樣?
- 2019 年 10 月 8 日
- 筆記
一、概述
單例模式是設計模式中相對簡單且非常常見的一種設計模式,但是同時也是非常經典的高頻面試題,相信還是有很多人在面試時會掛在這裡。本篇文章主要針對單例模式做一個回顧,記錄單例模式的應用場景、常見寫法、針對線程安全進行調試(看得見的線程)以及總結。相信大家看完這篇文章之後,對單例模式有一個非常深刻的認識。
文章中按照常見的單例模式的寫法,由淺入深進行講解記錄;以及指出該寫法的不足,從而進行演進改造。
秉承廢話少說的原則,我們下面快速開始
二、定義
單例模式(Singleton Pattern)是指確保一個類在任何情況下都絕對只有一個實例,並提供一個全局訪問點。
單例模式是創建型模式。
三、應用場景
- 生活中的單例:例如,國家主席、公司 CEO、部門經理等。
- 在
Java
世界中:ServletContext
、ServletContextConfig
等; - 在
Spring
框架應用中:ApplicationContext
、數據庫的連接池也都是單例形式。
四、常見的單例模式寫法
單例模式主要有:餓漢式單例、懶漢式單例(線程不安全型、線程安全型、雙重檢查鎖類型、靜態內部類類型)、註冊式(登記式)單例(枚舉式單例、容器式單例)、
ThreadLocal
線程單例
下面我們來看看各種模式的寫法。
1、餓漢式單例
餓漢式單例是在類加載的時候就立即初始化,並且創建單例對象。絕對線程安全,在線程還沒出現以前就是實例化了,不可能存在訪問安全問題。
Spring 中 IOC 容器 ApplicationContext 就是典型的餓漢式單例
優缺點
優點:沒有加任何的鎖、執行效率比較高,在用戶體驗上來說,比懶漢式更好。
缺點:類加載的時候就初始化,不管用與不用都佔著空間,浪費了內存,有可能佔著茅坑不拉屎。
寫法
/** * @author eamon.zhang * @date 2019-09-30 上午9:26 */ public class HungrySingleton { // 1.私有化構造器 private HungrySingleton (){} // 2.在類的內部創建自行實例 private static final HungrySingleton instance = new HungrySingleton(); // 3.提供獲取唯一實例的方法(全局訪問點) public static HungrySingleton getInstance(){ return instance; } }
還有另外一種寫法,利用靜態代碼塊的機制:
/** * @author eamon.zhang * @date 2019-09-30 上午10:46 */ public class HungryStaticSingleton { // 1. 私有化構造器 private HungryStaticSingleton(){} // 2. 實例變量 private static final HungryStaticSingleton instance; // 3. 在靜態代碼塊中實例化 static { instance = new HungryStaticSingleton(); } // 4. 提供獲取實例方法 public static HungryStaticSingleton getInstance(){ return instance; } }
測試代碼,我們創建 10 個線程(具體線程發令槍 ConcurrentExecutor 在文末源碼中獲取):
/** * @author eamon.zhang * @date 2019-09-30 上午11:17 */ public class HungrySingletonTest { @Test public void test() { try { ConcurrentExecutor.execute(() -> { HungrySingleton instance = HungrySingleton.getInstance(); System.out.println(Thread.currentThread().getName() + " : " + instance); }, 10, 10); } catch (Exception e) { e.printStackTrace(); } } }
測試結果:
pool-1-thread-6 : com.eamon.javadesignpatterns.singleton.hungry.HungrySingleton@5e37cce6 pool-1-thread-1 : com.eamon.javadesignpatterns.singleton.hungry.HungrySingleton@5e37cce6 pool-1-thread-9 : com.eamon.javadesignpatterns.singleton.hungry.HungrySingleton@5e37cce6 pool-1-thread-10 : com.eamon.javadesignpatterns.singleton.hungry.HungrySingleton@5e37cce6 pool-1-thread-2 : com.eamon.javadesignpatterns.singleton.hungry.HungrySingleton@5e37cce6 pool-1-thread-7 : com.eamon.javadesignpatterns.singleton.hungry.HungrySingleton@5e37cce6 pool-1-thread-5 : com.eamon.javadesignpatterns.singleton.hungry.HungrySingleton@5e37cce6 pool-1-thread-3 : com.eamon.javadesignpatterns.singleton.hungry.HungrySingleton@5e37cce6 pool-1-thread-4 : com.eamon.javadesignpatterns.singleton.hungry.HungrySingleton@5e37cce6 pool-1-thread-8 : com.eamon.javadesignpatterns.singleton.hungry.HungrySingleton@5e37cce6 ...
可以看到,餓漢式每次獲取實例都是同一個。
使用場景
這兩種寫法都非常的簡單,也非常好理解,餓漢式適用在單例對象較少的情況。
下面我們來看性能更優的寫法——懶漢式單例。
2、懶漢式單例
懶漢式單例的特點是:被外部類調用的時候內部類才會加載。
懶漢式單例可以分為下面這幾種寫法來。
簡單懶漢式(線程不安全)
這是懶漢式單例的簡單寫法
/** * @author eamon.zhang * @date 2019-09-30 上午10:55 */ public class LazySimpleSingleton { private LazySimpleSingleton(){} private static LazySimpleSingleton instance = null; public static LazySimpleSingleton getInstance(){ if (instance == null) { instance = new LazySimpleSingleton(); } return instance; } }
我們創建一個多線程來測試一下,是否線程安全:
/** * @author eamon.zhang * @date 2019-09-30 上午11:12 */ public class LazySimpleSingletonTest { @Test public void test() { try { ConcurrentExecutor.execute(() -> { LazySimpleSingleton instance = LazySimpleSingleton.getInstance(); System.out.println(Thread.currentThread().getName() + " : " + instance); }, 5, 5); } catch (Exception e) { e.printStackTrace(); } } }
運行結果:
pool-1-thread-3 : com.eamon.javadesignpatterns.singleton.lazy.LazySimpleSingleton@abe194f pool-1-thread-5 : com.eamon.javadesignpatterns.singleton.lazy.LazySimpleSingleton@abe194f pool-1-thread-1 : com.eamon.javadesignpatterns.singleton.lazy.LazySimpleSingleton@748e48d0 pool-1-thread-2 : com.eamon.javadesignpatterns.singleton.lazy.LazySimpleSingleton@abe194f pool-1-thread-4 : com.eamon.javadesignpatterns.singleton.lazy.LazySimpleSingleton@abe194f
從測試結果來看,一定幾率出現創建兩個不同結果的情況,意味着上面的單例存在線程安全隱患。
至於為什麼?由於篇幅問題,我們在後面一篇文章中利用測試工具進行詳細的分析(這可能也是面試中面試官會問到的問題)。大家現在只需要知道簡單的懶漢式會存在這麼一個問題就行了。
簡單懶漢式(線程安全)
通過對上面簡單懶漢式單例的測試,我們知道存在線程安全隱患,那麼,如何來避免或者解決呢?
我們都知道 java 中有一個synchronized
可以來對共享資源進行加鎖,保證在同一時刻只能有一個線程拿到該資源,其他線程只能等待。所以,我們對上面的簡單懶漢式進行改造,給getInstance()
方法加上synchronized
:
/** * @author eamon.zhang * @date 2019-09-30 上午10:55 */ public class LazySimpleSyncSingleton { private LazySimpleSyncSingleton() { } private static LazySimpleSyncSingleton instance = null; public synchronized static LazySimpleSyncSingleton getInstance() { if (instance == null) { instance = new LazySimpleSyncSingleton(); } return instance; } }
然後使用發令槍進行測試:
@Test public void testSync(){ try { ConcurrentExecutor.execute(() -> { LazySimpleSyncSingleton instance = LazySimpleSyncSingleton.getInstance(); System.out.println(Thread.currentThread().getName() + " : " + instance); }, 5, 5); } catch (Exception e) { e.printStackTrace(); } }
進行多輪測試,並觀察結果,發現能夠獲取同一個示例:
pool-1-thread-3 : com.eamon.javadesignpatterns.singleton.lazy.simple.LazySimpleSyncSingleton@1a7e99de pool-1-thread-2 : com.eamon.javadesignpatterns.singleton.lazy.simple.LazySimpleSyncSingleton@1a7e99de pool-1-thread-5 : com.eamon.javadesignpatterns.singleton.lazy.simple.LazySimpleSyncSingleton@1a7e99de pool-1-thread-1 : com.eamon.javadesignpatterns.singleton.lazy.simple.LazySimpleSyncSingleton@1a7e99de pool-1-thread-4 : com.eamon.javadesignpatterns.singleton.lazy.simple.LazySimpleSyncSingleton@1a7e99de
線程安全問題是解決了,但是,用synchronized
加鎖,在線程數量比較多情況下,如果CPU
分配壓力上升,會導致大批量線程出現阻塞,從而導致程序運行性能大幅下降。
那麼,有沒有一種更好的方式,既兼顧線程安全又提升程序性能呢?答案是肯定的。
我們來看雙重檢查鎖的單例模式。
雙重檢查鎖懶漢式
上面的線程安全方式的寫法,synchronized
鎖是鎖在 getInstance()
方法上,當多個線程過來拿資源的時候,其實需要拿的不是getInstance()
這個方法,而是getInstance()
方法裏面的instance
實例對象,而如果這個實例對象一旦被初始化之後,多個線程到達時,就可以利用方法中的 if (instance == null)
去判斷是否實例化,如果已經實例化了就直接返回,就沒有必要再進行實例化一遍。所以對上面的代碼進行改造:
第一次改造:
/** * @author eamon.zhang * @date 2019-09-30 下午2:03 */ public class LazyDoubleCheckSingleton { private LazyDoubleCheckSingleton() { } private static LazyDoubleCheckSingleton instance = null; public static LazyDoubleCheckSingleton getInstance() { // 這裡判斷是為了過濾不必要的同步加鎖,因為如果已經實例化了,就可以直接返回了 if (instance == null) { // 如果未初始化,則對資源進行上鎖保護,待實例化完成之後進行釋放 synchronized (LazyDoubleCheckSingleton.class) { instance = new LazyDoubleCheckSingleton(); } } return instance; } }
這種方法行不行?答案肯定是不行,代碼中雖然是將同步鎖添加到了實例化操作中,解決了每個線程由於同步鎖的原因引起的阻塞,提高了性能;但是,這裡會存在一個問題:
線程X
和線程Y
同時調用getInstance()
方法,他們同時判斷instance == null
,得出的結果都是為null
,所以進入了if
代碼塊了- 此時
線程X
得到CPU
的控制權 -> 進入同步代碼塊 -> 創建對象 -> 返回對象 線程X
執行完成了以後,釋放了鎖,然後線程Y
得到了CPU
的控制權。同樣是 -> 進入同步代碼塊 -> 創建對象 -> 返回對象
所以我們明顯可以分析出來:LazyDoubleCheckSingleton
類返回了不止一個實例!所以上面的代碼是不行的!大家可以自行測試,我這裡就不進行測試了!
我們再進行改造,經過分析,由於線程X
已經實例化了對象,在線程Y
再次進入的時候,我們再加一層判斷不就可以解決 「這個」 問題嗎?確實如此,來看代碼:
/** * @author eamon.zhang * @date 2019-09-30 下午2:03 */ public class LazyDoubleCheckSingleton { private LazyDoubleCheckSingleton() { } private static LazyDoubleCheckSingleton instance = null; public static LazyDoubleCheckSingleton getInstance() { // 這裡判斷是為了過濾不必要的同步加鎖,因為如果已經實例化了,就可以直接返回了 if (instance == null) { // 如果未初始化,則對資源進行上鎖保護,待實例化完成之後進行釋放(注意,可能多個線程會同時進入) synchronized (LazyDoubleCheckSingleton.class) { // 這裡的if作用是:如果後面的進程在前面一個線程實例化完成之後拿到鎖,進入這個代碼塊, // 顯然,資源已經被實例化過了,所以需要進行判斷過濾 if (instance == null) { instance = new LazyDoubleCheckSingleton(); } } } return instance; } }
大家覺得經過這樣改造是不是就完美了呢?在我們習慣性的「講道理」的思維模式看來,好像確實沒什麼問題,但是,程序是計算機在執行;什麼意思呢?
在 instance = new LazyDoubleCheckSingleton();
這段代碼執行的時候,計算機內部並非簡單的一步操作,也就是非原子操作,在JVM
中,這一行代碼大概做了這麼幾件事情:
- 給
instance
分配內存 - 調用
LazyDoubleCheckSingleton
的構造函數來初始化成員變量 - 將
instance
對象指向分配的內存空間(執行完這步instance
就為非 null 了)
但是在 JVM
中的即時編譯器中存在指令重排序的優化;通俗的來說就是,上面的第二步和第三步的順序是不能保證的,如果執行順序是 1 -> 3 -> 2
那麼在 3 執行完畢、2 未執行之前,被另外一個線程 A 搶佔了,這時 instance
已經是非 null 了(但卻沒有初始化),所以線程 A 會直接返回 instance
,然後被程序調用,就會報錯。
當然,這種情況是很難測試出來的,但是確實會存在這麼一個問題,所以我們必須解決它,解決方式也很簡單,就是 j 將
instance
加上volatile
關鍵字。
所以相對較完美的實現方式是:
/** * @author eamon.zhang * @date 2019-09-30 下午2:03 */ public class LazyDoubleCheckSingleton { private LazyDoubleCheckSingleton() { } private static volatile LazyDoubleCheckSingleton instance = null; public static LazyDoubleCheckSingleton getInstance() { // 這裡判斷是為了過濾不必要的同步加鎖,因為如果已經實例化了,就可以直接返回了 if (instance == null) { // 如果未初始化,則對資源進行上鎖保護,待實例化完成之後進行釋放(注意,可能多個線程會同時進入) synchronized (LazyDoubleCheckSingleton.class) { // 這裡的if作用是:如果後面的進程在前面一個線程實例化完成之後拿到鎖,進入這個代碼塊, // 顯然,資源已經被實例化過了,所以需要進行判斷過濾 if (instance == null) { instance = new LazyDoubleCheckSingleton(); } } } return instance; } }
測試代碼見文末說明
靜態內部類懶漢式
上面的雙重鎖檢查形式的單例,對於日常開發來說,確實夠用了,但是在代碼中使用synchronized
關鍵字 ,總歸是要上鎖,上鎖就會存在一個性能問題。難道就沒有更好的方案嗎?別說,還真有,我們從類初始化的角度來考慮,這就是這裡所要說到的靜態內部類的方式。
廢話不多說,直接看代碼:
/** * * @author eamon.zhang * @date 2019-09-30 下午2:55 */ public class LazyInnerClassSingleton { private LazyInnerClassSingleton() { } // 注意關鍵字final,保證方法不被重寫和重載 public static final LazyInnerClassSingleton getInstance() { return LazyHolder.INSTANCE; } private static class LazyHolder { // 注意 final 關鍵字(保證不被修改) private static final LazyInnerClassSingleton INSTANCE = new LazyInnerClassSingleton(); } }
進行多線程測試:
pool-1-thread-9 : com.eamon.javadesignpatterns.singleton.lazy.inner.LazyInnerClassSingleton@88b7fa2 pool-1-thread-1 : com.eamon.javadesignpatterns.singleton.lazy.inner.LazyInnerClassSingleton@88b7fa2 pool-1-thread-6 : com.eamon.javadesignpatterns.singleton.lazy.inner.LazyInnerClassSingleton@88b7fa2 ...
結果都是同一個對象實例。
結論
這種方式即解決了餓漢式的內存浪費問題,也解決了synchronized
所帶來的性能問題
原理
利用的原理就是類的加載初始化順序:
- 當類不被調用的時候,類的靜態內部類是不會進行初始化的,這就避免了內存浪費問題;
- 當有方法調用
getInstance()
方法時,會先初始化靜態內部類,而靜態內部類中的成員變量是final
的,所以即便是多線程,其成員變量是不會被修改的,所以就解決了添加synchronized
所帶來的性能問題
首先感謝也恭喜大家能夠看到這裡,因為我想告訴你,上面所有的單例模式似乎還存在一點小問題 —— 暴力破壞。解決這一問題的方式就是下面提到的枚舉類型單例。
至於緣由和為何枚舉能夠解決這個問題,同樣,篇幅原因,我將在後面單獨開一篇文章來說明。
下面我們先來講講註冊式單例。
3、註冊式(登記式)單例
註冊式單例又稱為登記式單例,就是將每一個實例都登記到某一個地方,使用唯一的標識獲取實例。
註冊式單例有兩種寫法:一種為容器緩存,一種為枚舉登記。
先來看枚舉式單例的寫法。
枚舉單例
廢話少說,直接看代碼,我們先創建EnumSingleton
類:
/** * @author eamon.zhang * @date 2019-09-30 下午3:42 */ public enum EnumSingleton { INSTANCE; private Object instance; EnumSingleton() { instance = new EnumResource(); } public Object getInstance() { return instance; } }
來看測試代碼:
/** * @author eamon.zhang * @date 2019-09-30 下午3:47 */ public class EnumSingletonTest { @Test public void test() { try { ConcurrentExecutor.execute(() -> { EnumSingleton instance = EnumSingleton.INSTANCE; System.out.println(instance.getInstance()); }, 10, 10); } catch (Exception e) { e.printStackTrace(); } } }
測試結果:
com.eamon.javadesignpatterns.singleton.enums.EnumResource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.EnumResource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.EnumResource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.EnumResource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.EnumResource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.EnumResource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.EnumResource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.EnumResource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.EnumResource@3eadb1e7 com.eamon.javadesignpatterns.singleton.enums.EnumResource@3eadb1e7
結果都一樣,說明枚舉類單例是線程安全的,且是不可破壞的;在 JDK 枚舉的語法特殊性,以及反射也為枚舉保駕護航,讓枚舉式單例成為一種比較優雅的實現。
枚舉類單例也是《Effective Java》中所建議使用的。
容器式單例
註冊式單例還有另外一種寫法,利用容器緩存,直接來看代碼:
創建ContainerSingleton
類:
/** * @author EamonZzz * @date 2019-10-06 18:28 */ public class ContainerSingleton { private ContainerSingleton() { } private static Map<String, Object> ioc = new ConcurrentHashMap<String, Object>(); public static Object getBean(String className) { synchronized (ioc) { if (!ioc.containsKey(className)) { Object object = null; try { object = Class.forName(className).newInstance(); ioc.put(className, object); } catch (Exception e) { e.printStackTrace(); } return object; } else { return ioc.get(className); } } } }
測試代碼:
@Test public void test() { try { ConcurrentExecutor.execute(() -> { Object bean = ContainerSingleton .getBean("com.eamon.javadesignpatterns.singleton.container.Resource"); System.out.println(bean); }, 5, 5); } catch (Exception e) { e.printStackTrace(); } }
測試結果:
com.eamon.javadesignpatterns.singleton.container.Resource@42e7420f com.eamon.javadesignpatterns.singleton.container.Resource@42e7420f com.eamon.javadesignpatterns.singleton.container.Resource@42e7420f com.eamon.javadesignpatterns.singleton.container.Resource@42e7420f com.eamon.javadesignpatterns.singleton.container.Resource@42e7420f
容器式寫法適用於創建實例非常多的情況,便於管理。但是,是非線程安全的。
其實 Spring 中也有相關容器史丹利的實現代碼,比如 AbstractAutowireCapableBeanFactory
接口
至此,註冊式單例介紹完畢。
五、拓展
ThreadLocal 線程單例
ThreadLocal 不能保證其創建的對象是唯一的,但是能保證在單個線程中是唯一的,並且在單個線程中是天生的線程安全。
看代碼:
/** * @author EamonZzz * @date 2019-10-06 21:40 */ public class ThreadLocalSingleton { private ThreadLocalSingleton() { } private static final ThreadLocal<ThreadLocalSingleton> instance = ThreadLocal.withInitial(ThreadLocalSingleton::new); public static ThreadLocalSingleton getInstance() { return instance.get(); } }
測試程序:
@Test public void test() { System.out.println("-------------- 單線程 start ---------"); System.out.println(ThreadLocalSingleton.getInstance()); System.out.println(ThreadLocalSingleton.getInstance()); System.out.println(ThreadLocalSingleton.getInstance()); System.out.println(ThreadLocalSingleton.getInstance()); System.out.println(ThreadLocalSingleton.getInstance()); System.out.println("-------------- 單線程 end ---------"); System.out.println("-------------- 多線程 start ---------"); try { ConcurrentExecutor.execute(() -> { ThreadLocalSingleton singleton = ThreadLocalSingleton.getInstance(); System.out.println(Thread.currentThread().getName() + " : " + singleton); }, 5, 5); } catch (Exception e) { e.printStackTrace(); } System.out.println("-------------- 多線程 end ---------"); }
測試結果:
-------------- 單線程 start --------- com.eamon.javadesignpatterns.singleton.threadlocal.ThreadLocalSingleton@1374fbda com.eamon.javadesignpatterns.singleton.threadlocal.ThreadLocalSingleton@1374fbda com.eamon.javadesignpatterns.singleton.threadlocal.ThreadLocalSingleton@1374fbda com.eamon.javadesignpatterns.singleton.threadlocal.ThreadLocalSingleton@1374fbda com.eamon.javadesignpatterns.singleton.threadlocal.ThreadLocalSingleton@1374fbda -------------- 單線程 end --------- -------------- 多線程 start --------- pool-1-thread-5 : com.eamon.javadesignpatterns.singleton.threadlocal.ThreadLocalSingleton@2f540d92 pool-1-thread-1 : com.eamon.javadesignpatterns.singleton.threadlocal.ThreadLocalSingleton@3ef7ab4e pool-1-thread-2 : com.eamon.javadesignpatterns.singleton.threadlocal.ThreadLocalSingleton@604ffe2a pool-1-thread-3 : com.eamon.javadesignpatterns.singleton.threadlocal.ThreadLocalSingleton@50f41c9f pool-1-thread-4 : com.eamon.javadesignpatterns.singleton.threadlocal.ThreadLocalSingleton@40821a7a -------------- 多線程 end ---------
從測試結果來看,我們不難發現,在主線程中無論調用多少次,獲得到的實例都是同一個;在多線程環境下,每個線程獲取到了不同的實例。
所以,在單線程環境中,ThreadLocal 可以達到單例的目的。這實際上是以空間換時間來實現線程間隔離的。
六、總結
單例模式可以保證內存里只有一個實例,減少了內存的開銷;可避免對資源的浪費。
單例模式看起來非常簡單,實現起來也不難,但是在面試中卻是一個高頻的面試題。希望大家能夠徹底理解。
下面
本篇文章所涉及的源代碼: