Kotlin設計模式解析之單例

yonglan.whl發表於2018-09-20

單例模式介紹

單例模式是一個比較簡單的設計模式,同時也是挺有意思的一個模式,雖然看起來簡單,但是可以玩出各種花樣。比如 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 中的單例實現既簡單又規範
  • 單例的一些擴充套件知識


相關文章