Kotlin 設計模式解析之單例

單例模式介紹

單例模式是一個比較簡單的設計模式,同時也是挺有意思的一個模式,雖然看起來簡單,但是可以玩出各種花樣。比如 Java 當中的懶餓漢式單例等。

什麼是單例

單例模式的定義:

Ensure a class only has one instance, and provide a global point of access to it.

簡單來說,確保某一個類只有一個實例,且自行實例化並向整個系統提供。

單例模式的適用場景

  • 提供一個全局的訪問,且只要求一個實例,如應用的配置信息
  • 創建一個對象比較耗費資源,如數據庫連接管理、文件管理、日誌管理等
  • 資源共享,如線程池
  • 工具類對象(也可以直接使用靜態常量或者靜態方法)
  • 要求一個類只能產生兩三個實例對象,比如某些場景下,會要求兩個版本的網絡庫實例,如公司內網和外網的網絡庫實例

單例模式的簡單實現

Java 當中實現一個簡單的單例:

    public class Singleton {

        private static Singleton sInstance;

        /**
         * 構造方法私有化
         */
        private Singleton() {}

        /**
         * 提供獲取唯一實例的方法
         * @return 實例
         */
        public static Singleton getInstance() {
            if (sInstance == null) {
                sInstance = new Singleton();
            }
            return sInstance;
        }

    }

優秀的單例模式設計

上面的單例模式實現簡單,但會存在一些問題,比如它並不是一個線程安全的。通常在設計一個優秀的單例會參考以下 3 點:

  • 延遲加載(懶加載)
  • 線程安全
  • 防止反射破壞

Java 中的單例模式回顧

剛纔簡單實現的單例就是延遲加載,即懶漢式,因爲只有在調用 getInstance() 方法的時候纔會去初始化實例,但是,同時也是線程不安全的,原因是在多線程的場景下,假如一個線程執行了 if (sInstance == null),而創建對象是需要花費一些時間的,這時另一個線程也進入了 if (sInstance == null) 裏並執行了 代碼,這樣,就會有兩個實例被創建出來,而這顯然並不是單例所期望的。

我們看下經過改良後的懶漢式。

1. 懶漢式改良版-線程安全

    public class Singleton {

        private static Singleton sInstance;
        
        private Singleton() {}

        public static synchronized Singleton getInstance() {
            if (sInstance == null) {
                sInstance = new Singleton();
            }
            return sInstance;
        }

    }

該版本的缺點顯而易見,雖然實現了延遲加載,但是對方法添加了同步鎖,性能影響很大,所以這種方式不推薦使用。

2. 懶漢式加強版-線程安全

    public class Singleton {

        private static Singleton sInstance;

        private Singleton() {}

        public static Singleton getInstance() {
            if (sInstance == null) {
                synchronized (Singleton.class) {
                    if (sInstance == null) {
                        sInstance = new Singleton();
                    }
                }
            }
            return sInstance;
        }

    }

這裏使用了雙重檢查機制,也就是執行了兩次 if (sInstance == null) 判斷,即是延遲加載,又保證了線程安全,而且性能也不錯。

雖然這種方式可以使用,但是代碼量多了很多,也變得更復雜,我一開始理解起來就覺得特別費勁。

所以,這裏也對兩次 if (sInstance == null) 簡單做下說明:

第一次 if (sInstance == null) ,其實在多線程場景下,是並不起作用的,重要的中間的同步鎖以及第二次 if (sInstance == null),比如一個線程進入了第一次 if (sInstance == null),接着執行到了同步代碼塊,這時另一個線程也通過了第一個 if (sInstance == null),也來到了同步代碼塊,假設如果沒有第二次 if (sInstance == null),那第一個線程執行完同步代碼塊,接着第二個線程也會執行同步代碼塊,這樣就會有兩個實例被創建出來,但是如果同步代碼塊裏面加上第二次的 if (sInstance == null) 的檢測。第二個線程執行的時候,就不會再去創建實例了,因爲第一個線程已經執行並創建完了實例。這樣,雙重檢測就很好避免了這種情況。

3. 餓漢式

    public class Singleton {

        private static Singleton sInstance = new Singleton();

        private Singleton() {}

        public static Singleton getInstance() {
            return sInstance;
        }

    }

簡單直接,因爲在類初始化的過程中,會執行靜態代碼塊以及初始化靜態域來完成實例的創建,而該初始化過程是由 JVM 來保證線程安全的。至於缺點嘛,因爲類被初始化的時機有多種方式,而對於單例來說,如果不是通過調用 getInstance() 初始化,也就造成了一定的資源浪費。不過,這種方式也是可以使用的。

