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阅读 207,113评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,644评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,340评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,449评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,445评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,166评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,442评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,105评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,601评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,066评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,161评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,792评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,351评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,352评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,584评论 1 261
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,618评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,916评论 2 344

推荐阅读更多精彩内容