摘要:反射攻击首先我们来看一下反射调用,以双重检验方式为例反射攻击输出结果是反射攻击结果私有构造方法被调用次私有构造方法被调用次从结果可以看到,私有的构造函数被调用了两次,也就是说这样的单例模式并不安全。
保证一个类仅有一个实例,并提供一个访问它的全局访问点。
——艾迪生维斯理 《设计模式》
版权声明:本文为 冬夏 原创发表在公众号「Android从入门到精通」,可以随意转载,但请注明出处。概述
在我们日常编写程序的时候,经常需要一种这样的对象。我们希望整个系统只有一个这样的对象,不论在什么时候和不论在哪里获取这个对象的时候,获得的都是同一个对象。
比如说系统的任务管理器,我们希望整个系统只有一个任务管理器,不论什么时候打开任务管理器,都可以看到当前系统的所有任务,而不是把任务分散在很多个任务管理器里。
又比如说打印机,当电脑连接上一台打印机的时候,我们会希望不管是在文档A里使用或者在文档B里使用的时候,都是同一台打印机,而且能够按顺序打印。
我们把这种类似的需求不断总结并归纳起来,就成了单例模式。
单例模式可以说是所有设计模式里面最简单的了,但是要灵活并且准确地使用它也不是那么容易的。
首先观察一下单例模式的 UML 图。
从 UML 图中我们可以观察到单例模式的几个特点
私有的、静态的实例对象
私有的构造函数
公有的、静态的获取实例对象的方法
那么,什么样的代码可以同时满足这几个特点呢?
懒汉模式所谓的懒汉模式,就是一开始并不实例化对象,等到需要使用的时候才实例化。
{% codeblock 懒汉模式 lang:java %} public class Singleton { private static Singleton instance = null; private Singleton(){} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } } {% endcodeblock %}
从上面的代码我们可以看到,当第一次获取 Singleton 实例的时候,instance 为空,将创建 Singleton 对象,并赋值给 instance 变量。以后的每次一获取都将获得第一次创建的 Singleton 对象,从而实现了唯一性。
线程安全验证仔细想想这段代码,可能存在什么问题呢?
假设有这么一种情况, Singleton 对象还没有创建,这时候有很多个线程同时获取 Singleton 对象,这时候会发生什么呢?
用下面的代码可以验证
{% codeblock 懒汉模式 线程安全验证 lang:java %} public class Singleton { private static int count = 0; private static Singleton instance = null; private Singleton(){ try { Thread.sleep(10); }catch (InterruptedException e){ } System.out.println("Singleton 私有构造方法被调用 " + ++count + "次"); } public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } } public class Test { public static void main(String[] args){ Runnable runnable = new Runnable() { @Override public void run() { Singleton singleton = Singleton.getInstance(); System.out.println("当前线程:" + Thread.currentThread().getName() + " Singleton: " + singleton.hashCode()); } }; for (int i = 0; i < 10; i++){ new Thread(runnable).start(); } } } {% endcodeblock %}
从上面的代码可以看到,我们对懒汉模式做了一点小修正,在创建 Singleton 对象的时候让当前线程休眠了10ms,这主要是因为计算机运算速度太快了,不让当前线程休眠一下的话很难出现想要的结果。关于休眠我们可以把它想象成创建对象的过程中需要消耗一定的时间。
运算部分结果如下:
{% codeblock 懒汉模式 线程安全验证结果 lang:java %} Singleton 私有构造方法被调用 1次 当前线程:Thread-1 Singleton: 2044439889 Singleton 私有构造方法被调用 4次 Singleton 私有构造方法被调用 3次 Singleton 私有构造方法被调用 2次 当前线程:Thread-0 Singleton: 605315508 当前线程:Thread-2 Singleton: 2298428 当前线程:Thread-3 Singleton: 1005746524 当前线程:Thread-4 Singleton: 1005746524 当前线程:Thread-5 Singleton: 1005746524 当前线程:Thread-6 Singleton: 1005746524 当前线程:Thread-7 Singleton: 1005746524 当前线程:Thread-8 Singleton: 1005746524 当前线程:Thread-9 Singleton: 1005746524 {% endcodeblock %}
从上面的结果可以看到,Singleton 的私有构造方法被调用了不止一次。对此的解释是,当第一次获取 Singleton 对象还没完成的时候,线程被系统挂起了,这时候有其他线程刚好也获取了 Singleton 对象,那么就会产生多个 Singleton 对象。
由此我们可以得出结论:懒汉模式是 非线程安全 的。
同步方法为了解决懒汉模式非线程安全的缺点,就出现了改进的懒汉模式。其原理是当多个线程同时获取 Singleton 对象时,一次只让一个线程获取,其他线程都在等待,这样就解决了多线程下的对象获取问题。
{% codeblock 同步方法 lang:java %} public class Singleton { private static Singleton instance = null; private Singleton(){} public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } } {% endcodeblock %}
我们通过 synchronized 关键字让 getInstance()方法一次只能让一个线程调用,但是随着而来的又有另外一个问题。
那就是 效率问题,因为只有第一次获取 Singleton 对象时有可能发生线程安全问题,但是使用同步方法却让每次只让一个线程能访问getInstance()方法,而不管 Singleton 对象是不是已经被创建出来了。
那么有没有办法能同时解决线程安全和效率问题呢?
双重校验双重校验 方式就是为了解决懒汉模式的线程安全和效率问题而产生的。
{% codeblock 双重校验 lang:java %} public class Singleton { private static Singleton instance = null; private Singleton(){} public static Singleton getInstance() { if (instance == null){ synchronized (Singleton.class){ if (instance == null){ instance = new Singleton(); } } } return instance; } } {% endcodeblock %}
双重校验就是将前面两种懒汉模式结合起来。当第一次获取 Singleton 对象时, instance 为空, 这时候为了解决可能存在的线程安全问题,同步了 Singleton 这个类对象。也就是说,同一时刻只能有一个线程能够执行 synchronized 之后的代码。同时因为同步代码外层有一个条件语句,所以同步代码只有在第一次获取 Singleton 对象的时候执行到,这样就解决了效率问题。
但是这种方法还是有一个问题,那就是 instance = new Singleton() 这一行代码并不是原子性的
具体来说,JVM执行这一行代码时主要做了三件事
给 instance 分配内存空间
调用 Singleton 的构造函数来初始化成员变量
将 instance 变量指向分配的内存空间(执行完这一步之后 instance 就不为 null 了)
由于 JVM 的指令优化存在,上面的第二点和第三点并不能保证一定按顺序执行。也就是说执行顺序有可能为 1-2-3 或者 1-3-2。
假设是 1-3-2,那么如果执行到3的时候,线程被抢占了,有另外一个线程获取了单例对象(这时候 instance 不为 null,但是还没有初始化),那么自然就会出现错误。
为了解决这个问题,我们只要将 instance 变量声明成 volatile 就可以了。
private static volatile Singleton instance = null;
volatile 关键字主要有两个特性
可见性:保证线程没有变量的本地副本,每次都去主内存获取最新版本
禁止指令重排序:生成内存屏障
很明显,我们这里利用的是 volatile 的第二个特性。
特别注意的是只有在 Java 5 之后使用这种方式才是完全安全的,原因是 Java 5 之前的 Java 内存模型(Java Memory Model,JMM)存在缺陷,即使变量声明为 volatile 也不能完全避免重排序,这个问题在 Java 5 之后才修复。
恶汉模式这时候我们可以换个思路,既然懒汉模式是因为需要的时候才创建对象,所以才让程序有机会可以产生多个对象。那如果我一开始就把对象创建好了,不就行了吗?这就出现了恶汉模式。
恶汉模式的意思是不管对象目前有没有使用,都会先创建出来。
{% codeblock 恶汉模式 lang:java %} public class Singleton { private static final Singleton instance = new Singleton(); private Singleton(){} public static Singleton getInstance() { return instance; } } {% endcodeblock %}
从代码中可以看到,由于在 Singleton 类加载时就创建了 Singleton 对象,所以恶汉模式是 线程安全 的。
但是恶汉模式存在的问题就是不管目前对象有没有被使用,都被创建了出来,浪费了内存空间。
静态方法静态方法的单例模式和恶汉模式的原理一样,都是利用了classloader,在类加载的时候就创建了 Singleton 对象。
{% codeblock 静态方法 lang:java %} public class Singleton { private static Singleton instance = null; static { instance = new Singleton(); } private Singleton(){} public static Singleton getInstance() { return instance; } } {% endcodeblock %}静态内部类
静态内部类的方法和上面两种方法既有相似的地方,也有不同的地方。
{% codeblock 静态内部类 lang:java %} public class Singleton { private static class SingletonHolder{ private static final Singleton INSTANCE = new Singleton(); } private Singleton(){} public static Singleton getInstance() { return SingletonHolder.INSTANCE; } } {% endcodeblock %}
从代码种我们可以看到,静态内部类的方法和前两种方法一样,都是利用了classloader,在加载类的时候创建 Singleton 对象。
不同的地方在于加载的类不同。静态内部类方法在加载 Singleton 类的时候不会创建 Singleton 对象。而是在加载 SingletonHolder 类的时候才会。那么 SingletonHolder 类是什么时候加载的呢?
根据JVM(Java 虚拟机)的类加载规则,静态内部类只有在主动调用的时候才会加载。也就是说,在第一次调用 getInstance() 方法时才会加载 SingletonHolder 类,同时创建了 Singleton 对象。
也可以说,静态内部类的方法利用JVM解决了前两种方法占用内存的问题。
防止单例受到攻击到目前为止,我们所分析的所有单例模式都有一个前提,那就是调用者非常听话地使用了 Singleton.getInstance() 方法获取单例对象。但是在现实生活中是不是都是这样的呢?会不会有不怀好意的人使用其他方式破坏我们的单例模式呢?
我们先思考一下,获取一个对象有几种方式
使用 new 关键字
通过反射调用
序列化
我们前面的单例模式都是通过第一种方式获取对象的,那么如果采用其他两种方式,之前的单例模式还安全吗?答案是否定的。
反射攻击首先我们来看一下反射调用,以双重检验方式为例
{% codeblock 反射攻击 lang:java %} public class Singleton { private static volatile Singleton instance = null; private Singleton(){} public static Singleton getInstance() { if (instance == null){ synchronized (Singleton.class){ if (instance == null){ instance = new Singleton(); } } } return instance; } } public class Test { public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException{ Singleton singleton1 = Singleton.getInstance(); Class> classType = Singleton.class; Constructor> constructor = classType.getDeclaredConstructor(null); constructor.setAccessible(true); Singleton singleton2 = (Singleton) constructor.newInstance(); System.out.println(singleton1 == singleton2); //false } } {% endcodeblock %}
输出结果是
{% codeblock 反射攻击结果 lang:java %} Singleton 私有构造方法被调用 1次 Singleton 私有构造方法被调用 2次 false {% endcodeblock %}
从结果可以看到,私有的构造函数被调用了两次,也就是说这样的单例模式并不安全。
为了防止单例模式被反射攻击,我们可以添加一个标志位,在新建对象时判断是否已经新建过对象了。
{% codeblock 防止反射攻击 lang:java %} public class Singleton { private static boolean flag = false; private static volatile Singleton instance = null; private Singleton(){ if (!flag){ flag = true; }else { throw new RuntimeException("构造函数被调用多次"); } } public static Singleton getInstance() { if (instance == null){ synchronized (Singleton.class){ if (instance == null){ instance = new Singleton(); } } } return instance; } } {% endcodeblock %}
当然这种方式也有一个缺点,那就是必须保证 Singleton.getInstance() 方法在反射之前调用,否则将不能正确获取单例对象。
而且,既然我们可以通过反射创建出对象,那么也可以通过反射修改标志位的值,这样一来,使用标志位的方法就不能完全防止反射攻击了。
序列化攻击接下来我们看一下序列化如何破坏单例模式,以恶汉模式为例。
{% codeblock 序列化攻击 lang:java %} public class Singleton implements Serializable{ private static final Singleton instance = new Singleton(); private Singleton(){} public static Singleton getInstance() { return instance; } } public class Test { public static void main(String[] args) throws IOException,ClassNotFoundException{ Singleton singleton1 = Singleton.getInstance(); Singleton singleton2; FileOutputStream fos = new FileOutputStream("SerSingleton.obj"); ObjectOutputStream oos = new ObjectOutputStream(fos); oos.writeObject(singleton1); oos.flush(); oos.close(); FileInputStream fis = new FileInputStream("SerSingleton.obj"); ObjectInputStream ois = new ObjectInputStream(fis); singleton2 = (Singleton)ois.readObject(); System.out.println(singleton1==singleton2); } } {% endcodeblock %}
输出结果为 false 表明我们的单例收到了攻击,那么如何防止这种情况呢?
我们可以在被序列化的类中添加readResolve方法
{% codeblock 防止序列化攻击 lang:java %} public class Singleton implements Serializable{ private static final Singleton instance = new Singleton(); private Singleton(){} public static Singleton getInstance() { return instance; } private Object readResolve(){ return instance; } } {% endcodeblock %}
说了这么多,不知道大家有没有这样一种感慨 「 都说单例模式是最简单的一种模式,这么还这么复杂,以后还让不让人活了 」。
那么有没有一种又简单有能防止所有攻击的方法呢?
枚举枚举( enum )是 Java1.5 之后新加的特性。
大家一定很奇怪,为什么枚举可以实现单例呢?其实和 Java 的编译特性有关。因为枚举是 Java1.5 之后新加的,一般新加入的功能有一个很重要的问题需要解决,就是对以前代码的兼容性问题。而 Java 是通过 语法糖 的方式解决的。简单来说就是编写代码的时候可以使用新的关键字 enum 编写程序,但是 Java 编译器在编译成字节码的时候,还是会利用现有的技术编译成之前的 JVM 能够识别并正确运行的字节码,这就是语法糖技术。
我们先来看一下枚举编写的单例是什么样子的。
{% codeblock 枚举 lang:java %} public enum Singleton { INSTANCE; public static Singleton getInstance(){ return INSTANCE; } public void otherMethods(){ System.out.println("do something"); } } {% endcodeblock %}
这段代码看起来很简单,我们定义了一个枚举类型 INSTANCE, 这就是我们需要的单例。但是为什么这样就能实现线程安全的单例呢?要解决这个疑问,我们必须把这段代码进行反编译,看看 java 编译器究竟是如何编译这段代码的。
我们使用 java 自带的反编译工具 javap 就可以将这段代码反编译
javap -c Singleton
反编译结果如下:
{% codeblock 反编译 lang:java %} public final class Singleton extends java.lang.Enum{ public static final Singleton INSTANCE; public static Singleton[] values(); Code: 0: getstatic #1 // Field $VALUES:[LSingleton; 3: invokevirtual #2 // Method "[LSingleton;".clone:()Ljava/lang/Object; 6: checkcast #3 // class "[LSingleton;" 9: areturn public static Singleton valueOf(java.lang.String); Code: 0: ldc #4 // class Singleton 2: aload_0 3: invokestatic #5 // Method java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum; 6: checkcast #4 // class Singleton 9: areturn public static Singleton getInstance(); Code: 0: getstatic #7 // Field INSTANCE:LSingleton; 3: areturn public void otherMethods(); Code: 0: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream; 3: ldc #9 // String do something 5: invokevirtual #10 // Method java/io/PrintStream.println:(Ljava/lang/String;)V 8: return static {}; Code: 0: new #4 // class Singleton 3: dup 4: ldc #11 // String INSTANCE 6: iconst_0 7: invokespecial #12 // Method " ":(Ljava/lang/String;I)V 10: putstatic #7 // Field INSTANCE:LSingleton; 13: iconst_1 14: anewarray #4 // class Singleton 17: dup 18: iconst_0 19: getstatic #7 // Field INSTANCE:LSingleton; 22: aastore 23: putstatic #1 // Field $VALUES:[LSingleton; 26: return } {% endcodeblock %}
可能这段代码对于刚刚接触 java 的人来说一时可能看不懂,但是我们只要关注到一下几点就好了。
public final class Singleton extends java.lang.Enum
public static final Singleton INSTANCE,说明我们定义的枚举值 INSTANCE 实际上被 java 编译器转换成了不可变对象,只可以初始化一次。
关注到 INSTANCE 实际上是在 static {} 这段代码里初始化的。也就是说, INSTANCE 是在 Singleton 类加载的时候初始化的,所以一旦 Singleton 类加载了,INSTANCE 也就初始化了,不能再改变了,这就实现了单例模式。
然后如果我们尝试使用序列化或者反射的方式去攻击枚举单例,会发现都不能成功,这是由于 JVM 实现枚举的机制决定的。
最后,引用一下 《Effective Java》一书中的话。
单元素的枚举类型已经成为实现Singleton的最佳方法。
——《Effective Java》
欢迎关注我的个人公众号,一起学习Android、Java、设计模式等技术!
文章版权归作者所有,未经允许请勿转载,若此文章存在违规行为,您可以联系管理员删除。
转载请注明本文地址:https://www.ucloud.cn/yun/68109.html
摘要:用来指向已创建好的实例构造函数为空注意这里是关键这是我们需要调用的方法把函数也定义为空,这样就大功告成啦。 接上一篇大话PHP设计模式之单例模式 这一篇介绍一下升级版的单例模式,废话不说先上代码 不完美的单例模式 class singleMode { //用来指向已创建好的实例 public static $instance; //判断是...
摘要:博主按每天一个设计模式旨在初步领会设计模式的精髓,目前采用靠这吃饭和纯粹喜欢两种语言实现。单例模式用途如果一个类负责连接数据库的线程池日志记录逻辑等等,此时需要单例模式来保证对象不被重复创建,以达到降低开销的目的。 博主按:《每天一个设计模式》旨在初步领会设计模式的精髓,目前采用javascript(_靠这吃饭_)和python(_纯粹喜欢_)两种语言实现。诚然,每种设计模式都有多种实...
摘要:博主按每天一个设计模式旨在初步领会设计模式的精髓,目前采用靠这吃饭和纯粹喜欢两种语言实现。单例模式用途如果一个类负责连接数据库的线程池日志记录逻辑等等,此时需要单例模式来保证对象不被重复创建,以达到降低开销的目的。 博主按:《每天一个设计模式》旨在初步领会设计模式的精髓,目前采用javascript(_靠这吃饭_)和python(_纯粹喜欢_)两种语言实现。诚然,每种设计模式都有多种实...
摘要:上面是简单的单例模式,自己写程序的话够用了,如果想继续延伸,请传送至大话设计模式之单例模式升级版 看了那么多单例的介绍,都是上来就说怎么做,也没见说为什么这么做的。那小的就来说说为什么会有单例这个模式以便更好的帮助初学者真正的理解这个设计模式,如果你是大神,也不妨看完指正一下O(∩_∩)O首先我不得不吐槽一下这个模式名字单例,初学者通过字面很难理解什么是单例,我觉得应该叫唯一模式更贴切...
摘要:最近开展了三次设计模式的公开课,现在来总结一下设计模式在中的应用,这是第一篇创建型模式之单例模式。不过因为不支持多线程所以不需要考虑这个问题了。 最近开展了三次设计模式的公开课,现在来总结一下设计模式在PHP中的应用,这是第一篇创建型模式之单例模式。 一、设计模式简介 首先我们来认识一下什么是设计模式: 设计模式是一套被反复使用、容易被他人理解的、可靠的代码设计经验的总结。 设计模式不...
摘要:原文博客地址单例模式系统中被唯一使用,一个类只有一个实例。中的单例模式利用闭包实现了私有变量两者是否相等弱类型,没有私有方法,使用者还是可以直接一个,也会有方法分割线不是单例最简单的单例模式,就是对象。 原文博客地址:https://finget.github.io/2018/11/06/single/ 单例模式 系统中被唯一使用,一个类只有一个实例。实现方法一般是先判断实例是否存在,...
阅读 815·2021-11-18 10:07
阅读 2308·2021-10-14 09:42
阅读 5224·2021-09-22 15:45
阅读 571·2021-09-03 10:29
阅读 3447·2021-08-31 14:28
阅读 1857·2019-08-30 15:56
阅读 3026·2019-08-30 15:54
阅读 980·2019-08-29 11:32