单例模式 - 并不是你想象的那么简单

2,410 阅读7分钟
原文链接: www.jianshu.com

最近正值面试高峰期,设计模式相关知识对于项目的构建和重构都有着至关重要的作用,而作为设计模式中最基础的单例模式及工厂模式又几乎是最常用到的,所以也是面试中问的比较多的,今天就给大家带来单例模式的详解。

出现原因

单例模式出现的主要原因是为了节约内存,比如说使用FileIO类来进行文件类的操作,你可能在多个地方要使用到FileIO类并创建多个对象,这种频繁的创建与销毁占用资源较多的类有极大的可能产生内存抖动甚至是内存溢出的情况发生。此时,我们的做法就是先创建一个FileIO类的实例,在其他类中使用这一个实例就可以了。单例模式也就应运而生了。

模式定义

单例模式:确保一个类只有一个实例,并且自行实例化并向整个系统中提供这个实例,这个类称为单例类,他提供全局访问的方法。
这个定义并不难理解,其中重点如下:

  1. 只有一个实例。类的实例是不能在别的地方创建的,所以需要私有化构造器。
  2. 自行创建这个实例。构造器被私有后,只能自己在类中创建自己的实例了。
  3. 向整个系统提供这个实例。在类中创建的实例通过全局访问的方式接受系统中的类的访问。

饿汉式

根据单例模式的定义,我们来创建一个单例类:

/**
 * Created by flame on 2017/2/20.
 */
public class Singleton {
   // 自行创建这个实例
   public static Singleton instance = new Singleton();
   // 私有化构造器,确保只有一个实例
   private Singleton() {}
   //向整个系统提供这个实例
   public static Singleton getInstance() {
      return instance;
   }
}

以后别人在调用Singleton的时候就不再使用new的方式去创建一个Singleton的实例,而是调用static方法getInstance()获取Singleton的实例,例:

Singleton.getInstance();

这样一来,类的实例就不会被重复创建了,起到了节约内存的作用。这种上来就创建类的实例的方式,被我们称为饿汉式。并且饿汉式也是一种线程安全的方式。

懒汉式

对上述模式进行一点简单的修改,当我们调用getInstace()时,再创建它的实例。

/**
 * Created by flame on 2017/2/20.
 */
public class Singleton {
   // 自行创建这个实例
  public static Singleton instance;
   // 私有化构造器
   private Singleton() {
   }
   //向整个系统提供这个实例
   public static Singleton getInstance() {
     //判断是否第一次创建
     if (instance == null) {
         instance = new Singleton();
     }
     return instance;
   }
}

这种懒得创建实例,一直拖到使用时在创建的方式被我们成为懒汉式。懒汉式还有个问题,它是线程不安全的。

线程安全

当有多个线程并发执行getInstance()的时候,可能会出现以下情况导致出现多个Singleton的实例。
线程一:Singleton.getInstance()

判断Singleton为null,执行Singleton的初始化。

线程二:Singleton.getInstance()

Singleton的初始化还没有完成,也执行Singleton的初始化。

等到两个线程都执行完后,实际上是创建了两个Singleton的实例。


流程图


这时我们需要配合synchronized关键在来确保线程安全,改进后的代码如下:

/**
* Created by flame on 2017/2/20.
*/
public class Singleton {
  // 自行创建这个实例
  public static Singleton instance;

  // 私有化构造器
  private Singleton() {

  }

  //向整个系统提供这个实例
  public static Singleton getInstance() {
      synchronized (Singleton.class) {
          if (instance == null) {
              instance = new Singleton();
          }
      }
      return instance;
   }
}

这样一来,当有两个线程同时执行getInstance()的时候,一旦线程一获取到了Singleton.class线程锁的时候,线程而只能在外面等待。
在线程一执行完getInstance()的逻辑后,释放Singleton.class线程锁,其他线程才能够进入其中,这样避免了创建连个Singleton的实例。


流程图

再次优化-双重校验锁

