设计模式之单例模式

91 阅读9分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第9天,点击查看活动详情

单例模式属于创建型模式的一种,主要是希望某个类在程序的始终只存在一个对象,在这个类提供访问这唯一对象的访问方式,这个类就叫做单例类,这种模式就称为单例模式。

单说理论有点干……

image.png

平常我们创建对象基本上都是使用关键字new,而new关键字是每使用一次就会产生一个新的对象,从JVM模型的角度来看就是,每new一次对象(普通对象)都会在堆中开辟新的内存空间,然后在栈中产生一个指向该内存的指针,当然了这里不详谈,以后有机会再写,至少现在还没这个能力写这么庞大的文章。

image.png

那么,说了这么多,为什么要创建一个单例对象呢?那就是要避免频繁创建和销毁系统全局使用的对象,这话看似很普通,但是我们在编程的时候经常会用到,比如最经典的就是,为什么要使用线程池?因为如果不用线程池,线程的上下文切换会消耗系统资源,而频繁切换则会大量消耗;同理,频繁创建一个常用的同样的对象也会消耗系统资源,还不如从一至终只创建一个。

单例模式的特点:其实上面就有提到

(1)单例类只能有一个实例。
(2)单例类必须自己创建唯一实例。
(3)单例类必须给其他类提供访问这一实例的入口。

需要说明的是,单例模式有好几种,有懒汉式、饿汉式、饿汉式同步锁、双重校验锁、静态内部类、枚举类,下面一一举例说明

一、饿汉式

饿汉式由于程序启动时直接创建对象实例,并且将唯一构造器设置成private,避免了其他程序访问,保证了线程安全

class HungryMan {

    // 只提供无参构造,并设置为private,避免其他类直接访问
    private HungryMan(){}

    // 设置成private 与 static,程序启动时直接创建对象实例
    private static HungryMan hungryMan = new HungryMan();

    // 设置成public的访问入口,供其他对象访问
    public static HungryMan getInstance(){
        // 返回已经创建好的实例,保证唯一性
        return hungryMan;
    }
}

优点:简单、直接
缺点:直接在程序启动时创建,降低了程序的启动速度;不论该实例是否被使用,浪费了内存;

二、懒汉式

针对饿汉式启动即创建的缺点,懒汉式在该实例被用到时才会创建

class LazyMan {

    // 只提供无参构造,并设置为private,避免其他类直接访问
    private LazyMan() {
    }

    // 设置成private 与 static
    private static LazyMan lazyMan = null;

    // 设置成public的访问入口,供其他对象访问
    public static LazyMan getInstance() {
        // 第一次调用时才会创建,后续调用直接返回创建好的(这里会出现线程不安全问题)
        if (lazyMan == null) {
            lazyMan = new LazyMan();
        }
        return lazyMan;
    }
}

优点:克服了饿汉式启动即创建的缺点
缺点:缺点也很明显,就是会出现线程不安全问题(多线程情况下有可能会创建多个实例),简单描述一下问题,在如果在多线程下, 一个线程进入了if (lazyMan == null) 判断语句时,还未往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例。也就是说,如果是单线程程序,还是可以用这个模式,但是多线程则不可以了。

三、同步锁-懒汉式

那么针对与懒汉式线程不安全的问题,有没有什么解决办法呢?那当然,不要问,问就是加锁

class SynLazyMan {

    // 只提供无参构造,并设置为private,避免其他类直接访问
    private SynLazyMan() {
    }

    // 设置成private 与 static,并且要加volatile关键字
    private static volatile SynLazyMan synLazyMan = null;

    // 设置成public的访问入口,供其他对象访问
    public static SynLazyMan getInstance() {
        // 加锁,每次只有一个线程访问,其他的堵塞再此
        synchronized (SynLazyMan.class){
            // 第一次调用时才会创建,后续调用直接返回创建好的
            if (synLazyMan == null) {
                synLazyMan = new SynLazyMan();
            }
        }
        return synLazyMan;
    }
}

优点:克服了多线程下线程不安全的问题;
缺点:每次都会加锁和释放锁操作,并且除了第一个拿到锁的线程之外,其他的线程都需要堵塞在此,效率低。

PS:这里在定义的时候加了volatile关键字,但在这里其实可以不加,因为没有拿到锁线程直接被阻塞在锁之外,具体为什么要加volatile关键字通过下面的double-check模式说明。

四、双重检测-懒汉式

应该叫双重检测-同步锁-懒汉式,也叫double-check模式,据说是美团整出来的,为了克服同步锁-懒汉式效率低的问题,在拿到锁之前再加一层判断

class DoubleCheckLazyMan {

    // 只提供无参构造,并设置为private,避免其他类直接访问
    private DoubleCheckLazyMan() {
    }

    // 设置成private 与 static,并且要加volatile关键字
    private static volatile DoubleCheckLazyMan doubleCheckLazyMan = null;

