设计模式之单例模式

聊聊单例模式,面试加分题

犹记得之前面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的初始化就是单例模式的典型应用,或者在消息中心中使用比较频繁的短链接!

posted @ 2020-08-08 12:02  叁有三分之一  阅读(134)  评论(0编辑  收藏  举报