4. 靜態內部類

    public class Singleton {

        public static Singleton getInstance() {
            return SingletonInstance.sInstance;
        }

        private static class SingletonInstance {
            private static final Singleton sInstance = new Singleton();
        }

    }

這種方式也比較容易理解,餓漢式是利用了類初始化的過程,會執行靜態代碼塊以及初始化靜態域來完成實例的創建,而靜態內部類的方式是利用了 Singleton 類初始化的時候,但是並不會去初始化 SingletonInstance 靜態內部類,而是只有在你去調用了 getInstance()方法的時候,纔會去初始化 SingletonInstance 靜態內部類,並創建 Singleton 的實例,很巧妙的一種方式。

餓漢式和靜態內部類的方式都是利用了 JVM 幫助我們保證了線程的安全性,因爲類的靜態屬性會在第一次類初始化的時候執行,而在執行類的初始化時,別的線程是無法進入的。

推薦使用靜態內部類的方式,這種方式應該是目前使用最多的一種,同時具備了延遲加載、線程安全、效率高三個優點。

好了,回顧完我們 Java 當中的花式玩單例,我們再對照下之前優秀單例設計的 3 點要求,是不是延遲加載和線程安全這兩點已經沒有問題了。不過第三點,防止反射破壞好像還沒有說到呢。各位可以先思考下,等說完 Kotlin 的單例模式後,我們再一起來看這個問題。

Kotlin 中的單例模式

終於到了本文的重點,碼點字不容易啊,Kotlin 作爲一個同樣面向 JVM 的靜態編程語言,它的單例模式又是如何的呢。

我們先想下,首先,剛纔 Java 中的單例大部分都是通過一個靜態屬性的方式實現,那在 Kotlin 當中是不是也可以通過同樣的方式呢。

作爲一個剛入門不久的 Kotlin 菜鳥,可以比較明確的告訴你,在 Kotlin 當中是沒有 Java 的靜態方法和靜態屬性這樣的一個直接概念。所以,對於一開始從 Java 切換到 Kotlin 的開發還是有些不太習慣。不過,類似的靜態方法和屬性的機制還是有的,感興趣的同學可以去看下 Kotlin 的官方文檔,這裏就不展開了。

所以,理論上來說,你可以完全按照 Java 的方式在 Kotlin 中把單例也花式玩一遍。不過,如果僅僅只是這樣,那這篇文章應該就不叫 Kotlin 單例模式分析了,而是 Java 單例模式分析。

所以,我們來看下 Kotlin 官方文檔描述的單例是如何寫的:

    object Singletons {

    }

我擦,有沒有感覺到起飛,一個關鍵字 object 就搞定單例了,什麼懶漢式、餓漢式還是其他式...統統閃一邊去!

我們接着看下官方的說明:

Singleton may be useful in several cases, and Kotlin (after Scala) makes it easy to declare singletons, This is called an object declaration, and it always has a name following the object keyword.Object declaration's initialization is thread-safe.

在 Kotlin 當中直接通過關鍵字 object 聲明一個單例,並且它是線程安全的。

另外,還有一個很重要的一句話:

object declarations are initialized lazily, when accessed for the first time;

同時,也意味着 object 聲明的方式也是延遲加載。

有同學可能會好奇了,它是怎麼實現的呢?

很簡單,我們可以通過 Android Studio 把上面的代碼轉成我們比較容易理解的 Java 代碼再看下:

    public final class Singletons {
       public static final Singletons INSTANCE;

       static {
          Singletons var0 = new Singletons();
          INSTANCE = var0;
       }
    }

在類初始化的時候執行靜態代碼塊來創建實例,本質上和上面的餓漢式沒有任何區別嘛,看到這裏,大家應該明白過來了,這並不是什麼延遲加載嘛,頂多也就一個語法糖而已。

可是官網上明明說的是 lazily 延遲加載,一開始我對這裏也是感到很困惑。不過,因爲這是 Kotlin,還是有它的一些特別之處的。我們來簡單回顧和梳理一下類的初始化,之前,我們提過類的初始化是在特定的時機纔會發生,那究竟是哪些時機呢?

  • 創建一個類的實例的時候,如 User user = new User()
  • 調用一個類中的靜態方法,如 User.create()
  • 給類或者接口中聲明的靜態屬性賦值時,如 User.sCount = 10
  • 訪問類或者接口聲明的靜態屬性,如 int count = User.sCount
  • 通過發射也會造成類的初始化
  • 頂層類中執行 assert 語句

