溫故而知新—從原理解釋單例模式

前言

單例模式,應該是使用頻率比較高的一種設計模式了。

關於它,你是否瞭解的夠深呢?比如:

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 就是我們常說的雙重校驗的模式。

比較下兩種方式其實區別也就是在這個雙重校驗,首先判斷單例是否爲空,如果爲空再進入加鎖階段,正常走單例的實例化代碼。

那麼,爲什麼要這麼做呢?

到這裏,看似問題都解決了.. 嗎?

等等,new Singleton()這個實例化過程真的沒問題嗎?

在 JVM 中,有一種操作叫做指令重排

JVM 爲了優化指令,提高程序運行效率,在不影響單線程程序執行結果的前提下,會將指令進行重新排序,但是這種重新排序不會對單線程程序產生影響。

簡單的說,就是在不影響最終結果的情況下,一些指令順序可能會被打亂。

再看看在對象實例化中的指令主要有這三步操作:

如果我們將第二步和第三步重排一下,結果也是不影響的:

這種情況下,就有問題了:

當線程 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 雙重校驗。

總結

今天和大家回顧了下單例模式,希望大家能有溫故而知新的收穫。

參考

www.kotlincn.net/docs/refere…

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://juejin.cn/post/6940060221041704968?utm_source=gold_browser_extension