上述方式很是简洁,也能确保线程的安全,但是在性能上却有所损耗。假设有以下场景:

  1. 现有线程1,线程2,线程3分别获取Singleton的实例。
  2. 当线程1进入方法中以后,获取锁(Singleton.class),线程2和线程3在外部等待。
  3. 线程1创建了Singleton的实例,并释放了锁。
  4. 线程2进入方法中,获取锁,线程3在外部等待。

当执行到第四部时,我们发现了问题,此时Singleton已经实例化过了,并不需要同步锁来控制,所有的线程只需要过来拿走它的实例即可。针对此问题,作出如下优化:

/**

* Created by flame on 2017/2/20.
*/
public class Singleton {
  // 自行创建这个实例
  public static volatile Singleton instance;

  // 私有化构造器
  private Singleton() {

  }

  //向整个系统提供这个实例
  public static Singleton getInstance() {
      //检验一
      if(instance==null){
        synchronized (Singleton.class) {
            //检验二
            if (instance == null) {
               instance = new Singleton();
            }
         }
      }
      return instance;
   }
}

此时,当我们按原先过程执行到第4步的时候,会判断instance的值是否为null,当发现不是null的时候,直接返回已经实例化的对象,这样不会对性能产生影响。
细心的同学肯定还发现了,我增加了一个volatile关键字,注意,这里算是面试的加分项了。
线程一进入检验二之后执行instance = new Singleton()操作,在这个操作中,JVM总共干了三件事:

  1. 在堆空间中分配一些空间
  2. 执行Singleton()的构造方法
  3. 把instance的对象指向在堆空间里分配好的空间
    但是当我们执行编译的时候,编译器在生成汇编代码的时候会对流程顺序进行优化,优化的结果可能按照1-2-3顺序执行,也可能按照1-3-2顺序执行。
    如果按照1-3-2的顺序执行,当执行了3的时候,instance已经不是空了,若此时线程2进入执行getInstance()方法后,判断instance不为空以后直接返回了Singleton的实例。
    此时instance还没有执行构造方法进行初始化,假设构造方法需要对一些参数进行初始化,当线程2的后续操作会用到这些参数的话,就可能会出现错误。

静态内部类

看完上面的优化之后,我想大部分人都会选择饿汉式的,代码少而且线程安全。
但是当我们需要在构造方法中传入参数时,就不能使用饿汉式的方式了。
不仅仅如此,使用饿汉式也有可能影响效率,来看看如下的代码,

/**

 * Created by flame on 2017/2/20.
 */
public class Singleton {
   // 自行创建这个实例
   public static Singleton instance = new Singleton();
   public static String  TAG = "tag";
   // 私有化构造器,确保只有一个实例
   private Singleton() {}
   //向整个系统提供这个实例
   public static Singleton getInstance() {
      return instance;
   }
}

当我们想要调用Singleton.TAG的时候,instance的实例也会被初始化,影响了效率,这显然不是我们想要的。为此,又出现了一种叫做静态内部类的方式来实现单例。

/**
* Created by flame on 2017/2/20.
*/
public class Singleton {
    private static final class SingletonHolder{
       private static final Singleton INSTANCE = new Singleton();
    }
       private Singleton() {

       }
   public static Singleton getInstance() {
       return SingletonHolder.INSTANCE;
   }
}

当执行getInstance()的时候就去调用SingletonHolder内部类里面的instance实例,此时SingletonHolder内部类会被加载到内存里,在类加载的时候就会对INSTANCE实例进行初始化。和饿汉式一个道理,保证了只有一个实例,而且在调用getInstance()的时候才进行INSTANCE实例的初始化,又具有了懒汉式的部分特性,而且不会出现调用静态成员同时对instance进行实例化的情况。
此种方式是利用JVM的特定机制,在其他语言中不一定适用的。

枚举

最后的最后,还有一种黑科技的方式,使用枚举来实现单例:

public enum  Singleton {
    INSTANCE;
    private Singleton(){}
}

这种方式在《Effective Java》中被提出,虽然看起来是一个枚举类,但是枚举类实际上就是一个继承了Enum的类,因为枚举的特点,保证了只有一个实例,同时保证了线程安全。
虽然这种方式看起来很酷炫,但是因为枚举的效率原因在开发中一般不适用。