Kotlin 設計模式系列之單例模式

寫在前面

前段時間在回顧 Java 當中的 23(泛指并非只有23) 種設計模式,最近又在學習 Kotlin ,然后,便萌生了一個想法,是不是可以把兩者結合起來,考慮到我是那種學完就忘的人,那就通過寫筆記的形式把學習過程記錄下來,加深印象,但是我的自制力又比較差,難以堅持下去,那就再通過一個系列文章分享的方式督促自己吧。

于是,一個 Kotlin 的設計模式系列文章的 Flag 就這么立下來了。這是本系列文章的第一篇,目前計劃是在保證質量的前提下,一周至少要完成一篇,挑戰也比較大,實際上也是邊學邊寫。是不是和美劇的風格比較像(邊拍邊寫)。既然這樣,作為第一篇必須是個大殺器。

所以,本篇文章內容還是比較多的,但是強烈建議你耐心看完,相信你會對單例模式有一個很清晰的認識,也歡迎互相交流學習。當然,如果你覺得內容確實挺不錯的,記得點贊并關注哦。

好了,話不多說,接下來進入正文吧。

單例模式介紹

單例模式是一個比較簡單的設計模式,同時也是挺有意思的一個模式,雖然看起來簡單,但是可以玩出各種花樣。比如 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 volatile 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 的代碼

     @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()
         }
     }
    
  2. 提供注入方法(個人推薦)

     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 ConcurrentHashMap<>();

        private SimplePreferences() {
        }

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

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

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

    }

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

總結

沒想到碼了這么多字,一個單例模式寫了快五千字,有點不敢想象接下里的文章還怎么寫...不過,有耐心讀到這里的同學,應該都是真愛粉,希望可以讓你有所收獲!

最后,簡單總結回顧下:

  • 單例是一個簡單并有意思的設計模式
  • 一個好的單例設計要具有延遲加載、線程安全以及效率高
  • Kotlin 中的單例實現既簡單又規范
  • 單例的一些擴展知識

關于作者

image
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 228,606評論 6 533
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,582評論 3 418
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,540評論 0 376
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,028評論 1 314
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,801評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,223評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,294評論 3 442
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,442評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,976評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,800評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,996評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,543評論 5 360
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,233評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,662評論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,926評論 1 286
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,702評論 3 392
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,991評論 2 374

推薦閱讀更多精彩內容