单例模式的注意点
单例模式相信大家都不陌生, 我们不讨论单例模式的几种写法及其优劣. 今天我们单独拎出单例的几种实现来看看如何有效的抵御反射及序列化的攻击. 如果不了解反射和序列化的可以看这两篇文章.
反射
序列化
单例模式与反射
单例模式最根本的在于类只能有一个实例, 如果通过反射来构建这个类的实例, 单例模式就会被破坏, 下面我们通过例子来看下:
- /**
- * 静态内部类式单例模式
- */
- class Singleton implements Serializable{
- private static class SingletonClassInstance { private static final Singleton instance = new Singleton();
- }
- // 方法没有同步, 调用效率高
- public static Singleton getInstance() {
- return SingletonClassInstance.instance;
- }
- private Singleton() {}
- }
复制代码
相信大家对于这个单例的这种实现方式肯定不陌生, 下面我们来看看通过反射来创建类实例会不会破坏单例模式. main 函数代码如下:
- Singleton sc1 = Singleton.getInstance();
- Singleton sc2 = Singleton.getInstance();
- System.out.println(sc1); // sc1,sc2 是同一个对象
- System.out.println(sc2);
- /* 通过反射的方式直接调用私有构造器 (通过在构造器里抛出异常可以解决此漏洞)*/
- Class<Singleton> clazz = (Class<Singleton>) Class.forName("com.learn.example.Singleton");
- Constructor<Singleton> c = clazz.getDeclaredConstructor(null);
- c.setAccessible(true); // 跳过权限检查
- Singleton sc3 = c.newInstance();
- Singleton sc4 = c.newInstance();
- System.out.println("通过反射的方式获取的对象 sc3:" + sc3); // sc3,sc4 不是同一个对象
- System.out.println("通过反射的方式获取的对象 sc4:" + sc4);
复制代码
下面我们来看输出:
- com.learn.example.Singleton@52e922
- com.learn.example.Singleton@52e922
通过反射的方式获取的对象 sc3:com.learn.example.Singleton@25154f
通过反射的方式获取的对象 sc4:com.learn.example.Singleton@10dea4e
复制代码
我们看到正常的调用 getInstance 是符合我们预期的, 如果通过反射 (绕过检查, 通过反射可以调用私有的), 那么单例模式其实是失效了, 我们创建了两个完全不同的对象 sc3 和 sc4. 我们如何来修复这个问题呢? 反射需要调用构造函数, 那我们可以在构造函数里面进行判断. 修复代码如下:
- class Singleton implements Serializable{
- private static class SingletonClassInstance {
- private static final Singleton instance = new Singleton();
- }
- // 方法没有同步, 调用效率高
- public static Singleton getInstance() {
- return SingletonClassInstance.instance;
- }
- // 防止反射获取多个对象的漏洞
- private Singleton() {
- if (null != SingletonClassInstance.instance)
- throw new RuntimeException();
- }
- }
复制代码
我们看到唯一的改进在于, 构造函数里面添加了判断, 如果当前已有实例, 通过抛出异常来阻止反射创建对象. 我们来看下输出:
- com.learn.example.Singleton@52e922
- com.learn.example.Singleton@52e922
- Exception in thread "main" java.lang.reflect.InvocationTargetException
- at sun.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
- at sun.reflect.NativeConstructorAccessorImpl.newInstance(Unknown Source)
- at sun.reflect.DelegatingConstructorAccessorImpl.newInstance(Unknown Source)
- at java.lang.reflect.Constructor.newInstance(Unknown Source)
- at com.learn.example.RunMain.main(RunMain.java:45)
- Caused by: java.lang.RuntimeException
- at com.learn.example.Singleton.<init>(RunMain.java:28)
- ... 5 more
复制代码
我们看到, 我们通过反射创建对象的时候会抛出异常了.
单例模式与序列化
除了反射以外, 反序列化过程也会破坏单例模式, 我们来看下现阶段反序列化输出的结果:
- com.learn.example.Singleton@52e922
- com.learn.example.Singleton@52e922
对象定义了 readResolve() 方法, 通过反序列化得到的对象: com.learn.example.Singleton@16ec8df
复制代码
我们看到反序列化后的对象和原对象 sc1 已经不是同一个对象了. 我们需要对反序列化过程进行处理, 处理代码如下:
- // 防止反序列化获取多个对象的漏洞.
- // 无论是实现 Serializable 接口, 或是 Externalizable 接口, 当从 I/O 流中读取对象时, readResolve() 方法都会被调用到.
- // 实际上就是用 readResolve() 中返回的对象直接替换在反序列化过程中创建的对象
- private Object readResolve() throws ObjectStreamException {
- return SingletonClassInstance.instance;
- }
复制代码
我们从注释里面也可以看出来, readResolve 方法会将原来反序列化出来的对象进行覆盖. 我们丢弃原来反序列化出来的对象, 使用已经创建的好的单例对象进行覆盖. 我们来看现在的输出:
- com.learn.example.Singleton@52e922
- com.learn.example.Singleton@52e922
对象定义了 readResolve() 方法, 通过反序列化得到的对象: com.learn.example.Singleton@52e922
复制代码
关于 readResolve 这个方法的详细解释可以看这篇文章:
序列化的相关方法介绍 https://www.cnblogs.com/-9527/p/5222715.html
使用枚举实现单例
Effective Java 中推荐使用枚举来实现单例, 因为枚举实现单例可以阻止反射及序列化的漏洞, 下面我们通过例子来看下:
- class Resource{}
- /**
- * 使用枚举实现单例
- */
- enum SingletonEnum{
- INSTANCE;
- private Resource instance;
- SingletonEnum() {
- instance = new Resource();
- }
- public Resource getInstance() {
- return instance;
- }
- }
复制代码
我们在 main 方法中调用代码:
- Resource resource1 = SingletonEnum.INSTANCE.getInstance();
- Resource resource2 = SingletonEnum.INSTANCE.getInstance();
- System.out.println(resource1);
- System.out.println(resource2);
复制代码
输出如下:
- com.learn.example.Resource@52e922
- com.learn.example.Resource@52e922
复制代码
我们看到, 通过枚举我们实现了单例, 那么枚举是如何保证单例的 (如何满足多线程及序列化的标准的)? 其实枚举是一个普通的类, 它继承自 java.lang.Enum 类. 我们将上面的 class 文件反编译后, 会得到如下代码:
- public final class SingletonEnum extends Enum<SingletonEnum> {
- public static final SingletonEnum INSTANCE;
- public static SingletonEnum[] values();
- public static SingletonEnum valueOf(String s);
- static {};
- }
复制代码
由反编译后的代码可知, INSTANCE 被声明为 static 的, 在类加载过程, 可以知道虚拟机会保证一个类的 () 方法在多线程环境中被正确的加锁, 同步. 所以, 枚举实现是在实例化时是线程安全.
枚举实现与序列化
Java 规范中规定, 每一个枚举类型极其定义的枚举变量在 JVM 中都是唯一的, 因此在枚举类型的序列化和反序列化上, Java 做了特殊的规定.
在序列化的时候 Java 仅仅是将枚举对象的 name 属性输出到结果中, 反序列化的时候则是通过 java.lang.Enum 的 valueOf() 方法来根据名字查找枚举对象.
也就是说, 以下面枚举为例, 序列化的时候只将 INSTANCE 这个名称输出, 反序列化的时候再通过这个名称, 查找对于的枚举类型, 因此反序列化后的实例也会和之前被序列化的对象实例相同.
Effective Java 中单元素的枚举类型被作者认为是实现 Singleton 的最佳方法.
来源: https://juejin.im/post/5b9a29676fb9a05d32513bd0