单例模式是设计模式中最基础、最常用的,但是简单的实现下也隐藏着每一位 Coder 必须深入了解的知识。
引:对象的创建过程
1 | public class MyClass { |
我们有一个类叫做 MyClass,建一个 Main 方法,只有一句 MyClass myClass = new MyClass();
,编译得到字节码后观察生成的命令:
- NEW MyClass:为 MyClass 对象在堆上申请一块内存,将地址压入操作数栈顶。
- DUP:复制操作数栈的栈顶元素,复制后的元素也压入栈顶。(此时栈顶有两个一样的对象地址)
- INVOKESPECIAL MyClass.<init> ()V:调用 MyClass 的 init() 方法,即无参构造方法。由于无参构造方法是一个实例方法,所以需要从操作数栈顶弹出一个对象地址。(此时操作数栈还有一个的对象地址)
- ASTORE 1:栈顶弹出一个元素,并放入位置为 1 的局部变量区中。(此时引用变量 myClass 不再是 null)
PS:当 NEW MyClass 指令执行完后,由于还没有调用构造方法,此时对象处于半初始化状态,对象的成员变量还是默认值(num 等于 0)!
正文:单例模式
有时业务需要进程的内存空间里,某个类只能存在一个实例,并提供一个访问它的全局访问点。 这时,单例模式就应运而生了!
一、饿汉式
类加载到内存时,实例化一个对象,每次使用时只要调用 get 方法去获取这个单例对象即可:
1 | public class Singleton { |
这种实现方式由 JVM 的类加载机制保证初始化 instance 时只有一个线程(ClassLoader#loadClass(String name, boolean resolve)方法加锁),并且会通过检查当前类加载器是否已经缓存过类,来保证类加载过程只会生成一个实例 。详细的过程需要去深入了解 JVM 的类加载机制
优点是简单实用,但缺点是这个单例对象无论是否需要,都会在类加载时被创建。
二、懒汉式
获取单例对象时先判断是否有实例,没有就创建一个,有则直接返回:
1 | public class Singleton { |
但是这样简单的懒汉式,是线程不安全的,并发的时候会 new 出多个对象。(A 线程调用 getInstance()
方法时实例为 null,正准备创建实例对象,此时 B 线程也调用 getInstance()
,由于此时 A 线程正准备创建实例对象,但还没有创建出来,所以 B 线程也要去 new 对象,就会造成内存中有多个对象实例的情况。)
很容易想到给 getInstance()
方法加 synchronized 修饰,方法就变成了互斥的,只能同时有一个线程在执行。但是,这样加锁的粒度太粗,如果方法里有耗时操作,效率会大打折扣,应该尽可能的降低锁的粒度(只要锁住创建对象的操作即可)。
可以使用 DCL(双重校验锁) 的方式去进行实现:
1 | public class Singleton { |
相较于饿汉式,这种方法的优点是只有当需要获取到实例对象时才去创建,但是由于有锁等待的情况,效率是稍有下降。
此时是否要给这个单例对象加 volatile 修饰?答案是:要加!
volatile 作用:
- 保持线程可见性:由于现代多核处理器会导致多线程分别在不同的核上执行,不同核上的 CPU cache 将内存中的变量数据读入,之后线程就对其所在核上的 cache 进行读写,从而造成其他核上的线程对该核上的写操作过程的不可见性;而 volatile 关键字修饰的变量,JVM 保证了所有线程每次都直接从内存中读取,跳过 CPU cache,进而确保了线程可见性。
- 禁止指令重排序: 编译器、处理器以及运行时等都可能对命令的执行顺序进行一些意想不到的调整 ,从而达到加快总体执行速度之类的目的;而 volatile 关键字会告诉编译器和运行时被声明的变量是共享的,所以该变量上的操作不会和其他内存操作一起被重排序,具体做法就是对被 volatile 声明的变量所做的赋值操作后多执行一条
load.addl $0x0,(%esp)
操作来作为内存屏障,重排序时不能将内存屏障后的指令排序到内存屏障之前。
例如,本应该按照前文所说的NEW MyClass -> DUP -> INVOKESPECIAL MyClass.<init>() -> astory
的命令顺序去进行创建对象的过程,试想一下:
A 线程发现单例对象的引用是 null,所以要 new 一个,但是由于这个单例对象的构造方法比较耗时或其他原因,导致 CPU 进行指令重排序, astory
和 INVOKESPECIAL MyClass.<init>()
的执行顺序对调了,并且此时只执行完了 astory
,而 INVOKESPECIAL MyClass.<init>()
还没有执行完毕;
就在此时 B线程调用 getInstance()
,由于执行完毕了 astory
所以引用变量并不为空,于是直接返回了这个还没有正确执行完构造方法的半初始状态的对象,因此可能会造成数据不一致的情况。
所以为了避免出现这种指令重排造成的数据不一致的情况,需要给单例对象增加 volatile 修饰。
三、静态内部类
像是饿汉式和懒汉式的结合体,但是有所区别:
1 | public class Singleton { |
只有当显式调用 getInstance()
方法时,才会将 SingletonHolder 这个静态内部类加载入内存,同时创建一个 Singleton 对象,且 SingletonHolder 只会被加载进内存一次,即 Singleton 对象也只会被创建一次。
这种方式的线程安全同样由 JVM 的类加载机制来保证的。
对静态域延迟加载时,应该选择匿名内部类的方式实现;但是如果需要实例域延迟加载时,还是要选择 DCL 的方式实现。
四、枚举
先看一段代码的运行结果:
1 | public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { |
使用反射,很容易就获取到两个不同的 Singleton 对象实例,违背了单例模式的规则,除了反射攻击之外,前几种单例模式还会遭到反序列化攻击:
1 | public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException { |
这时需要用枚举来实现单例模式,就可以规避上述两种风险:
1 | public enum Singleton { |
1 | public static void main(String[] args) { |
枚举类是如何实现单例效果的?反编译一下 Singleton 生成的字节码(还原语法糖):
1 | package resources; |
观察反编译后的源码,构造方法私有化、static final 修饰的实例引用、通过 static 代码块初始化来完成对象创建、提供静态方法 valueOf(String name)
去获取单例对象,有饿汉式实现单例模式内味儿了,所以该实现的线程安全也是由 JVM 的类加载机制保证的。
并且,枚举实现单例在IO类中和反射类中有天然的安全检查优势,再次尝试反射攻击:
1 | public static void main(String[] args) { |
运行代码,抛出异常: IllegalArgumentException:Cannot reflectively create enum objects
进入 constructor.newInstance("INSTANCE",1)
方法,可以看到如下语句:
1 | if ((clazz.getModifiers() & Modifier.ENUM) != 0) |
源码中直接写明了禁止反射创建枚举类的实例了!!!
那就尝试反序列化攻击:
1 | public static void main(String[] args) { |
deserialize(bytes)
方法内部调用了 ObjectInputStream#readObject()
方法的底层实现方法 ObjectInputStream#readObject0()
,该实现方法内部判断到是枚举类的时候,去调用 ObjectInputStream#readEnum(boolean unshared)
方法:
1 | private Enum<?> readEnum(boolean unshared) throws IOException { |
看第 23 行,反序列化枚举类的时候,并不是根据字节码去 new 一个新对象,而是使用枚举类的 valueof(Class<T> enumType, String name)
方法,去获取 name 在 class 类型中对应的枚举常量,在枚举类中 name 唯一,且在对应着内存中一个唯一的枚举常量,所以序列化方法拿到的实例一定是唯一的!!
总
- 饿汉式:JVM 的类加载机制保证线程安全,不能懒加载,会被反射或反序列化破坏对象唯一性。
- 懒汉式:可以用 DCL 来保证线程安全、volatile 关键字防止数据不一致,懒加载,会被反射或反序列化破坏对象唯一性。
- 静态内部类:类加载机制保证线程安全,懒加载,会被反射或反序列化破坏对象唯一性。
- 枚举实现单例:类加载机制保证线程安全,不能懒加载,不会被反射或反序列化破坏对象唯一性。
结尾引用《effective Java》中 Bloch 大神说过的话:
单元素的枚举类型已经成为实现 Singleton 的最佳实践! ——Joshua Bloch
菜鸟本菜,不吝赐教,感激不尽!