聊聊单例模式,面试加分题
犹记得之前面xx时,面试官一上来就问你知道哪些设计模式,来手写一个单例模式的场景;尴尬的我,只写了懒汉式饿汉式,对于单例其他的变种一概不知;这次就来弥补下这方面的知识盲区!
饿汉式
饿汉式,从字面上理解就是很饿,一上来就要吃的,那么它会把吃的先准备好,以满足它的需求;那么对应到程序上的表现就为:在类加载的时候就会首先进行实例的初始化,后面如果应用程序需要这个实例的话,就有现成的了,可以直接使用当前的单例对象!
我们来手写下饿汉式的代码:
public class Singleton{ // 声明静态私有实例 并实例化 private static Singleton singleton = new Singleton(); // 提供对外初始化方法 静态类加载就初始化 public static Singleton initInstance(){ return singleton; } // 声明私有构造方法 即在外部类无法通过new 初始化实例 private Singleton(){ } public void doSomeThing(){ System.out.println("do some thing!"); } } class SingletonDemo{ public static void main(String[] args) { Singleton singleton = Singleton.initInstance(); } }
饿汉式的优点:它是线程安全的,因为单例对象在类加载的时候就被初始化了,当调用单例对象时只需要去把对应的对象赋值给变量即可!
饿汉式的缺点:如果这个类不经常使用,会造成一定的资源浪费!
懒汉式
懒汉式,就是比较懒,每次需要填饱肚子时才会外出觅食;那么对应到程序层面的理解,当应用程序需要某个对象时,该对象的类就会去创建一个实例,而不是提前准备好的!
我们来手写下懒汉式的代码:
public class Singleton2 { // 声明私有静态对象 private static Singleton2 singleton2; // 对外提供初始化方法 public static Singleton2 initInstance(){ if(singleton2 == null){ singleton2 = new Singleton2(); } return singleton2; } // 私有构造器 private Singleton2(){ } public void doSomeThing(){ System.out.println("do some thing!"); } } class SingletonDemo2{ public static void main(String[] args) { Singleton2 singleton2 = Singleton2.initInstance(); singleton2.doSomeThing(); } }
同样我们看下懒汉式的优点:不会造成资源的浪费
懒汉式的缺点:多线程情况下,可能会有线程安全的问题;
上面我们可以看到,饿汉式和懒汉式的唯一区别就是:饿汉式在类加载时就完成了对象的初始化,而懒汉式是在需要初始化的时候再去初始化对象;其实在单线程情况下,他们都是线程安全的;但是我们写的代码,必须考虑多线程情况下的并发问题,那么懒汉式的这种写法基本不满足需求,我们需要做些改造,使得它变得线程安全,满足我们的需求!
双重检测锁
我们知道,懒汉式下对象的初始化在并发环境下,可能多个线程同时执行到singleton2 == null
,从而初始化了多个实例,这就引发了线程安全问题!
我们就需要改写它的初始化方法,我们知道加锁可以解决一般的线程安全问题,synchronized
这个关键字可以修饰一个代码块或方法,被其修饰的方法或代码块就被加了锁;而从某些方面理解,synchronized
是个同步锁,亦是个可重入锁!哈哈,关于锁的种类及概念有点多,后面准备写一篇关于锁的博客来总结下;不在发散了,回归正题
我们来改造下懒汉式的初始化方法如下:
// 对外提供初始化方法 public synchronized static Singleton2 initInstance(){ if(singleton2 == null){ singleton2 = new Singleton2(); } return singleton2; }
我们看下上面的代码,初看没什么问题是解决了线程安全问题;但是由于整个方法都被synchronized
修饰,那么在多线程的情况下就增加了线程同步的开销,降低了程序的执行效率;为了改进这个问题,我们将synchronized
放入到方法内,实现代码块的同步;改下如下:
// 对外提供初始化方法 public static Singleton2 initInstance(){ if(singleton2 == null){ synchronized(Singleton2.class){ singleton2 = new Singleton2(); } } return singleton2; }
呃,这样就满足了我们的要求了吗?聪明如你一定发现了,虽然我们将synchronized
移到了方法内部,降低了同步的开销,但是在并发的情况下假设多个线程同时执行到if(singleton2 == null)
时,依旧会排队初始化Singleton2
实例,这样又会造成新的线程安全问题;那么为了解决这个问题,就出现了大名鼎鼎的“双重检测锁”。我们来看下它的实现,将上述代码改写如下:
// 对外提供初始化方法 public static Singleton2 initInstance(){ if(singleton2 == null){// 第一次非空判断 synchronized(Singleton2.class){ if(singleton2 == null)// 第二次非空判断 singleton2 = new Singleton2(); } } return singleton2; }
哈哈,这个双重即是判断两次的意思,并不是加两把锁哈;那么这样就能行了吗?初看没问题啊,但是我们细想之下这样写真的没问题吗?你写的代码,执行的时候真的会按你想的过程执行吗?有没有考虑过指令重排呢?问题就出现在new Singleton2()
这个代码上,这行代码不是一个原子操作!
我们再来回顾下指令重排的大致执行流程:
1.给对象实例分配内存空间
2.调用对象构造方法,初始化成员变量
3.将构造的对象指向分配的内存空间
问题就出在指令重排后,cpu对指令重排的优化上,也就是说上述的三个过程并不是每次都是1-2-3顺序执行的,而是也有可能1-3-2;那么我们试想下并发情况下可能出现的场景,当线程A执行到步骤3时,cpu时间片正好轮询到线程B,那么线程B判断实例已经指向了对应的内存空间,不为null就不会 初始化实例了,就得到了一个未初始化完成的对象,这就导致了问题的诞生!
为了解决这个问题,我们知道还有一个关键字volatile
可以完美的解决指令重排,使得非原子性的操作对其他对象是可见的!(volatile关键字保障了变量的内存的可见性和一致性问题,关于内存屏障可以看我之前的一篇文章JMM 内存模型知识点探究了解)。那么我们将懒汉式改写如下:
public class Singleton2 { // 声明私有静态对象 private volatile static Singleton2 singleton2; // 对外提供初始化方法 public static Singleton2 initInstance(){ if(singleton2 == null){ synchronized(Singleton2.class){ if(singleton2 == null) singleton2 = new Singleton2(); } } return singleton2; } // 私有构造器 private Singleton2(){ } public void doSomeThing(){ System.out.println("do some thing!"); } } class SingletonDemo2{ public static void main(String[] args) { Singleton2 singleton2 = Singleton2.initInstance(); singleton2.doSomeThing(); } }
其实除了上面的单例实现外,还有两种常见的单例实现
静态内部类
代码如下:
public class InnerClassSingleton { // 私有静态内部类 private static class InnerInstance{ private static final InnerClassSingleton singleton = new InnerClassSingleton(); } // 对外提供的初始化方法 public static InnerClassSingleton initInstance(){ return InnerInstance.singleton; } // 私有构造器 private InnerClassSingleton(){ } public void doSomeThing(){ System.out.println("do some thing!"); } } class InnerClassSingletonDemo{ public static void main(String[] args) { InnerClassSingleton innerClassSingleton = InnerClassSingleton.initInstance(); innerClassSingleton.doSomeThing(); } }
其实,静态内部类的方式和饿汉式本质是一样的,都是根据类加载机制来初始化实例,从而保证单例和线程安全的;不同的是静态内部类的方式是按需构建实例,不会如饿汉式一样造成资源浪费的问题;所以这个是饿汉式一个比较好的变种!
枚举类
枚举是比较推荐的一种单例模式,它是线程安全的,且通过反射序列已经反序列化都无法破坏它的单例属性(其他的单例采用私有构造器的实现其实并不安全),至于为什么呢?这个可以参考博客:为什么要用枚举实现单例模式(避免反射、序列化问题)
代码如下:
public class EnumSingleton { // 声明私有的枚举类型 private enum Enum{ INSTANCE; // 声明单例对象 private final EnumSingleton instance; // 实例化 Enum(){ instance = new EnumSingleton(); } private EnumSingleton getInstance(){ return instance; } } // 对外提供的初始化方法 public static EnumSingleton initInstance(){ return Enum.INSTANCE.getInstance(); } // 私有构造器 private EnumSingleton(){ } public void doSomeThing(){ System.out.println("do some thing!"); } } class EnumSingletonDemo{ public static void main(String[] args) { EnumSingleton enumSingleton = EnumSingleton.initInstance(); enumSingleton.doSomeThing(); } }
好,至此我们总结了单例的几种实现方式;比较推荐的是后面两种方式,一般懒汉式我们就采用双重检测锁的方式;你可以发散思考下单例的应用场景,例如Spring中的Bean的初始化就是单例模式的典型应用,或者有些项目中的长链接等!