溫故而知新—從原理解釋單例模式
前言
單例模式,應該是使用頻率比較高的一種設計模式了。
關於它,你是否瞭解的夠深呢?比如:
java 和 kotlin 的實現方式? 懶漢餓漢到底啥意思?
餓漢、雙重校驗、靜態內部類模式的實現原理?
涉及到的類初始化、類鎖、線程安全、kotlin 語法知識?
靜態變量實現單例——餓漢
保證一個實例很簡單,只要每次返回同一個實例就可以,關鍵是如何保證實例化過程的線程安全
?
這裏先回顧下類的初始化
。
在類實例化之前,JVM 會執行類加載
。
類加載的最後一步就是進行類的初始化,在這個階段,會執行類構造器<clinit>
方法,其主要工作就是初始化類中靜態的變量,代碼塊。
而<clinit>()
方法是阻塞的,在多線程環境下,如果多個線程同時去初始化一個類,那麼只會有一個線程去執行這個類的<clinit>()
,其他線程都會被阻塞。換句話說,<clinit>
方法被賦予了線程安全的能力。
再結合我們要實現的單例,就很容易想到可以通過靜態變量
的形式創建這個單例,這個過程是線程安全的,所以我們得出了第一種單例實現方法:
1private static Singleton singleton = new Singleton();
2
3public static Singleton getSingleton() {
4 return singleton;
5}
很簡單,就是通過靜態變量實現唯一單例,並且是線程安全
的。
看似比較完美的一個方法,也是有缺點的,就是有可能我還沒有調用getSingleton方法
的時候,就進行了類的加載,比如用到了反射或者類中其他的靜態變量靜態方法。所以這個方法的缺點就是有可能會造成資源浪費,在我沒用到這個單例的時候就對單例進行了實例化。
在同一個類加載器下,一個類型只會被初始化一次,一共有六種能夠觸發類初始化的時機:
- 1、虛擬機啓動時,初始化包含 main 方法的主類;
- 2、new 等指令創建對象實例時
- 3、訪問靜態方法或者靜態字段的指令時
- 4、子類的初始化過程如果發現其父類還沒有進行過初始化
- 5、使用反射 API 進行反射調用時
- 6、第一次調用 java.lang.invoke.MethodHandle 實例時
這種我不管你用不用,只要我這個類初始化了,我就要實例化這個單例,被類比爲 餓漢方法
。(是真餓了,先實例化出來放着吧,要喫的時候就可以直接吃了)
缺點就是 有可能造成資源浪費(到最後,飯也沒喫上,飯就浪費了)
但其實這種模式一般也夠用了,因爲一般情況下用到這個實例的時候纔會去用到這個類,很少存在需要使用這個類但是不使用其單例的時候。
當然,話不能說絕了,也是有更好的辦法來解決這種可能的資源浪費
。
在這之前,我們先看看 Kotlin 的 餓漢實現
。
kotlin 餓漢 —— 最簡單單例
1object Singleton
2
3
沒了?嗯,沒了。
這裏涉及到一個 kotlin 中才有的關鍵字:object(對象)
。
關於 object 主要有三種用法:
- 對象表達式
主要用於創建一個繼承自某個(或某些)類型的匿名類的對象。
1window.addMouseListener(object : MouseAdapter() {
2 override fun mouseClicked(e: MouseEvent) { }
3
4 override fun mouseEntered(e: MouseEvent) { }
5})
6
7
- 對象聲明
主要用於單例。也就是我們今天用到的用法。
1object Singleton
2
3
我們可以通過 Android Studio 的 Show Kotlin Bytecode
功能,看到反編譯後的 java 代碼:
1public final class Singleton {
2 public static final Singleton INSTANCE;
3
4 private Singleton() {
5 }
6
7 static {
8 Singleton var0 = new Singleton();
9 INSTANCE = var0;
10 }
11}
很顯然,跟我們上一節寫的餓漢差不多,都是在類的初始化階段就會實例化出來單例,只不過一個是通過靜態代碼塊,一個是通過靜態變量。
- 伴生對象
類內部的對象聲明可以用 companion
關鍵字標記,有點像靜態變量,但是並不是真的靜態變量。
1class MyClass {
2 companion object Factory {
3 fun create(): MyClass = MyClass()
4 }
5}
6
7
8MyClass.create()
反編譯成 Java 代碼:
1public final class MyClass {
2 public static final MyClass.Factory Factory = new MyClass.Factory((DefaultConstructorMarker)null);
3 public static final class Factory {
4 @NotNull
5 public final MyClass create() {
6 return new MyClass();
7 }
8
9 private Factory() {
10 }
11
12
13 public Factory(DefaultConstructorMarker $constructor_marker) {
14 this();
15 }
16 }
17}
其原理還是一個靜態內部類
,最終調用的還是這個靜態內部類的方法,只不過省略了靜態內部類的名稱。
要想實現真正的靜態成員需要 @JvmField
修飾變量。
優化餓漢,喫飯的時候再去做飯 —— 最優雅單例
說回正題,即然餓漢有缺點,我們就想辦法去解決,有什麼辦法可以不浪費這個實例呢?也就是達到 按需加載
單例?
這就要涉及到另外一個知識點了,靜態內部類
的加載時機。
剛纔說到類的加載時候,初始化過程只會加載靜態變量和代碼塊,所以是不會加載靜態內部類的。
靜態內部類是延時加載
的,意思就是說只有在明確用到內部類
時才加載,只使用外部類時不加載。
根據這個信息,我們就可以優化剛纔的 餓漢模式
,改成靜態內部類模式(java和kotlin版本)
:
1 private static class SingletonHolder {
2 private static Singleton INSTANCE = new Singleton();
3 }
4
5 public static Singleton getSingleton() {
6 return SingletonHolder.INSTANCE;
7 }
1 companion object {
2 val instance = SingletonHolder.holder
3 }
4
5 private object SingletonHolder {
6 val holder = SingletonDemo()
7 }
同樣是通過類的初始化<clinit>()
方法保證線程安全,並且在此之上,將單例的實例化過程向後移,移到靜態內部類。所以就變成了當調用 getSingleton 方法的時候纔會去初始化這個靜態內部類,也就是纔會實例化靜態單例。
如此一整,這種方法就完美了... 嗎?好像也有缺點啊,比如我調用getSingleton方法
創建實例的時候想傳入參數怎麼辦呢?
可以,但是需要一開始就設置好參數值,無法通過調用getSingleton
方法來動態設置參數。比如這樣寫:
1 private static class SingletonHolder {
2 private static String test="123";
3 private static Singleton INSTANCE = new Singleton(test);
4 }
5
6 public static Singleton getSingleton() {
7 SingletonHolder.test="12345";
8 return SingletonHolder.INSTANCE;
9 }
最終實例化進去的 test 只會是 123,而不是 12345。因爲只要你開始用到SingletonHolder
內部類,單例INSTANCE
就會最開始完成了實例化,即使你賦值了 test,也是單例實例化之後的事了。
這個就是 靜態內部類方法的缺點了。如果不用動態傳參數,那麼這個方法已經足夠了。
可以傳參的單例 —— 懶漢
如果需要傳參數呢?
那就正常寫唄,也就是調用getSingleton
方法的時候,去判斷這個單例是否已存在,不存在就實例化即可。
1 private static Singleton singleton;
2
3 public static Singleton getSingleton() {
4 if (singleton == null) {
5 singleton = new Singleton();
6 }
7 return singleton;
8 }
這個倒是看的很清楚,需要的時候纔去創建實例,這樣的話就保證了在需要喫飯的時候纔去做飯,比較中規中矩的一個做法。但是在餓漢的思維裏就會覺得這個人好懶啊,都不先準備好飯,喫的時候再煮好麻煩。
因此,這個方法被稱爲 懶漢式
。
但是這個方法的弊端也是很明顯,就是線程不安全
,不同線程同時訪問 getSingleton 方法有可能導致對象實例化出錯。
所以,加鎖。
雙重校驗的懶漢
加鎖怎麼加,也是個問題。
首先肯定的是,我們加的鎖肯定是類鎖
,因爲要針對這個類進行加鎖,保證同一時間只有一個線程進行單例的實例化操作。
那麼類鎖就有兩種加法了,修飾靜態方法和修飾類對象:
1 public synchronized static Singleton getSingleton() {
2 if (singleton == null) {
3 singleton = new Singleton();
4 }
5 return singleton;
6 }
7
8
9 public static Singleton getSingleton() {
10 if (singleton == null) {
11 synchronized (Singleton.class) {
12 if (singleton == null) {
13 singleton = new Singleton();
14 }
15 }
16
17 }
18 return singleton;
19 }
方法 2 就是我們常說的雙重校驗
的模式。
比較下兩種方式其實區別也就是在這個雙重校驗,首先判斷單例是否爲空,如果爲空再進入加鎖階段,正常走單例的實例化代碼。
那麼,爲什麼要這麼做呢?
第一個判斷,是爲了性能
。當這個 singleton 已經實例化之後,我們再取值其實是不需要再進入加鎖階段的,所以第一個判斷就是爲了減少加鎖。把加鎖只控制在第一次實例化這個過程中,後續就可以直接獲取單例即可。第二個判斷,是防止重複創建對象
。當兩個線程同時走到synchronized
這裏,線程 A 獲得鎖,進入創建對象。創建完對象後釋放鎖,然後線程 B 獲得鎖,如果這時候沒有判斷單例是否爲空,那麼就會再次創建對象,重複了這個操作。
到這裏,看似問題都解決了.. 嗎?
等等,new Singleton()
這個實例化過程真的沒問題嗎?
在 JVM 中,有一種操作叫做指令重排
:
JVM 爲了優化指令,提高程序運行效率,在不影響單線程程序執行結果的前提下,會將指令進行重新排序,但是這種重新排序不會對單線程程序產生影響。
簡單的說,就是在不影響最終結果的情況下,一些指令順序可能會被打亂。
再看看在對象實例化中的指令主要有這三步操作:
- 1、分配對象內存空間
- 2、初始化對象
- 3、instance 指向剛分配的內存地址
如果我們將第二步和第三步重排一下,結果也是不影響的:
- 1、分配對象內存空間
- 2、instance 指向剛分配的內存地址
- 3、初始化對象
這種情況下,就有問題了:
當線程 A 進入實例化階段,也就是new Singleton()
,剛完成第二步分配好內存地址。這時候線程 B 調用了getSingleton()
方法,走到第一個判空,發現不爲空,返回單例,結果用的時候就有問題了,對象都沒有初始化完成。
這就是指令重排有可能導致的問題。
所以,我們需要禁止指令重排,volatile
登場。
volatile 主要有兩個特性:
- 可見性。也就是寫操作會對其他線程可見。
- 禁止指令重排。
所以再加上volatile
對變量進行修飾,這個雙重校驗的單例模式也就完整了。
1private volatile static Singleton singleton;
kotlin 版本雙重校驗
1class Singleton private constructor() {
2 companion object {
3 val instance: Singleton by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) {
4 Singleton() }
5 }
6}
7
8
9class Singleton private constructor(private val context: Context) {
10 companion object {
11 @Volatile private var instance: Singleton? = null
12
13 fun getInstance(context: Context) =
14 instance ?: synchronized(this) {
15 instance ?: Singleton(context).apply {
16 instance = this
17 }
18 }
19 }
20}
帶參數的寫法很好理解,和 Java 差不多。 但這個不帶參數的寫法也太簡便了點吧?Volatile 也沒有了?確定沒問題?
沒問題,奧祕就在這個延遲屬性lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED)
中,我們進去瞧瞧:
1public actual fun <T> lazy(mode: LazyThreadSafetyMode, initializer: () -> T): Lazy<T> =
2 when (mode) {
3 LazyThreadSafetyMode.SYNCHRONIZED -> SynchronizedLazyImpl(initializer)
4 LazyThreadSafetyMode.PUBLICATION -> SafePublicationLazyImpl(initializer)
5 LazyThreadSafetyMode.NONE -> UnsafeLazyImpl(initializer)
6 }
7
8private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
9 private var initializer: (() -> T)? = initializer
10 @Volatile private var _value: Any? = UNINITIALIZED_VALUE
11
12 private val lock = lock ?: this
13
14 override val value: T
15 get() {
16 val _v1 = _value
17 if (_v1 !== UNINITIALIZED_VALUE) {
18 @Suppress("UNCHECKED_CAST")
19 return _v1 as T
20 }
21
22 return synchronized(lock) {
23 val _v2 = _value
24 if (_v2 !== UNINITIALIZED_VALUE) {
25 @Suppress("UNCHECKED_CAST") (_v2 as T)
26 } else {
27 val typedValue = initializer!!()
28 _value = typedValue
29 initializer = null
30 typedValue
31 }
32 }
33 }
其實內部還是用到了Volatile + synchronized
雙重校驗。
總結
今天和大家回顧了下單例模式,希望大家能有溫故而知新
的收穫。
參考
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://juejin.cn/post/6940060221041704968?utm_source=gold_browser_extension