    // 设置成public的访问入口,供其他对象访问
    public static DoubleCheckLazyMan getInstance() {
        // 再加一层判断,如果已经有线程创建好了实例,后续实例就不需要再尝试获取锁造成堵塞了
        if(doubleCheckLazyMan == null){
            // 加锁,每次只有一个线程访问,其他的堵塞再此
            synchronized (DoubleCheckLazyMan.class){
                // 第一次调用时才会创建,后续调用直接返回创建好的
                if (doubleCheckLazyMan == null) {
                    doubleCheckLazyMan = new DoubleCheckLazyMan();
                }
            }
        }
        return doubleCheckLazyMan;
    }
}

这种方式是比较完美的,即克服了线程不安全的问题,又解决了效率低下的问题(当然了,是针对于同步锁-懒汉式而言,事实上加了锁肯定会效率低下一些);而这个模式最关键的是volatile关键字

private static volatile DoubleCheckLazyMan doubleCheckLazyMan = null;

不加volatile关键字会发生什么?
主要原因在于doubleCheckLazyMan = new DoubleCheckLazyMan();并不是原子性的操作。

创建一个对象可以分为三步:

1.分配对象的内存空间(称为半初始化对象)
2.初始化对象(可以理解为给当前对象赋值)
3.设置doubleCheckLazyMan指向刚分配的内存地址(栈中的指针指向堆内存)

当doubleCheckLazyMan指向分配地址时,doubleCheckLazyMan是不为null

上面三步按照我们正常的逻辑是1、2、3,但是JVM会用它以为效率好的方式对代码进行重排序,也就是在这里的步骤有可能变成1、3、2

那么在这种重排序步骤情况下,我们假设线程A执行到了doubleCheckLazyMan = new DoubleCheckLazyMan()这一步,并且完成了1、3,此时doubleCheckLazyMan已经指向堆内存了,也就是不为null了,恰好线程B到了最外面的if(doubleCheckLazyMan == null)判断,好,返回false,线程B直接跳过锁块,拿到了一个半初始化对象。

那么这里为什么加了volatile关键字就解决了这个问题呢?

private static volatile DoubleCheckLazyMan doubleCheckLazyMan = null;

volatile关键字在java程序起到的作用:
(1) 确保线程可见性;(加了volatile关键字的变量被修改之后会立马刷新回主内存,其他线程必须强行从主内存中读取最新的值)
(2) 禁止指令重排序;

这里最重要的就是禁止指令重排序了,也就是使得上面的逻辑一定是1、2、3,不会再发生1、3、2这样的步骤。

优点:效率高,线程安全。
缺点:代码复杂(其实也没多复杂),这应该是比较常用的了。

五、静态内部类

静态内部类能实现单例模式主要依赖JVM类加载的特性,主要有下面两点:
(1)JVM在加载外部类的时候并不会加载其静态内部类,在使用到静态内部类的时候才会对静态内部类进行加载。我们知道,类的静态变量是在类加载的时候进行加载的,这样静态内部类实现单例模式就有一个特性:如果没有使用到这个实例,这个实例就不会进行加载。这和懒汉模式一样,能有效节省资源。
(2)JVM底层保证类加载的安全,即使在高并发的情况下,类的加载都只有一次,这就保证了创建单例时的并发安全性。

 class SingleStatic {

     // 只提供无参构造,并设置为private,避免其他类直接访问
    private SingleStatic() {
    }

    // 使用内部类方式创建
    private static class InnerClass {
        public static SingleStatic singleStatic = new SingleStatic();
    }

     // 第一次调用时才会创建(静态内部类只会加载一次),后续调用直接返回创建好的
    public static SingleStatic getInstance() {
        return InnerClass.singleStatic;
    }
}

六、枚举类

枚举类单例模式直接通过枚举类是线程安全而实现,并且只会加载一次

class EnumSingleton {
    // 只提供无参构造,并设置为private,避免其他类直接访问
    private EnumSingleton(){
    }

    // 枚举类型是线程安全的,并且只会装载一次
    private enum Singleton{
        INSTANCE;

        private final EnumSingleton instance;

        Singleton(){
            instance = new EnumSingleton();
        }

        private EnumSingleton getInstance(){
            return instance;
        }
    }
    // 设置成public的访问入口,供其他对象访问
    public static EnumSingleton getInstance(){
        return Singleton.INSTANCE.getInstance();
    }
}

优点:枚举类是唯一不会被反射破坏单例的实现方式

总结:前五种单例方式都可以被Java最大的“bug”破坏掉,那就是反射,即使构造器是私有的也能拿到;而枚举类虽然构造器也是私有的,但是java 的反射 API 已经通过写死的方式限制了不能为枚举类型创建实例,所以没办法破坏。也就是说,枚举类是唯一一个相对安全的单例模式。当然了,实际上咱也不会手贱整反射破坏,所以根据情况使用其他的单例模式即可。