0%

再谈单例模式

之前提到枚举实现是单例的最佳实现,这毋庸置疑;

不过,对比枚举和静态内部类,好像它们的区别就在于防止了反射攻击;

那么,都『攻击』了,为啥偏偏没事去改你的单例呢?直接获取更有意思的信息不是更好吗?

1. 『反射攻击』不是攻击

这里所提到的『反射攻击』的概念,实际上并不是信息安全领域的 『攻击』 的概念;

而是, 通过反射的合理利用,可以令单例失效

那么在日常开发中,最常遇到的反射攻击就是 对象的序列化

当单例需要实现序列化的时候,反序列化过程实际上就是使用 反射 来生成了新的实例。

那么在序列化和反序列化的过程中,单例模式就被破坏掉了。

这时,有人提出可以利用 readResolve() 方法来防止这种事情的发生;

而实际上, 单纯利用 readResolve() 也并不能防止单例被破坏;

《Effective Java 第二版》在 77 条提出:

如果依赖 readResolve() 方法来进行实例控制,带有对象引用类型的所有实例域都必须声明为 transient 的。

否则,那种破釜沉舟式的攻击者,就有可能在 readResolve() 方法运行之前,保护指向反序列化对象的引用。

此时,枚举类型就派上用场了,枚举为了防止这种事情的发生,单独实现了一套序列化和反序列化的机制;

大体就是利用 valueOf() 来进行反序列化,而不是使用普通的序列化机制;

同时,也禁止声明 readResolve()readObject() 这类方法。

2. Kotlin 单例是懒加载的

下面是 Kotlin 单例的反编译 Java 代码

1
2
3
4
5
6
7
8
9
10
11
public final class Test {
public static final Test INSTANCE;

private Test() {
INSTANCE = (Test)this;
}

static {
new Test();
}
}

虽然它和所谓的饿汉式 Java 单例很类似,但是在实际使用中,它是 懒加载 的。

为什么呢?

原因就在于 JVM 类的加载时机;

JVM Specification 中在 准备阶段 中提出:

explicit initializers for static fields are executed as part of initialization (§5.5), not preparation.

所以,上面的 INSTANCE 的实例化,即 static 块是在类加载的 初始化阶段 进行的;

而对于初始化阶段,JVM Specification 强制规定了有且仅有 5 种情况 可以触发初始化阶段;

而这 5 种情况,都是你真正使用到类的实例的时候才会出现的;

根据这 5 种情况,再结合 Kotlin object 的单例语法和使用,可以得出有且仅有 2 种情况会导致 object 单例提前进行初始化:

  1. 反射
  2. 调用类中其他的静态变量

对于反射,一个很典型的应用场景就是使用 classpath scanner 进行注解扫描;

不过,JB 的工程师提出,classpath scanner 并不需要反射来实现注解扫描[1]

于是我随便找了一个 classpath scanner: fast-classpath-scanner

经过使用之后,发现即使打印出了单例的信息,但是 JVM 只加载了 main 方法的类,而并没有加载单例。

相关的结果在 这个 gist 中。

单例的名字叫 Test,而 main() 方法类的名字叫 SingletonTest

对于第二种情况,在 Kotlin 中是不存在的。为什么呢?

因为对于 Kotlin object 中,声明值的方法只有 var valconst val 三种;

对于前两种,虽然反编译出来的代码指明这样的确定义了两个静态的值;

var 的情况:

val 的情况:

但是,当你使用的时候,却是通过 INSTANCE 来引用的。

这样无论如何都会导致单例的实例化。

而使用 const val 的确得到了一个 public static 的值;

但是,当你使用的时候,编译器会自动替换为 字面量,不会导致单例加载;

所以,综上所述,Kotlin 中 object 单例是懒加载的。


  1. https://discuss.kotlinlang.org/t/kotlin-singleton-implementation/2853/6?u=omysho ↩︎