這裏,我們主要關心第 2、3、4 條所說的靜態相關時機所發生的類初始化,回到之前的問題,爲什麼 Kotlin 說 object 聲明的是延遲加載呢,其實可以換個角度來理解,首先,當一個類沒有被初始化的時候,也就是實例沒有創建的時候,那麼,我們都可以認爲它是延遲加載。而在 Kotlin 當中是沒有靜態方法和屬性的這樣的一個直接概念,也就是說在 object 聲明的單例中沒有靜態方法和屬性的前提下,那麼這個類是沒有其他時機被初始化的,只有當它被第一次訪問的時候,纔會去初始化。怎麼訪問呢,我們來看代碼吧:

    object Singletons {

        var name = "I am Kotlin Singletons"

    }

    fun main(args: Array<String>) {
        val singletonsName = Singletons.name
        println(singletonsName)
    }

因爲 object 聲明的屬性是可以直接通過類名的方式訪問,所以這裏猛一看會有點懵。我們換成 Java 代碼就好理解了,看下訪問代碼:

    // val singletonsName = Singletons.name 轉換成 Java 代碼就是下面的意思
    String singletonsName = Singletons.INSTANCE.getName();

也就是說,在我們第一次訪問 object 聲明的類中的屬性或者方法時,會先觸發類的初始化時機,去執行靜態代碼塊中的實例創建,也就是我們所認爲的延遲加載

其實 Kotlin 並沒有什麼所謂的黑科技,它的單例實現原理和 Java 本質上是一致的,只是,在 Kotlin 中對於一些我們熟知的特性,比如單例,實體類(data 關鍵字聲明)的實現,做了更加規範化的處理,並同時讓這些特性的實現代碼變得更簡單。
而在 Java 當中,對於這些細節,平時寫起來可能不會特別去注意,比如在單例中會定義一些靜態屬性或者靜態方法,就會導致一些並不符合我們預期的結果。

另外,通過剛纔轉換後的 Java 代碼,我們也可以確認它是線程安全的。

最後,Kotlin 中的 obejct 聲明的也是可以繼承其他父類。

防止反射破壞的問題

什麼是反射破壞?儘管我們在單例模式通過構造方法私有化,並自行提供了有且只有一個的實例獲取方法,但是,這不能防止通過反射機制去訪問這個單例類的私有構造方法進行實例化,並且,只要我願意,我想創建幾個實例就創建幾個實例。

