Java的懒汉式双检锁单例模式

Java的懒汉式双检锁单例模式

文章目录

  • Java的懒汉式双检锁单例模式
    • 一、 实现一个双检锁
    • 二、 为什么线程不安全
    • 三、 关于指令重排序
    • 四、 关于原子操作
    • 五、 实现线程安全的双检锁

首先回忆一下,Java中的单例模式有两种,俗称“饿汉式”和“懒汉式”。

饿汉式

public class SingletonEH {
    /**
     *是否 Lazy 初始化:否
     *是否多线程安全:是
     *实现难度:易
     *描述:这种方式比较常用,但容易产生垃圾对象。
     *优点:没有加锁,执行效率会提高。
     *缺点:类加载时就初始化,浪费内存。
     *它基于 classloder 机制避免了多线程的同步问题,
     * 不过,instance 在类装载时就实例化,虽然导致类装载的原因有很多种,
    * 在单例模式中大多数都是调用 getInstance 方法,
     * 但是也不能确定有其他的方式(或者其他的静态方法)导致类装载,
     * 这时候初始化 instance 显然没有达到 lazy loading 的效果。
     */
    private static SingletonEH instance = new SingletonEH();
    private SingletonEH (){}
    public static SingletonEH getInstance() {
        System.out.println("instance:"+instance);
        System.out.println("加载饿汉式....");
        return instance;
    }
}
 

懒汉式

public class SingletonLHsyn {
    /**
     *是否 Lazy 初始化:是
     *是否多线程安全:是
     *实现难度:易
     *描述:这种方式具备很好的 lazy loading,能够在多线程中很好的工作,但是,效率很低,99% 情况下不需要同步。
     *优点:第一次调用才初始化,避免内存浪费。
     *缺点:必须加锁 synchronized 才能保证单例,但加锁会影响效率。
     *getInstance() 的性能对应用程序不是很关键(该方法使用不太频繁)。
     */
    private static SingletonLHsyn instance;
    private SingletonLHsyn (){}
    public static synchronized SingletonLHsyn getInstance() {
        if (instance == null) {
            instance = new SingletonLHsyn();
        }
        return instance;
    }
}
  • 线程安全:饿汉式在线程还没出现之前就已经实例化了,所以饿汉式一定是线程安全的。懒汉式是非线程安全的,可以通过加同步锁解决线程安全问题。
  • 执行效率:饿汉式没有加任何的锁,因此执行效率比较高。懒汉式一般使用都会加同步锁,效率比饿汉式差。
  • 内存使用:饿汉式在一开始类加载的时候就实例化,无论使用与否,都会实例化,所以会占据空间,浪费内存。懒汉式什么时候用就什么时候实例化,不浪费内存。

懒汉式的线程问题可以通过加同步锁来解决,但是上面的懒汉式的代码的性能有点不好,同步锁加在了方法上面,如果实例之前已经初始化完成,每次调用方法都会去访问到同步锁,性能开销比较大,这个时候实际上不在需要加锁了,直接把实例化好的对象返回就可以了。这就引出了下面要说的双检锁,它其实是对懒汉式的一个优化。

一、 实现一个双检锁

双检锁,顾名思义,两次检查一次锁:

public class DoubleCheckLock {
    private static DoubleCheckLock instance;

    private DoubleCheckLock() {
    	// TODO
    }

    public static DoubleCheckLock getInstance() {
        if (instance == null) {
            synchronized (DoubleCheckLock.class) {
                if (instance == null) {
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

外层判空是为了解决已经实例化对象后调用方法的性能问题(访问修饰锁的方法有较大开销),中间加锁的方法是为了保证同一时间只有一个线程去实例化对象,内层判空是为了解决非原子操作可能因指令重排序导致的问题。看似是已经完美了,但是还是不安全,下面举例说明一下。

二、 为什么线程不安全

假设有两个线程,当线程A执行到" instance = new DoubleCheckLock();"这一行,而线程B执行到外层"if (instance == null) "时,可能出现instance还未完成构造,但是此时不为null导致线程B获取到一个不完整的instance。
之所以会出现这种情况,要从JVM的指令重排序说起。

三、 关于指令重排序

指令重排序:是编译器在不改变执行效果的前提下,对指令顺序进行调整,从而提高执行效率的过程。
一个最简单的重排序例子:

int a = 1;
String b = "b";

对于这两行毫无关联的操作指令,编译器可能会将其顺序调整为:

String b = "b";
int a = 1;

此时该操作并不会影响后续指令的执行和执行结果。
再回过头看我们的双检锁内部,对于"instance = new DoubleCheckLock();"这一行代码,它分为三个步骤执行:

  • 1.分配一块内存空间
  • 2.在这块内存上初始化一个DoubleCheckLock的实例
  • 3.将声明的引用instance指向这块内存

第2和第3个步骤都依赖于第1个步骤,但是2和3之间没有依赖关系,那么如果编译器将2和3调换顺序,变成了:

  • 1.分配一块内存空间
  • 2.将声明的引用instance指向这块内存
  • 3.在这块内存上初始化一个DoubleCheckLock的实例

当线程A执行到第2步时,instance已经不为null了,因为它指向了这块内存,此时如果线程B走到了"if (instance == null)",那么线程B其实拿到的还是一个未初始化的实例,因为这块内存还没有初始化,这就出现了问题。
指令重排序是导致出现线程不安全的直接原因,而根本原因则是对象实例化不是一个原子操作。

四、 关于原子操作

原子操作:不可划分的最小单位操作,不会被线程调度机制打断,不会有线程切换,整个操作要么不执行,一旦执行就会运行到结束。

我们来看一个简单的例子:

Object a;
Object b = new Object();
a = b;

对于"a = b" 这一操作指令,将a这个引用指向b这一对象的内存,只需要改变a的指针,因此该直接赋值操作是一个不可划分的原子操作。

再看另一个例子:

int i = 0;
i ++;

对于"i ++"这一操作指令,其实它分为三个步骤执行:

  • 读取i的值
  • 将i的值加1
  • 将新的值赋值给i

类似的还有:

boolean b = true;
b = !b;

对于这些涉及自身值的操作,由于其最终实现需要划分更小的操作单位,因此均不是原子操作。

对于非原子操作,在多线程下就可能出现线程安全问题,这也是我们的双检锁不安全的根本原因,实例化对象不是一个原子操作。

五、 实现线程安全的双检锁

我们只需要对instance加上一个volatile修饰符便可解决线程安全问题,其实就是依赖了volatile的禁止进行指令重排序的特性。

public class DoubleCheckLock {
    private static volatile DoubleCheckLock instance;

    private DoubleCheckLock() {
    	// TODO
    }

    public static DoubleCheckLock getInstance() {
        if (instance == null) {
            synchronized (DoubleCheckLock.class) {
                if (instance == null) {
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

除此之外volatile还有一个特性就是可见性,保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。

synchronized和volatile的完美配合,便实现了线程安全的懒汉式双检锁单例模式。

你可能感兴趣的:(单例模式,java,开发语言)