前言
最近在学习Kotlin,由于Google粑粑把这一语言提携为官方指定,并且对于移动端等开发得天独厚的优势,作为一名AndroidDeveloper学习Kotlin是必要的。
当初在学习Java时对单例模式的六种书写与应用记忆深刻
所以这次对不同单例模式的Java与Kotlin实现进行了分别探讨。
六种模式如下:
- 饿汉模式
- 懒汉模式
- 线程安全的懒汉模式
- 双重校验锁式
- 静态内部类式
- 枚举式
饿汉模式:static final field
饿汉式其实是一种比较形象的称谓。既然饿,那么在创建对象实例的时候就比较着急,饿了嘛,于是在装载类的时候就创建对象实例。
这种方法非常简单,因为单例的实例被声明成 static 和 final 变量了,在第一次加载类到内存中时就会初始化,所以创建实例本身是线程安全的。
Kotlin引入了 object
类型,可以很容易声明单例模式
这种方式和 Java 单例模式的饿汉式一样,不过比 Java 中的实现代码量少很多,其实是个语法糖(Kotlin漫山遍野都是语法糖)。反编译生成的 class 文件后如下:
|
|
从反编译的代码可以看出 object 对象实际上还是利用了 INSTANCE 静态变量,但是在Java和Kotlin混编时,Java代码中调用则需要注意,使用如下Singleton.INSTANCE.test()
,Kolint中调用时只需要使用Singleton.test()
。
这种实现方式在类加载时就创建了单例对象,所以肯定是线程安全的,但是还是有饿汉式实现方式的问题:
- 如果构造方法中有耗时操作的话,会导致这个类的加载比较慢。
- 饿汉式一开始就创建实例,但是并没有调用,会造成资源浪费。
- 还有一个 Java 饿汉式单例模式没有的问题:无法自定义构造函数,object 中不允许 constructor 函数。
懒汉模式:线程不安全
懒汉式其实是一种比较形象的称谓。既然懒,那么在创建对象实例的时候就不着急。会一直等到马上要使用对象实例的时候才会创建,懒人嘛,总是推脱不开的时候才会真正去执行工作,因此在装载对象的时候不创建对象实例。
这段代码简单明了,而且使用了懒加载模式,但是却存在致命的问题。当有多个线程并行调用 getInstance() 的时候,就会创建多个实例。也就是说在多线程下不能正常工作。
上述代码中,我们可以发现在Kotlin实现中,我们让其主构造函数私有化并自定义了其属性访问器,其余内容大同小异。
线程安全的懒汉模式
为了解决上面的问题,最简单的方法是将整个 getInstance() 方法设为同步(synchronized)。
虽然做到了线程安全,并且解决了多实例的问题,但是它并不高效。因为在任何时候只能有一个线程调用 getInstance() 方法。但是同步操作只需要在第一次调用时才被需要,即第一次创建单例实例对象时。这就引出了双重检验锁。
大家都知道在使用懒汉式会出现线程安全的问题,需要使用同步锁,在Kotlin中,如果你需要将方法声明为同步,需要添加 @Synchronized 注解
双重校验锁式
双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次检验的话就会生成多个实例了。
有些人认为使用 volatile 的原因是可见性,也就是可以保证线程在本地不会存有 instance 的副本,每次都是去主内存中读取。但其实是不对的。使用 volatile 的主要原因是其另一个特性:禁止指令重排序优化。也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。
- 从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。
但是特别注意在 Java 5 以前的版本使用了 volatile 的双检锁还是有问题的。其原因是 Java 5 以前的 JMM (Java 内存模型)是存在缺陷的,即时将变量声明成 volatile 也不能完全避免重排序,主要是 volatile 变量前后的代码仍然存在重排序问题。这个 volatile 屏蔽重排序的问题在 Java 5 中才得以修复,所以在这之后才可以放心使用 volatile。
相信你不会喜欢这种复杂又隐含问题的方式,当然我们有更好的实现线程安全的单例模式的办法。
哇!小伙伴们惊喜不,感不感动啊。我们居然几行代码就实现了多行的Java代码。其中我们运用到了Kotlin的延迟属性 Lazy。
Lazy是接受一个 lambda 并返回一个 Lazy 实例的函数,返回的实例可以作为实现延迟属性的委托: 第一次调用 get() 会执行已传递给 lazy() 的 lambda 表达式并记录结果, 后续调用 get() 只是返回记录的结果。
这里还有有两个额外的知识点。
如果你了解以上知识点,我们直接来看Lazy的内部实现。
- Lazy内部实现
|
|
观察上述代码,因为我们传入的mode = LazyThreadSafetyMode.SYNCHRONIZED, 那么会直接走 SynchronizedLazyImpl,我们继续观察SynchronizedLazyImpl。
- Lazy接口
SynchronizedLazyImpl实现了Lazy接口,Lazy具体接口如下:
|
|
继续查看SynchronizedLazyImpl,具体实现如下:
- SynchronizedLazyImpl内部实现
|
|
通过上述代码,我们发现 SynchronizedLazyImpl 覆盖了Lazy接口的value属性,并且重新了其属性访问器。其具体逻辑与Java的双重检验是类似的。
到里这里其实大家还是肯定有疑问,我这里只是实例化了SynchronizedLazyImpl对象,并没有进行值的获取,它是怎么拿到高阶函数的返回值呢?。这里又涉及到了委托属性。
委托属性语法是: val/var <属性名>: <类型> by <表达式>。在 by 后面的表达式是该 委托, 因为属性对应的 get()(和 set())会被委托给它的 getValue() 和 setValue() 方法。 属性的委托不必实现任何的接口,但是需要提供一个 getValue() 函数(和 setValue()——对于 var 属性)。
而Lazy.kt文件中,声明了Lazy接口的getValue扩展函数。故在最终赋值的时候会调用该方法。
|
|
静态内部类式
这种方法也是《Effective Java》上所推荐的。
这种写法仍然使用JVM本身机制保证了线程安全的问题。由于静态单例对象没有作为Singleton的成员变量直接实例化,因此类加载时不会实例化Singleton,第一次调用getInstance()时将内部类SingletonHodler,在该内部类中定义了一个static类型的变量INSTANCE,此时会首先初始化这个成员变量,由Java虚拟机来保证其线程安全性,确保该成员变量只能初始化一次。由于getInstance()方法没有任何线程锁定,因此其性能不会造成任何影响。
由于SingletonHodler是私有的,除了getInstance()之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖JDK版本。
枚举式:Enum
用枚举写单例实在太简单了!这也是它最大的优点。下面这段代码就是声明枚举实例的通常做法。
|
|
我们可以通过EasySingleton.INSTANCE来访问实例,这比调用getInstance()方法简单多了。创建枚举默认就是线程安全的,所以不需要担心double checked locking,而且还能防止反序列化导致重新创建新的对象。