双重校验锁实现单例模式

3,584 阅读4分钟

简单了解几种单例模式

说到单例模式就有必要理解一下什么是单例模式:

单例模式是23中设计模式中较为常见的一种设计模式,其类别归属为创建型设计模式。单例可以简单定义为一个类只能产生一个示例,即所谓的全局唯一。

实现几种单例模式的方式大同小异,他们共同的核心步骤为:

1. 将类的构造方法私有化,即`private Singleton() {}`,这样做的目的是防止代码段中通过构造函数生成不同的实例对象。
2. 在类中提供一个静态方式,旨在生成一个唯一的实例,如果静态引用为空则尝试初始化一个对象,并将引用指向它,否则直接返回。

饿汉式和懒汉式

/**
 * 饿汉式单例
 *
 * @author chenq
 */
public class SingletonHungry {
​
    // 主动创建实例对象
    private static SingletonHungry singletonHungry = new SingletonHungry();
​
    // 构造方法私有化
    private SingletonHungry() {
​
    }
​
    // 静态共有方式,获取类实例唯一路径
    public static SingletonHungry getInstance() {
        return singletonHungry;
    }
}

众所周知,类加载的方式是按需加载,且加载一次,因此上述类在加载时就会生成一个SingletonHungry对象,即在整个生命周期只会创建一次对象,充分保证单例。

它主要的问题是存在内存浪费,甚至可能导致内存泄漏。不管代码是否使用,在类加载时就创建类实例,没有达到延迟加载的效果,也无意义的占用了内存空间。

/**
 * 懒汉式单例
 *
 * @author chenq
 */
public class SingletonLazy {
​
    // 实例引用
    private static SingletonLazy singletonLazy;
​
    // 构造方法私有化
    private SingletonLazy() {
​
    }
​
    // 静态共有方式,获取类实例唯一路径
    public static SingletonLazy getInstance() {
        // 当需要时才创建实例
        if (singletonLazy == null) {
            singletonLazy = new SingletonLazy();
        }
        return singletonLazy;
    }
}

懒汉式的单例可以看到是延迟加载的,只有第一次调用getInstance方式才会创建实例。

它的问题是只能在单线程下使用,多线程的情况下会出现创建多个实例的情况,这在单例模式中是不允许的,所有才出现了本文所探讨的双重校验锁单例模式。

双重校验锁实现单例模式

/**
 * 双重校验锁实现单例
 *
 * @author chenq
 */
public class SingletonDoubleCheck {
​
    // 类引用
    private static volatile SingletonDoubleCheck singletonDoubleCheck;
​
    // 构造函数私有化
    private SingletonDoubleCheck() {
​
    }
​
    // 双重校验 + 锁实现单例
    public static SingletonDoubleCheck getInstance() {
        // 第一次校验是否为null
        if (singletonDoubleCheck == null) {
            // 不为空则加锁
            synchronized (SingletonDoubleCheck.class) {
                // 第二次校验是否为null
                if (singletonDoubleCheck == null) {
                    singletonDoubleCheck = new SingletonDoubleCheck();
                }
            }
        }
        return singletonDoubleCheck;
    }
}

第一次校验是否为null:

主要是为了实现返回单例,避免多余的加锁操作,以及锁的等待和竞争,如果条件不成立就说明已经生成实例,直接返回即可,提高程序执行的效率。

第二次校验是否为null:

第二次校验是关键,这里防止了多线程创建多个实例(一般为两个),这里的特殊情况是这样的:在未创建实例的情况下,A线程和B线程都通过了第一次校验(singletonDoubleCheck为空),这时如果通过竞争B线程拿到了锁就会执行一次new操作,生成一个实例,然后B执行完了A就会拿到资源的锁,如果没有第二次判断的话,这时A线程也会执行一次new操作,这里就出现了第二个类实例,违背了单例原则。所以说两次校验都是必不可少的

提一下上述代码中类引用中的volatile关键字是不能少的:

常见的,该关键字能够实现变量在内存中的可见性(告诉JVM在使用该关键字修饰的变量时在内存中取值,而不是用特定内存区域的副本,因为真实的值可能已经被修改过了),它的另外一种作用是防止JVM对指令进行重排。

其实,在new一个对象的时候会有如下步骤(指令):

1. 分配内存空间
2. 初始化引用
3. 将引用指向内存空间

正常的逻辑肯定以为是这样执行的 1 -> 2 -> 3,但是偏偏JVM拥有指令重排的能力,所以说执行顺序是随机的,可能是 1 -> 3 -> 2,这样的话在多线程环境下可能会拿到空引用:线程A先执行了1,3步骤,紧接着线程B执行getInstance,发现不为null(这里的==是判断实际的值,即引用指向的内存空间),就会返回引用,然而此时引用未初始化。所以说volatile在这里保证指令的执行顺序,在多线程情况下不可少。