你的单例是高效安全的吗

2019-04-182693

title: 你的单例是高效安全的吗 tags: 单例,线程安全, 双重检查锁定,延迟初始化

grammar_cjkRuby: true

你的单例是高效安全的吗

前言

大家去面试时,是不是经常被问到你会写单例模式吗?其实面试官问这个问题并非毫无玄机。因为不同层次的单例模式,真的可以初识一个人的技术水平。话不多说,我们直接进入正题。

什么情况下用单例模式

单例模式的好处有以下3点:

  • 控制资源的使用,通过线程同步来控制资源的并发访问。

  • 控制实例产生的数量,达到节约资源的目的。

  • 作为通信媒介使用,也就是数据共享,它可以在不建立直接关联的条件下,让多个不相关的两个线程或者进程之间实现通信。

常见的例子有数据库连接池、创建和删除文件、打印机等。

单例在Java中的实现

通常说了解单例模式的格子衫们都知道,单例的实现方式有懒汉式饿汉式

  • 懒汉式。只有在自身需要的时候才会去创建它,这也是延迟初始化的思想。

      public class UnsafeLazyInitialization{
              private static Instance instance;
    
              public static Instance getInstance(){
                  if(intance == null){//1:A线程执行
                      instance = new Instance();//2:B线程执行
                  }
                  return instance;
              }
    
      }
  • 饿汉式。它在类加载的时候就立即创建对象。

      public class UnsafeLazyInitialization{
              private static Instance instance = new Instance();
    
              public static Instance getInstance(){
                  return instance;
              }
    
      }
    

如果求职者是这样实现的,那么可以初步判断该名求职者不是技术深入型人才。因为上面的懒汉式会存在线程安全问题,这也是本文重点谈论的问题。当然,饿汉式不会存在线程安全问题。至于为何不安全,这里先卖个关子,下文会进行解释。

考虑到线程安全问题后,有些程序员做如下改进

public class SafeLazyInitialization{
        private static Instance instance;

        public synchronized static Instance getInstance(){
            if(intance == null){
                instance = new Instance();
            }
            return instance;
        }

}

恩,不错!这次做到了线程安全。但有没有考虑到性能方面的问题呢?这是以牺牲性能来换取安全的。由于对getInstance()方法做了同步处理,synchronized将导致性能开销。如果getInstance()方法被多个线程频繁调用,将会导致程序执行性能下降。反之,getInstance()方法不会被多个线程频繁调用,那么这个延迟初始化方案将能提供令人满意的性能。

考虑到性能问题后,这时双重检查锁定(Double-Checked Locking) 就派上用场了,通过双重检查锁定改进后的代码如下

public class DoubleCheckedLocking {//1
        private static Instance instance;//2

        public static Instance getInstance(){//3
            if(intance == null){//4:第一次检查
                synchronized (DoubleCheckedLocking.class){//5:加锁
                    if(intance == null){//6:第二次检查
                        instance = new Instance();//7: 问题的根源
                    }//8
                }//9
            }//10
            return instance;//11
        }
}

如上面的代码所示,如果第一次检查instance不为null,那么就不需要执行下面的加锁和初始化操作,因此,可以大幅度降低synchronized带来的性能开销。上面代码表面看起来,似乎两全其美。

  • 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象
  • 在对象创建好后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象

双重检查锁定看起来似乎很完美。这时心细的读者就会发现,该版本和最开始的懒汉式不是一样存在线程安全问题吗?是的没错,但到了这一步已经很接近我们想要的结果了,因为最起码同时考虑到了安全和性能问题。接下来我们就分析为何会存在线程安全问题!

线程执行到第4行,代码读到instance不为null时,instance引用有可能还没有完成初始化。在UnsafeLazyInitialization类中,假设A线程执行代码1的同时,B线程执行代码2。此时,线程A可能会看到instance引用的对象还没有完成初始化。因为代码2我们可以分为以下三步执行

1. memory = allocate();//1: 分配对象的内存空间
2. ctorInstance(memory);//2: 初始化对象
3. instance = memory;//3: 设置instance指向刚分配的内存地址

在JMM模型中,在不影响结果的情况下可以重排序,所以2和3可能会被重排序,排序后的顺序如下

memory = allocate();//1: 分配对象的内存空间
instance = memory;//3: 设置instance指向刚分配的内存地址(注意此时对象还没有被初始化)
ctorInstance(memory);//2: 初始化对象

注:根据《The Java Language Specification,Java SE 7 Edition》,所有线程在执行java程序时必须遵守intra-thread semantics。intra-thread semantics保证重排序不会改变单线程内的程序执行结果。换句话说,intra-thread semantics允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面3行伪代码的2和3之间虽然被重排序了,但这个重排序并不会违反intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能。

知晓了问题发生的根源后,我们可以想出两个办法来实现线程安全的延迟初始化。

  1. 不允许2和3重排序。
  2. 允许2和3重排序,但不允许其他线程"看到"这个重排序。

此时volatile就派上用用场了,因为volatile关键字修饰的属性,对属性修改时,对所有的读进程是可见的。这里先补充一下volatile的特性

  • 可见性。对于一个volatile变量的读,总能看到(任意线程)对这个volatile变量最后的写入
  • 原始性。对于任意单个volatile变量的读/写具有原始性,但类似于volatile++这种复合操作不具有原始性

优化后的代码如下

public class DoubleCheckedLocking {
        private volatile static Instance instance;

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

}

没错,这就是我们最终的懒汉式单例模式实现效果。如果你的求职者可以写出这样的代码,并且知其所以然,我想他应该是一位不错的苗子!

至此,我的分享完毕,欢迎各位读者积极交流点评!

分享
点赞8
打赏
上一篇:Docker常用命令笔记(一)
下一篇:Elasticsearch的简单使用