舉個餓漢式的例子:

    public class Singleton {

        private static Singleton sInstance = new Singleton();

        private Singleton() {}

        public static Singleton getInstance() {
            return sInstance;
        }

    }
    
    /**
     * 單例反射測試
     */
    public class SingletonReflection {

        public static void main(String[] args) {
            System.out.println("getInstance = " + Singleton.getInstance().hashCode());
            try {
                Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
                constructor.setAccessible(true);
                Singleton instance = constructor.newInstance();
                System.out.println("reflection = " + instance.hashCode());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

    }
    執行結果:
    getInstance = 1915318863
    reflection = 1283928880

我們在測試代碼中,可以看到執行結果中,兩個 Singleton 類的實例 hashCode 的值不一樣,也就是說,我們通過反射的方式,成功的又創建出了一個實例。

而這也意味着之前說的所有的單例方式都可以通過反射的方式去進行實例化,從而破壞原有的單例模式,當然在 Kotlin 當中也是一樣,WTF !

好了,不着急拍桌子,我們相信辦法總比困難多,既然是通過反射訪問私有構造參數來創建實例,所以還是有辦法去避免的,繼續看代碼:

    public class Singleton {

        private static Singleton sInstance = new Singleton();

        private Singleton() {
            if (sInstance != null) {
                throw new RuntimeException("Can not create instance in this class with Singleton");
            }
        }

        public static Singleton getInstance() {
            return sInstance;
        }

    }

二話不說,我們向你拋出了一個炸彈,噢不,是異常。

這裏,會有另一個問題,如果是懶加載的單例實現方式,就不能直接通過以上的方式來阻止了。不過,辦法還是有的。這裏就不詳細說了,感興趣的同學的可以去看下這篇文章

考慮到實際場景當中,基本不會有人會這麼去做,所以,之前說的單例實現,大家還是可以愉快使用的。這裏的反射破壞也只是讓大家有個瞭解。那假設真的有人這麼心血來潮去做了,嗯,直接給丫扔個炸彈!就是這麼殘暴~

單例的一些擴展

帶參數的單例

一般來說,並不推薦在初始化單例的時候,通過構造方法中傳參數,因爲如果需要傳參數,那就意味着這個單例的對象會根據參數的不同是有可能變化的。這違反了單例模式的設計初衷。

但是在 Android 當中,我們寫單例的時候,經常會需要持有一個全局 Application Context 對象,比如這句代碼 Singleton.getInstance(contenxt).sayHello(),這個時候靜態內部類以及 Kotlin 中的 object 聲明的方式就都無法滿足了。

這裏提供兩種方式:

  1. 懶漢式加強版

    Java 代碼
    

    public class Singleton {

    private static Singleton sInstance;
    
    private Context context
    
    private Singleton(Context context) {
        this.context = context;
    }
    
    public static Singleton getInstance(Context context) {
        if (sInstance == null) {
            synchronized (Singleton.class) {
                if (sInstance == null) {
                    sInstance = new Singleton(context);
                }
            }
        }
        return sInstance;
    }
    

    }

Kotlin 代碼([參考自 Google Sample 的代碼](https://github.com/googlesamples/android-architecture-components/blob/master/BasicRxJavaSampleKotlin/app/src/main/java/com/example/android/observability/persistence/UsersDatabase.kt))

    @Database(entities = arrayOf(User::class), version = 1)
    abstract class UsersDatabase : RoomDatabase() {

        abstract fun userDao(): UserDao

        companion object {

            @Volatile private var INSTANCE: UsersDatabase? = null

            fun getInstance(context: Context): UsersDatabase =
                    INSTANCE ?: synchronized(this) {
                        INSTANCE ?: buildDatabase(context).also { INSTANCE = it }
                    }

            private fun buildDatabase(context: Context) =
                    Room.databaseBuilder(context.applicationContext,
                            UsersDatabase::class.java, "Sample.db")
                            .build()
        }
    }
  1. 提供注入方法(個人推薦)

    object Singleton {

    private var context: Context? = null
    
    fun init(context: Context?) {
        this.context = context
    }
    

    }

    class MainApplication : Application() {

    override fun onCreate() {
        super.onCreate()
        Singleton.getInstance().init(this)
    }
    

    }

推薦 2 的理由是因爲,一般單例當中持有的 Context 都是全局的,不然持有 ActivityContext 就會造成內存泄漏,所以,在這種場景下,我們可以在 Application 類中直接通過 Singleton.getInstance().init(context) 去注入一個 Context ,雖然多了一個注入的邏輯,但是好處也很明顯,更符合我們的場景設計,並且在後面的使用中,也不用每次調用這個單例的時候傳入 Context 對象

枚舉單例

是的,枚舉也是單例的一種實現,不過實際使用的場景比較少,這裏就不多介紹了,感興趣的去了解一下。

    enum class SingleEnum {
        INSTANCE
    }

另外,Kotlin 中的枚舉類有很多種用法,關於這個我再單獨寫個文章說明一下,如果有時間的話。

哎,我真是太容易給自己立 Flag 了...

多實例單例

什麼是多實例單例,就是在某些場景下,我們對一個類要求有且只有兩三個實例對象,通常的做法是在構造單例的時候,傳入一個 ID 用來標識某個實例,並存入到一個靜態的 map 集合裏

比如:

    /**
     * 根據不同 ID 存儲相應的緩存數據單例示例
     */
    public class SimplePreferences {

        private static Map<String, SimplePreferences> instanceMap = new HashMap<>();

        private SimplePreferences() {
        }

        public static SimplePreferences getInstance(String instanceId) {
            if (!instanceMap.containsKey(instanceId)) {
                synchronized (SimplePreferences.class) {
                    if (!instanceMap.containsKey(instanceId)) {
                        SimplePreferences instance = new SimplePreferences();
                        instanceMap.put(instanceId, instance);
                    }
                }
            }
            return instanceMap.get(instanceId);
        }

        public void set(...) {
            ...
        }

        public String get(...) {
            ...
        }

    }

其實在 Kotlin 中針對這種場景,可能使用工廠的模式會更適合,也更簡單,這在後面的工廠模式的分析當中,我們再來一起看一下,這裏就不做多描述了。

總結

最後,簡單總結回顧下:

  • 單例是一個簡單並有意思的設計模式
  • 一個好的單例設計要具有延遲加載、線程安全以及效率高
  • Kotlin 中的單例實現既簡單又規範
  • 單例的一些擴展知識
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章