单例模式
1. 什么是单例模式
面试官问什么是单例模式时,千万不要答非所问,给出单例模式有两种类型之类的回答,要围绕单例模式的定义去展开。
单例模式是指在内存中只会创建且仅创建一次对象的设计模式。在程序中多次使用同一个对象且作用相同时,为了防止频繁地创建对象使得内存飙升,单例模式可以让程序仅在内存中创建一个对象,让所有需要调用的地方都共享这一单例对象。
2. 单例模式的类型
单例模式有两种类型:
懒汉式:在真正需要使用对象时才去创建该单例类对象
饿汉式:在类加载时已经创建好该单例对象,等待被程序使用
懒汉式创建单例对象
懒汉式创建对象的方法是在程序使用对象前,先判断该对象是否已经实例化(判空),若已实例化直接返回该类对象。否则则先执行实例化操作。
根据上面的流程图,就可以写出下面的这段代码
public class Singleton {
private static Singleton singleton;
private Singleton(){
}
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
没错,这里我们已经写出了一个很不错的单例模式,不过它不是完美的,但是这并不影响我们使用这个“单例对象”。
懒汉式如何保证只创建一个对象
我们再来回顾懒汉式的核心方法
public static Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
这个方法其实是存在问题的,试想一下,如果两个线程同时判断singleton为空,那么它们都会去实例化一个Singleton对象,这就变成双例了。所以,我们要解决的是线程安全问题。
最容易想到的解决方法就是在方法上加锁,或者是对类对象加锁,程序就会变成下面这个样子
public static Singleton getInstance() {
synchronized(Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
return singleton;
}
这样就规避了两个线程同时创建Singleton对象的风险,但是引来另外一个问题:每次去获取对象都需要先获取锁,并发性能非常地差,极端情况下,可能会出现卡顿现象。
接下来要做的就是优化性能,目标是:如果没有实例化对象则加锁创建,如果已经实例化了,则不需要加锁,直接获取实例
所以直接在方法上加锁的方式就被废掉了,因为这种方式无论如何都需要先获取锁
public static Singleton getInstance() {
if (singleton == null) {
// 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
synchronized (Singleton.class) {
// 线程A或线程B获得该锁进行初始化
if (singleton == null) {
// 其中一个线程进入该分支,另外一个线程则不会进入该分支
singleton = new Singleton();
}
}
}
return singleton;
}
上面的代码已经完美地解决了并发安全+性能低效问题:
- 第2行代码,如果singleton不为空,则直接返回对象,不需要获取锁;而如果多个线程发现singleton为空,则进入分支;
- 第3行代码,多个线程尝试争抢同一个锁,只有一个线程争抢成功,第一个获取到锁的线程会再次判断singleton是否为空,因为singleton有可能已经被之前的线程实例化
- 其它之后获取到锁的线程在执行到第4行校验代码,发现singleton已经不为空了,则不会再new一个对象,直接返回对象即可
- 之后所有进入该方法的线程都不会去获取锁,在第一次判断singleton对象时已经不为空了
因为需要两次判空,且对类对象加锁,该懒汉式写法也被称为:DCL(Double Check(双重校验) + Lock(加锁))
完整的代码如下所示:
public class Singleton {
private static Singleton singleton;
private Singleton(){
}
public static Singleton getInstance() {
if (singleton == null) {
// 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
synchronized(Singleton.class) {
// 线程A或线程B获得该锁进行初始化
if (singleton == null) {
// 其中一个线程进入该分支,另外一个线程则不会进入该分支
singleton = new Singleton();
}
}
}
return singleton;
}
}
上面这段代码已经近似完美了,但是还存在最后一个问题:指令重排
使用volatile防止指令重排
创建一个对象,在JVM中会经过三步:
(1)为singleton分配内存空间
(2)初始化singleton对象
(3)将singleton指向分配好的内存空间
指令重排序是指:JVM在保证最终结果正确的情况下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能
在这三步中,第2、3步有可能会发生指令重排现象,创建对象的顺序变为1-3-2,会导致多个线程获取对象时,有可能线程A创建对象的过程中,执行了1、3步骤,线程B判断singleton已经不为空,获取到未初始化的singleton对象,就会报NPE异常。文字较为晦涩,可以看流程图:
使用volatile关键字可以防止指令重排序,其原理较为复杂,这篇博客不打算展开,可以这样理解:使用volatile关键字修饰的变量,可以保证其指令执行的顺序与程序指明的顺序一致,不会发生顺序变换,这样在多线程环境下就不会发生NPE异常了。
volatile还有第二个作用:使用volatile关键字修饰的变量,可以保证其内存可见性,即每一时刻线程读取到该变量的值都是内存中最新的那个值,线程每次操作该变量都需要先读取该变量。
最终的代码如下所示:
public class Singleton {
private static volatile Singleton singleton;
private Singleton(){
}
public static Singleton getInstance() {
if (singleton == null) {
// 线程A和线程B同时看到singleton = null,如果不为null,则直接返回singleton
synchronized(Singleton.class) {
// 线程A或线程B获得该锁进行初始化
if (singleton == null) {
// 其中一个线程进入该分支,另外一个线程则不会进入该分支
singleton = new Singleton();
}
}
}
return singleton;
}
}
饿汉式创建单例对象
饿汉式在类加载时已经创建好该对象,在程序调用时直接返回该单例对象即可,即我们在编码时就已经指明了要马上创建这个对象,不需要等到被调用时再去创建。
关于类加载,涉及到JVM的内容,我们目前可以简单认为在程序启动时,这个单例对象就已经创建好了。
public class Singleton{
private static final Singleton singleton = new Singleton();
private Singleton(){
}
public static Singleton getInstance() {
return singleton;
}
}
注意上面的代码在第3行已经实例化好了一个Singleton对象在内存中,不会有多个Singleton对象实例存在
类在加载时会在堆内存中创建一个Singleton对象,当类被卸载时,Singleton对象也随之消亡了。
内部类方式
在这种方式中,使用了一个专门的内部类来初始化Singleton,JVM将推迟SingletonHolder的初始化操作,直到开始使用这个类时才初始化。并且在初始化的过程中JVM会去获取一个用于同步多个线程对同一个类进行初始化的锁,这样就不需要额外的同步。这种方式不仅能够保证线程安全,也能保证单例对象的唯一性,同时也延迟实例化,是一种非常推荐的方式。
public class Singleton {
// 获得对象实例的方法
public static Singleton getSingleton() {
return SingletonHolder.instance;
}
/**
* 静态内部类与外部类的实例没有绑定关系,而且只有被调用时才会 * 加载,从而实现了延迟加载
*/
private static class SingletonHolder {
/**
* 静态初始化器,由JVM来保证线程安全
*/
private static Singleton instance = new Singleton();
}
private Singleton() {
}
}
枚举方式
从Java1.5起,可以通过使用枚举机制来实现单例模式。虽然这种方法尚未被广泛采用,但单元素枚举类型是实现单例的最佳方法:
public enum Singleton {
// 定义枚举元素,他就是Singleton的一个实例
INSTANCE;
public void doSomething() {
// do something
}
}
调用方式
Singleton singleton = Singleton.INSTANCE;
singleton.doSomething();
枚举优点:
饿汉式、懒汉式、双重校验锁(DCL)还是静态内部类都存在的缺点:
- 都需要额外的工作(Serializable、transient、readResolve())来实现序列化,否则每次反序列化一个序列化的对象实例时都会创建一个新的实例。
- 可能会有人使用反射强行调用我们的私有构造器(如果要避免这种情况,可以修改构造器,让它在创建第二个实例的时候抛异常)。
枚举类很好的解决了这两个问题,使用枚举除了线程安全和防止反射强行调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。
3. 序列化与反序列化问题
在上述的几种单例模式实现中,在一个情况下它们会出现重新创建对象的情况,那就是反序列化。
通过序列化可以将一个单例的实例对象写到磁盘,然后再读回来,从而有效地获得一个实例。即使构造函数是私有的,反序列化时依然可以通过特殊的途径去创建类的一个新的实例,相当于调用该类的构造函数。反序列化操作提供一个很特别的钩子函数,类中具有一个私有的、被实例化的方法readResolve(),这个方法可以让开发人员控制对象的反序列化。例如,上述几个实例中如果要杜绝单例对象在被反序列化时重新生成对象,那么必须加入如下方法:
private Object readResolve() throws ObjectStreamException {
return INSTANCE;
}
也就是在readResolve方法中将实例对象返回,而不是默认的重新生成一个新的对象。
4. Java反射攻击
下面我们基于内部类实现的单例模式的方式,来演示一下通过JAVA的反射机制来“攻击”单例模式:
public class TestMain {
public static void main(String[] args) throws NoSuchMethodException, SecurityException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
Class<?> classType = Singleton.class;
Constructor<?> c = classType.getDeclaredConstructor(null);
c.setAccessible(true);
Singleton singleton1 = (Singleton) c.newInstance();
Singleton singleton2 = Singleton.getSingleton();
System.out.println(singleton1 == singleton2);
}
}
运行结果:false,可以看到,通过反射获取构造函数,然后调用setAccessible(true)就可以调用私有的构造函数,所有singleton1和singleton2是两个不同的对象。如果要抵御这种攻击,可以修改构造器,让它在被要求创建第二个实例的时候抛出异常。
修改原有代码为:
public class Singleton {
public static Singleton getSingleton() {
return SingletonHolder.instance;
}
private static class SingletonHolder {
private static Singleton instance = new Singleton();
}
private static boolean flag = false;
private Singleton() {
synchronized(Singleton.class) {
if(flag == false) {
flag = !flag;
} else {
throw new RuntimeException("单例模式被破坏!");
}
}
}
}
再次运行上面的测试代码:得到的结果为:
Exception in thread "main" java.lang.RuntimeException: 单例模式被破坏!
at com.danli.Singleton.<init>(Singleton.java:29)
at com.danli.Singleton.getSingleton(Singleton.java:12)
at com.danli.TestMain.main(TestMain.java:23)
可以看到,成功的阻止了单例模式被破坏。
但是我们如果直接基于枚举方式实现的单例模式进行同样的代码测试,会直接得到结果:
Exception in thread "main" java.lang.NoSuchMethodException: com.danli.Singleton.<init>()
at java.lang.Class.getConstructor0(Class.java:2730)
at java.lang.Class.getDeclaredConstructor(Class.java:2004)
at com.danli.TestMain.main(TestMain.java:20)
可以看到,枚举方式实现的单例自己是可以避免反射攻击的。
5. 总结
(1)单例模式有两种:懒汉式、饿汉式;
(2)懒汉式:在需要用到对象时才实例化对象,正确的实现方式是:Double Check + Lock,解决了并发安全和性能低下问题;
(3)饿汉式:在类加载时已经创建好该单例对象,在获取单例对象时直接返回对象即可,不会存在并发安全和性能问题。
(4)在开发中如果对内存要求非常高,那么使用懒汉式写法,可以在特定时候才创建该对象;
(5)如果对内存要求不高使用饿汉式写法,因为简单不易出错,且没有任何并发安全和性能问题
(6)为了防止多线程环境下,因为指令重排序导致变量报NPE,需要在单例对象上添加volatile关键字防止指令重排序;
6.参考文献
1.Double-checked locking and the Singleton pattern
2.双重检查锁定与延迟初始化
3.如何防止单例模式被JAVA反射攻击
4.单例模式的一些注意点
5.单例模式各版本的原理与实践
6.我给面试官讲解了单例模式后,他对我竖起了大拇指!