单例设计模式有着非常广泛的应用,而平时我们接触的都是些Java的实现方式,关于Kotlin的单例模式则很少被提及,所以我们就来聊聊Kotlin单例的那些事儿。
1、单例模式的相关概念
定义:
确保某个类只有一个实例,而且自行实例化并向整个系统提供这个实例。
简单解读一下就是:单例的类负责创建自己的对象,同时确保只有一个对象被创建;这个类提供一种访问其唯一对象的方式,可以直接访问,不需要实例化该类的对象。
使用场景:
确保某个类有且只有一个对象的场景,避免产生多个对象消耗过多的资源,或者某些类型的对象有些只能有一个。
举个栗子:当我们访问IO和数据库等资源时,每创建一个对象需要消耗很多资源,这时就要考虑使用单例模式。
UML类图:
总的来说,实现单例模式有下面几个关键点:
- 构造函数私有化,使外部不能直接构造单例对象;
- 暴露公共静态方法或枚举,返回单例对象;
- 确保线程安全,保证多线程环境下也仅有一个实例对象;
- 确保单例对象在反序列化时不会创建新的对象。
真实使用中不一定要同时满足所有条件,尤其在Android对并发性要求不高的时候,仅满足条件的前两条也是可以。
2、单例模式的实现方式
2.1、饿汉式单例:
饿汉式单例就是声明一个静态对象,在类被第一次加载的时候,就完成静态对象的实例化。
Java篇:
public class SingletonJava {
// 在类内部实例化一个私有的实例
private static final SingletonJava INSTANCE = new SingletonJava();
// 构造函数私有,外部无法访问
private SingletonJava() { }
// 共有的静态函数,对外暴露获取单例对象的接口
public static SingletonJava getInstance() {
return INSTANCE;
}
}
这种写法比较容易理解,也应该都有接触过,我们直接看一下如何在Kotlin实现类似效果。
Kotlin篇:
object SingletonKotlin { }
没开玩笑,这真的是Kotlin的饿汉式单例,爽到难以置信。这种写法在Kotlin中有自己的称呼:对象声明。
- 何为对象声明,我们在
object
关键字后面跟一个名称,就可以获取一个单例对象; - 像变量声明一样,对象声明不是一个表达式,不能用在赋值语句的右边;
- 而且对象声明的初始化过程是线程安全的。
如此神奇的效果,我们怎么能忍住不一探究竟,所以我们把Kotlin的自节码进行了反编译。
查看Kotlin的字节码:Tools
→ Kotlin
→ Show Kotlin Bytecode
,然后点击字节码的DECOMPILE
进行反编译:
public final class SingletonKotlin {
public static final SingletonKotlin INSTANCE;
private SingletonKotlin() { }
static {
SingletonKotlin var0 = new SingletonKotlin();
INSTANCE = var0;
}
}
愣了,这分明就是我们饿汉式单例的另一种写法!难怪可以实现相同的单例效果,原来是Kotlin通过语法糖为我们做了简化封装。
2.2、懒汉式单例:
懒汉式单例就是声明一个静态对象,并且在用户第一次调用的时进行初始化。
Java篇:
public class SingletonJava {
private static SingletonJava INSTANCE;
private SingletonJava() { }
public static SingletonJava getInstance() {
if (INSTANCE == null) {
INSTANCE = new SingletonJava();
}
return INSTANCE;
}
}
Kotlin篇:
// 构造函数私有化
class SingletonKotlin private constructor() {
// 伴生对象,类似于Java的静态代码块
companion object {
// 声明私有对象,并重写get方法
private var mInstance: SingletonKotlin? = null
get() {
// 如果对象为空,则进行实例化
field = field ?: SingletonKotlin()
return field
}
// 对外暴露获取单例对象的接口
fun getInstance(): SingletonKotlin = mInstance!!
}
}
Java的写法大家都应该很熟悉了,而Kotlin的写法则有些不同,毕竟Kotlin也有它自己的语言特性:
被companion
标记的这块代码,在Kotlin中被称为伴生对象:类似于Java的静态代码块,这样它就与外部类关联在一起,我们可以直接通过外部类访问到对象的内部元素。
而用过Kotlin的都知道,Kotlin的属性自带Getter
和Setter
(对于var属性,下同)。
所以我们在内部声明一个私有的mInstance
,自定义它的Getter
;当对象为空是进行实例化,当对象不为空时,直接返回实例对象。这样我们既满足了构造函数的私有化,有对外暴露了对象的获取方法。
小结一下:
- 懒汉式单例只有在使用时才会进行实例化,在一定程度上节约了资源;
- 但第一次加载时需要及时进行实例化,反应稍慢;
- 而且这种写法是非线程安全的,适用于单线程环境,不推荐使用。
2.3、线程安全的懒汉式:
为了解决上述线程安全性的问题,我们使用同步锁适应多线程的环境。
Java篇:
public class SingletonJava {
private static SingletonJava INSTANCE;
private SingletonJava() { }
public static synchronized SingletonJava getInstance() {
if (INSTANCE == null) {
INSTANCE = new SingletonJava();
}
return INSTANCE;
}
}
Kotlin篇:
class SingletonKotlin private constructor() {
companion object {
private var mInstance: SingletonKotlin? = null
get() {
field = field ?: SingletonKotlin()
return field
}
@Synchronized
fun getInstance(): SingletonKotlin = mInstance!!
}
}
这种写法和上面的懒汉式写法类似,仅仅是多了一层同步锁,从而保证在多线程环境下的线程安全。由于每次对象的获取都会对整个类进行加锁,所以运行效率不高,实际使用中并不推荐。
2.4、双重校验锁式(DCL):
DCL单例既能在需要时进行初始化单例,又能保证线程安全,且单例对象实例化之后,不再进行同步锁。
Java篇:
public class SingletonJava {
private volatile static SingletonJava INSTANCE;
private SingletonJava() { }
public static SingletonJava getInstance() {
if (INSTANCE == null) {
synchronized (SingletonJava.class) {
if (INSTANCE == null) {
INSTANCE = new SingletonJava();
}
}
}
return INSTANCE;
}
}
这段代码的精髓就是getInstance
方法的两层非空判断:
- 第一层为了避免不必要的同步,只需在第一次创建实例时同步;
- 第二层为了在对象为null的情况下创建实例,防止别的线程抢先初始化了。
可是仅靠getInstance
方法就能解决并发线程安全性的问题吗?
我们一起看下INSTANCE = new SingletonJava()
这行代码,这行代码都干了些什么呐,可基本拆分为三个步骤:
- 给
INSTANCE
单例对象分配内存空间; - 调用
SingletonJava()
构造函数,初始化成员对象; - 将
INSTANCE
对象指向分配的内存空间(此时INSTANCE
不为null)。
由于Java内存模型(JVM)允许指令重排,执行顺序不一定是1 → 2 → 3
,也可能是1 → 3 → 2
。
虽然单线程环境下,指令重排并不会影响最终结果,但会影响到多线程并发执行的正确性:
- 如果线程A按
1 → 3
执行,还没有执行到2
; - 此时被切换带线程B,由于
INSTANCE
已经不为null了,会被线程B直接取走; - 而此时
INSTANCE
是未完全初始化的,线程B直接使用将会出错。
结合并发线程安全的三要素:原子性、可见性、有序性。
我们知道这种写法是有缺陷的,可是怎么解决上述问题呐?答案就是我们上述代码用到的volatile
关键字。
-
volatile
可以禁止指令重排,保证有序性; -
volatile
还可以保证可见性,强制INSTANCE
对象每次从主存中读取。
这里为什么不提原子性呐,因为原子性我们已经通过synchronized
来保证了。关于volatile
的更多内容,大家可以参考:Java并发编程:volatile关键字解析。
Kotlin篇:
class SingletonKotlin private constructor() {
companion object {
@Volatile
private var mInstance: SingletonKotlin? = null
get() {
field = field ?: synchronized(this) { field ?: SingletonKotlin() }
return field
}
fun getInstance() = mInstance!!
}
}
看到这里你可能会觉得:Kotlin也不过如此,好像除了语法糖也没什么了。要是这么想,结论就下早了,其实针对DCL的写法Kotlin是有杀手锏的。
Kotlin之:延迟属性 lazy
class SingletonKotlin private constructor() {
companion object {
val instance: SingletonKotlin by lazy { SingletonKotlin() }
}
}
如果告诉你这个lazy
关键字就是DCL单例,你会不会觉得香?不过要说lazy
我们还是先来聊聊委托属性。
委托属性:
val/var <属性名>: <类型> by <表达式>
, by
后面的表达式就是该属性的委托。
属性的委托不需要实现接口,但是需要提供一个getValue()
函数与 setValue()
函数(对于var属性,下同)。效果类似于自定义的Getter
和Setter
,因为属性对应的get()
、set()
方法会被委托给它的getValue()
、setValue()
方法。
百闻不如一见,我们还是写个简单的委托属性,大家一起看下:
class MyStringDelegate {
private var value = "默认值2333..."
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {
return value
}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {
this.value = value
println("我被赋值:$value")
}
}
getValue()
、setValue()
方法的参数比较拗口,为了避免手写出现错误,也可以通过继承ReadWriteProperty
实现其中的方法,效果是一样的。
-
thisRef
:为进行委托的类的对象,必须与属性所有者类型(对于扩展属性——指被扩展的类型)相同或者是它的超类型; -
property
:为进行委托属性的对象本身,必须是类型KProperty<*>
或其超类型; -
value
(setValue):必须与属性同类型或者是它的子类型。
fun main(args: Array<String>) {
// 将 a 的值委托给 MyStringDelegate
var a: String by MyStringDelegate()
println(a)
a = "Hello World"
println(a)
}
运行上面的代码,我们得到了如下结果:
默认值2333...
我被赋值:Hello World
Hello World
以上结果符合预期,因为在每个委托属性的实现的背后,Kotlin 编译器都会生成辅助属性并委托给它:取值时将调用getValue()
方法,赋值时将调用getValue()
方法,这一点在查看反编译的字节码也可以证实。
而lazy
则是Kotlin标准库中提供惰性值的工厂方法,下面我们将针对lazy
实现单例效果的过程进行分析。
lazy
源码分析:
lazy
是可以指定线程安全模式的,这里我们将对默认模式LazyThreadSafetyMode.SYNCHRONIZED
进行分析。点击查看lazy
,我们得到:
public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)
initializer: () -> T
表示lazy
可以接收一个带T
型返回值的lambda表达式,这里有疑问可以参考:高阶函数与 lambda 表达式。
我们继续追踪SynchronizedLazyImpl
,发现它实现了Lazy
接口:
public interface Lazy<out T> {
// 当前实例的延迟初始化值,一旦实例化将不能更改
public val value: T
// 当前实例是否已经初始化,如果实例化了将会一直返回true,并且将不再初始化
public fun isInitialized(): Boolean
}
观察Lazy
接口,我们发现它提供了单例的指导思想,保证只有一个实例。那就让我们看一下SynchronizedLazyImpl
是如何具体实现的:
private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
// 由于Kotlin的形参是val类型,定义一个var类型的参数方便使用
private var initializer: (() -> T)? = initializer
// 定义一个默认结果,并指定默认值
@Volatile private var _value: Any? = UNINITIALIZED_VALUE
private val lock = lock ?: this
override val value: T
get() {
val _v1 = _value
// 若引用不同,则已经完成初始化,直接返回结果
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}
return synchronized(lock) {
val _v2 = _value
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
} else {
// 执行lambda表达式,计算结果
val typedValue = initializer!!()
// 将结果赋值,下次可直接返回结果,无需再次计算
_value = typedValue
initializer = null
typedValue
}
}
}
// 部分代码省略。。。
}
上述代码重写了Lazy
接口value属性的getter
:在第一次调用get()
方法会执行lambda表达式并记录结果,后续调用get()
方法只是返回记录的结果,实现逻辑是DCL一致的。
而作为委托属性的一份子,lazy
为属性赋值也需要提供getValue()
方法。所以lazy
在Lazy
接口中提供了getValue()
的扩展函数,将计算结果value返回:
@kotlin.internal.InlineOnly
public inline operator fun <T> Lazy<T>.getValue(thisRef: Any?, property: KProperty<*>): T = value
至此就是lazy
实现DCL单例的全过程,大大简化了我们对DCL单例的使用。
2.5、静态内部类模式:
Java篇:
public class SingletonJava {
// 私有的构造方法
private SingletonJava() { }
// 对外提供获取实例的静态方法
public static SingletonJava getInstance() {
return SingletonHolder.INSTANCE;
}
// 在静态内部类中初始化实例对象
private static class SingletonHolder {
private static final SingletonJava INSTANCE = new SingletonJava();
}
}
Kotlin篇:
class SingletonKotlin private constructor() {
companion object {
val instance = SingletonHolder.INSTANCE
}
private object SingletonHolder {
val INSTANCE = SingletonKotlin()
}
}
两种写法没什么差别,只是Kotlin看着更精简一些,那这种写法到底有什么好处呐:
首先,在第一次加载Singleton
类时并不会初始化INSTANCE
,只有在第一次调用getInstance()
方法时才会进行初始化操作。而由于静态内部类的特性,在第一次调用getInstance()
方法时,虚拟机会去加载SingletonHolder
类;这种方式不仅能够保证线程安全,也能够保证单例对象的唯一性,同时也延迟了单例的实例化。
总得来说,静态内部类的写法优点和DCL写法类似;而且不会被反射入侵,因为反射不能从外部类获取内部类的属性。不足就是需要两个类去做到这一点,虽然不会创建静态内部类的对象,但是其Class对象还是会被创建,而且是属于永久的对象。瑕不掩瑜,这种写法依旧是种比较推荐的单例实现方式。
2.6、Enum单例:
Java篇:
public enum SingletonEnumJava {
INSTANCE;
public void doSomething() {
System.out.println("doSomething: Java");
}
}
Kotlin篇:
enum class SingletonEnumKotlin {
INSTANCE;
fun doSomething() {
println("doSomething: Kotlin")
}
}
使用枚举来实现单例效果,确实是一种很骚的操作。枚举类与普通的类一样,也可以有自己的字段、方法,甚至实现一个或多个接口(Interface
)。但枚举类不能作为子类继承其他类,也不能被继承,因为枚举反编译是final
类型的。
使用枚举的究竟有何种优势呐,首先写法简单算一个;重要的是枚举实例的创建是线程安全的;而且在任何情况下它都是一个单例,即使反序列化也不会创建新的对象,而且JVM 还会阻止通过反射获取枚举类的私有构造方法。
2.7、容器单例模式:
Java篇:
public class SingletonManagerJava {
private static Map<String, Object> map = new HashMap<>();
public SingletonManagerJava() { }
public static void registerService(String key, Object instance) {
if (!map.containsKey(key)) {
map.put(key, instance);
}
}
public static Object getService(String key) {
return map.get(key);
}
}
Kotlin篇:
class SingletonManagerKotlin() {
private val map = mutableMapOf<String, Any>()
fun registerService(key: String, instance: Any) {
if (!map.containsKey(key)) {
map[key] = instance
}
}
fun getService(key: String) = map[key]
}
在程序的初始,将多种单例类型注入到统一的管理类中,使用时根据key值获取对应的单例对象。使用这种方式我们可以管理多种类型的单例,并且可以通过统一的接口进行获取操作,降低了用户的使用成本,也对用户隐藏了具体实现,降低了耦合度。
此种方式一般用于系统层面,如Android的系统服务就是通过此种方式进行管理,使用时可以通过getSystemService()
方法获取具体的服务对象。
2.8、补充:
虽然单例的实现方式已经介绍完了,但前面我们有提到反序列化相关的问题。枚举的内部实现了Serializable
接口,并且自己处理了序列化相关操作,但是其他实现方式做不到啊。
如果需要序列化相关的操作,首先单例类需要实现Serializable
接口,并且重写readResolve()
方法,由我们自己控制对象的反序列化。不然反序列化时会通过特殊的途经创建一个新的实例,相当于调用了该类的构造函数。
为了避免这种情况,我们要做的就是在readResolve()
方法中将mInstance
对象返回:
// Java篇
private Object readResolve() throws ObjectStreamException{
return mInstance;
}
// Kotlin篇
@Throws(ObjectStreamException::class)
private fun readResolve() = mInstance
3、总结
无论使用那种形式实现单例模式,核心原理都是将构造函数私有化,并且通过静态方法获取唯一的实例。而在方案选取时我们会从:线程安全性、延迟初始化、反序列化、使用便利性等几方面进行考虑。
在Java中,我们使用比较频繁的有饿汉式、DCL、静态内部类的实现方式。而在Kotlin中由于语法糖的存在,object
和lazy
的实现方式将成为我们的首选,静态内部类的写法会略显繁琐。
而如果考虑到序列化的情况,我们也可以使用枚举的实现方式,枚举在两种语言的使用可谓是无差别的。
内容参考:
Android 源码设计模式解析与实战
Kotlin下的5种单例模式
Java并发编程:volatile关键字解析