再谈单例模式
之前提到枚举实现是单例的最佳实现,这毋庸置疑;
不过,对比枚举和静态内部类,好像它们的区别就在于防止了反射攻击;
那么,都『攻击』了,为啥偏偏没事去改你的单例呢?直接获取更有意思的信息不是更好吗?
1. 『反射攻击』不是攻击
这里所提到的『反射攻击』的概念,实际上并不是信息安全领域的 『攻击』 的概念;
而是, 通过反射的合理利用,可以令单例失效;
那么在日常开发中,最常遇到的反射攻击就是 对象的序列化。
当单例需要实现序列化的时候,反序列化过程实际上就是使用 反射 来生成了新的实例。
那么在序列化和反序列化的过程中,单例模式就被破坏掉了。
这时,有人提出可以利用 readResolve()
方法来防止这种事情的发生;
而实际上, 单纯利用 readResolve()
也并不能防止单例被破坏;
《Effective Java 第二版》在 77 条提出:
如果依赖
readResolve()
方法来进行实例控制,带有对象引用类型的所有实例域都必须声明为transient
的。否则,那种破釜沉舟式的攻击者,就有可能在
readResolve()
方法运行之前,保护指向反序列化对象的引用。
此时,枚举类型就派上用场了,枚举为了防止这种事情的发生,单独实现了一套序列化和反序列化的机制;
大体就是利用 valueOf()
来进行反序列化,而不是使用普通的序列化机制;
同时,也禁止声明 readResolve()
和 readObject()
这类方法。
2. Kotlin 单例是懒加载的
下面是 Kotlin 单例的反编译 Java 代码
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
单例提前进行初始化:
- 反射
- 调用类中其他的静态变量
对于反射,一个很典型的应用场景就是使用 classpath scanner 进行注解扫描;
不过,JB 的工程师提出,classpath scanner 并不需要反射来实现注解扫描[1];
于是我随便找了一个 classpath scanner: fast-classpath-scanner;
经过使用之后,发现即使打印出了单例的信息,但是 JVM 只加载了 main
方法的类,而并没有加载单例。
相关的结果在 这个 gist 中。
单例的名字叫
Test
,而main()
方法类的名字叫SingletonTest
对于第二种情况,在 Kotlin 中是不存在的。为什么呢?
因为对于 Kotlin object
中,声明值的方法只有 var
val
和 const val
三种;
对于前两种,虽然反编译出来的代码指明这样的确定义了两个静态的值;
var
的情况:
val
的情况:
但是,当你使用的时候,却是通过 INSTANCE
来引用的。
这样无论如何都会导致单例的实例化。
而使用 const val
的确得到了一个 public static
的值;
但是,当你使用的时候,编译器会自动替换为 字面量,不会导致单例加载;
所以,综上所述,Kotlin 中 object
单例是懒加载的。