单例模式应该是设计模式中最为常见的一种了,今天我们来讨论一下单例模式的几种实现方式,再谈谈笔者主观偏好以及其中的为什么。
定义
单例,顾名思义,就是一个类只有一个对象实例。什么情况下一个类只需要一个实例呢?当一个类代表了一个全局的资源时,或者一个类的实例化成本很高,只需要一个实例就可以满足要求的情况下,我们就会倾向于使用单例模式。比如全局的线程池,对一个数据库连接对象,全局缓存对象等等。
照理来说,我们无需对类做任何特殊的处理,只需要保证使用的时候只实例化一次就可以了。但这种以约定作为保证的方式会随着时间的推进,无心的使用而很容易被破坏,这一点也不安全。这就是单例模式发挥作用的时候了,单例模式就是安全的保障一个类只能被实例化一次
的实现方式。
实现方式
单例模式的实现方式有很多种,按照对象实例化的时机,可以把实现方式粗略分为 Eager 和 Lazy 两种类型。下面我们就来看看一些常见的实现方式。
Eager(饿汉式)
java
public class SingletonEager {
// 1
private static final SingletonEager INSTANCE = new SingletonEager();
// 2
private SingletonEager() {
}
// 3
public static SingletonEager getInstance() {
return INSTANCE;
}
}
代码很简单, part1 声明对象并实例化,part2 把构造函数声明为 private,这样从外部就无法调用构造函数,这样保证了这个类无法在外部被实例化,part3 通过一个方法返回这个实例。这就是著名的饿汉式,Eager 翻译为急切的意思,这个翻译着实有趣。
这种实现方式利用了类的加载机制,JVM 会保证一个类只会被加载一次,在加载一个类的时候,类的 static 成员变量也会被加载,即 part1 就会执行实例化,这样就保证了 INSTANCE 会且仅仅被实例化一次。
Lazy-1(饱汉式/懒汉式)
java
public class SingletonLazy1 {
// 1
private static SingletonLazy1 INSTANCE;
// 2
private SingletonLazy1() {
}
// 3
public static SingletonLazy1 getInstance() {
if (INSTANCE == null) {
// 3.1
INSTANCE = new SingletonLazy1();
}
return INSTANCE;
}
}
对比上面的 Eager 模式,我们把 part1 的 实例化过程挪到了 part3 里面,这样在加载类的时候并不会在 part1 处就被实例化,而是在切实调用 getInstance() 的时候才会实例化,这也是为什么被称为 lazy 模式,这里的翻译同样有趣,懒汉式可以理解为 lazy 的直译,但饱汉式应该是和饿汉式对应强行套用"饱汉子不知饿汉子饥"的俗语,单独使用这个翻译并不直观。
有经验的同学会指出这里其实并不安全,如果是多线程环境的话这里有可能会出错,如果第一个线程进到 3.1 处时被切走,后面的线程可能会进入到 3.1 处,这样就会实例化多次了,说明这种模式在多线程环境确实是不够健壮的。
Lazy-2
java
public class SingletonLazy2 {
// 1
private static SingletonLazy2 INSTANCE;
// 2
private SingletonLazy2() {
}
// 3
public static synchronized SingletonLazy2 getInstance() {
if (INSTANCE == null) {
INSTANCE = new SingletonLazy2();
}
return INSTANCE;
}
}
在 lazy1 的基础上在 part3 加了同步控制,这样在多线程环境下也安全了。代价是 getInstance 方法被加了锁,调用效率会降低。于是我们又有了如下变种
Lazy-3
java
public class SingletonLazy3 {
// 1
private static SingletonLazy3 INSTANCE;
// 2
private SingletonLazy3() {
}
// 3
public static SingletonLazy3 getInstance() {
if (INSTANCE == null) {
// 3.1
synchronized (SingletonLazy3.class) {
// 3.2
if (INSTANCE == null) {
// 3.3
INSTANCE = new SingletonLazy3();
}
}
}
return INSTANCE;
}
}
我们把 lazy2 中的 synchronized 关键字挪到了 3.1,只有当 INSTANCE 还未实例化时,才会进入到 synchronized 块,加入3.2 的双重判断的原因是:如果两个线程同时执行到 3.1,即使第一个线程先获取到锁进入3.3 执行了,另外一个线程在等待完成获得锁之后依然会进入 synchronized 块,如果没有 3.2 的判断,3.3 会被再次执行。而当 INSTANCE 有值后,再调用 getInstance() 都不会进去 synchronized 块,这就是锁粒度细化
带来的好处。
性能对比
上面关于加锁的内容很多地方都这么提到过,但是关键的效率具体会降低多少呢?似乎没有人回答,我们就来测测看:首先因为 getInstance() 方法执行都会很快,多个线程同时执行 getInstance() 的几率其实非常之低,所以我们以占实际场景 99.99% 以上的单线程执行来测试一下效率:
kotlin
fun main(args: Array<String>) {
val repeatTimes = 1000_000
// part1 empty block
val emptyBlockTime = measureTimeMillis {
repeat(repeatTimes) {}
}
println("emptyBlock: $emptyBlockTime")
// part2 none Sync
val nonSyncTime = measureTimeMillis {
repeat(repeatTimes) { SingletonLazy1.getInstance() }
}
println("nonSyncTime: $nonSyncTime")
// part3 big Sync
val syncTime = measureTimeMillis {
repeat(repeatTimes) { SingletonLazy2.getInstance() }
}
println("syncTime: $syncTime")
// part4 less Sync
val syncLessTime = measureTimeMillis {
repeat(repeatTimes) { SingletonLazy3.getInstance() }
}
println("syncLessTime:$syncLessTime")
}
fun printlnWithTime(message: Any) {
println("${System.currentTimeMillis()}: $message")
}
// log
emptyBlock: 3
nonSyncTime: 6
syncTime: 7
syncLessTime:5
我们 repeat 100w 次,分别执行四个场景,part1 什么都不做,作为基准,part2 为不同步版本,part3 方法同步版本,part4 double check 同步版本。可以看出 nonSyncTime 在 emptyBlock 的基础上增了 3,syncTime 增加了 4,syncLessTime 增加了 5,在 100w 这个量级上可以看出差别并不大,这个结果我执行了多次才得到接近于预期的结果,甚至有可能 syncTime 比 nonSyncTime 还低,只有当 repeat 次数高于 1000w,两者的效率才能拉出数量级的差别,相信在后端的代码能够达成这个量级的调用是有可能的,但在 Android 端这几乎不可能。
ClassLoader Lazy
类会保证只被加载一次,子类在父类被加载的时候而自己没被访问的时候不会被加载,组合这两个特性,我们就能得到一种类加载器的懒加载版本:
java
public class SingletonLazyClassLoader {
// 1
private SingletonLazyClassLoader() {
}
// 2
public static SingletonLazyClassLoader getInstance() {
return SingletonHolder.INSTANCE;
}
// 3. Static declarations in inner classes are not supported at language level '8',upgrade to jdk16+
private static class SingletonHolder {
static SingletonLazyClassLoader INSTANCE = new SingletonLazyClassLoader();
}
}
和 lazy-2 以及 lazy-3 的版本相比,instance 不再由外层的 SingletonLazyClassLoader 持有,这样在首次加载 SingletonLazyClassLoader 类的时候就不会实例化 instance,做到了懒加载,只有在首次调用 getInstance 方法的时候才会加载内部的 SingletonHolder 类,这时 instance 才会被实例化,而因为内部类能够访问外部类的私有方法,所以能够实例化外部类,这个实例化的过程又由类加载机制帮我们保证了只会实例化一次,足够巧妙。之所以用 static 的内部类是因为在 Java 17 之前普通内部类不能持有 static 的属性,在 Java17 之后无此限制。
Enum - 另一种Eager
枚举类的每一个枚举量都只有一个实例,我们来看一看枚举的实现和细节:
java
// define
public enum SingletonEnum {
ENUM1;
}
// decompile
// access flags 0x4019
public final static enum Lcom/hunter/kotlin/coroutine/learn/other/SingletonEnum; ENUM1
// static 块
static <clinit>()V
...
ICONST_0
NEW com/hunter/kotlin/coroutine/learn/other/SingletonEnum // new 对象
DUP
LDC "ENUM1" // 赋值给 ENUM1 成员
...
可以看到 Enum 枚举量在内部也是一个 static 的成员,并且在 static 块内部做了实例化,所以这其实和我们手动实现的 Eager 方式等价。
Kotlin 中的单例
Eager
kotlin 提供了一个 object 关键字,被此修饰的就是一个单例对象:
kotlin
// 1. define
object SingletonObject {}
// decompile
public final class SingletonObject {
@NotNull
public static final SingletonObject INSTANCE;
private SingletonObject() {
}
static {
SingletonObject var0 = new SingletonObject();
INSTANCE = var0;
}
}
可以看到 decompile 后的实现与我们在上面的 Eager(饿汉式) 的实现基本等价。人生苦短,用好 kotlin 语法糖。
Lazy
kotlin 可以通过委托方式把对象的获取交给代理对象,下面我们看看如果通过委托实现 Lazy 的单例模式:
kotlin
// 1. define
class SingletonLazy private constructor() {
companion object {
val INSTANCE: SingletonLazy by lazy { SingletonLazy() }
}
}
// 2. lazy
public actual fun <T> lazy(initializer: () -> T): Lazy<T> = SynchronizedLazyImpl(initializer)
// 3. SynchronizedLazyImpl
private class SynchronizedLazyImpl<out T>(initializer: () -> T, lock: Any? = null) : Lazy<T>, Serializable {
// 3.1
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
// 3.2
if (_v1 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST")
return _v1 as T
}
// 3.3
return synchronized(lock) {
val _v2 = _value
// 3.4
if (_v2 !== UNINITIALIZED_VALUE) {
@Suppress("UNCHECKED_CAST") (_v2 as T)
} else {
// 3.5
val typedValue = initializer!!()
_value = typedValue
initializer = null
typedValue
}
}
}
}
我们在 part1 中就定义了一个可以懒加载的单例对象 INSTANCE,使用时直接调用 SingletonLazy.INSTANCE。定义的 by lazy 实际调用了 part2 的 lazy 方法,返回了一个 SynchronizedLazyImpl 对象(part3),从名字看我们可以推测内部有同步代码的实现。进入 part3 内部,首先我们可以看到 3.1 的 initializer block,就是我们在 part1 中用来创建 SingletonLazy 的部分,part3.2 做了一个检查,如果已经初始化了,就直接返回,part3.3 则是同步代码块,part3.4 在内部做了第二次判断,part3.5 则是正式做了初始化,这个实现跟我们在 Lazy-3 中的实现基本等价,但是 kotlin 再次大大简化了我们的实现,再次拥抱 kotlin。
如何应用
Eager / Lazy ?
lazy 的方法大多看来不算简单, eager 是最省事的,但代价是什么?内存提前被占用。如果一个单例对象占用的内存较大,那么这种提前占用就是一种浪费,所以我们需要 lazy 模式。但:
- 我们的单例对象似乎很多并不算大,一个最简单的对象占用的内存在 16 byte,如果采用内部类持有的方式,一个类被加载到内存中占用的内存数量级在 1kb 左右,虽然两者的内存区域不同
- 在上述的单例实现中,一个静态成员变量只有在类被主动加载(一般不会做)或者调用静态方法时才会被加载,所以只要没有其他不需要单例对象就可以调用的静态方法,类就不会被加载,这就意味着上述的 Eager 和 Lazy 其实是等价的
不要过早优化,优化应该基于现状,而不是想象。所以我推荐如果没有非常明确的情况,首先使用 Eager 方式,不要在无谓的地方增加复杂度,只有在根据数据分析出此处产生瓶颈时再做优化。
需要单例吗?
如果你要实现的只是一个工具类,那么你可能只需要一个 static 的类和方法,你甚至不需要实例化一个对象,根据自己的情况灵活处理。
防止反序列化?
关于防止反序列化,笔者曾看到一种说法,重写 readResolve 方法,返回单例对象:
java
public class Singleton implements Serializable {
private static final long serialVersionUID = 1L;
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance;
}
// 确保序列化和反序列化过程中保持单例
private Object readResolve() throws ObjectStreamException {
return instance;
}
}
但这里貌似有一个逻辑的悖论:如果反序列化没有用到序列化的数据,那序列化的意义又在哪里。笔者不曾遇到过需要序列化和反序列化的单例场景,有用过的可以在评论区互动一下。
局部单例
在某个范围内部只有一个对象实例,在全局可能有多个对象实例,我称之为局部单例,比如 DI 框架里的 scope 概念就是基于此的,后面有机会可以展开详解。
总结
单例模式就是保证只有一个对象实例的一种实现方式,按照对象的实例化时机分为 Eager 和 Lazy 模式,保证单例的机制主要有两种:类加载器模式和手动同步控制两种。在多数情况下,Eager 其实跟 Lazy 模式效果类似,所以推荐首先使用 Eager 模式,另外,用好 Kotlin,生活更美好。