设计模式之单例模式

保证一个类仅有一个实例,并提供一个访问它的全局访问点。

艾迪生维斯理 《设计模式》

版权声明:本文为 冬夏 原创文章,可以随意转载,但请注明出处。

概述

在我们日常编写程序的时候,经常需要一种这样的对象。我们希望整个系统只有一个这样的对象,不论在什么时候和不论在哪里获取这个对象的时候,获得的都是同一个对象。

比如说系统的任务管理器,我们希望整个系统只有一个任务管理器,不论什么时候打开任务管理器,都可以看到当前系统的所有任务,而不是把任务分散在很多个任务管理器里。

又比如说打印机,当电脑连接上一台打印机的时候,我们会希望不管是在文档A里使用或者在文档B里使用的时候,都是同一台打印机,而且能够按顺序打印。

我们把这种类似的需求不断总结并归纳起来,就成了单例模式。

单例模式可以说是所有设计模式里面最简单的了,但是要灵活并且准确地使用它也不是那么容易的。

首先观察一下单例模式的 UML 图。

从 UML 图中我们可以观察到单例模式的几个特点

  1. 私有的、静态的实例对象

  2. 私有的构造函数

  3. 公有的、静态的获取实例对象的方法

那么,什么样的代码可以同时满足这几个特点呢?

懒汉模式

所谓的懒汉模式,就是一开始并不实例化对象,等到需要使用的时候才实例化。

{% codeblock 懒汉模式 lang:java %}
    public class Singleton {

      private static Singleton instance = null;

      private Singleton(){}

      public static Singleton getInstance() {
          if (instance == null)
          {
              instance = new Singleton();
          }
          return instance;
      }
    }
    {% endcodeblock %}

从上面的代码我们可以看到,当第一次获取 Singleton 实例的时候,instance 为空,将创建 Singleton 对象,并赋值给 instance 变量。以后的每次一获取都将获得第一次创建的 Singleton 对象,从而实现了唯一性。

线程安全验证

仔细想想这段代码,可能存在什么问题呢?

假设有这么一种情况, Singleton 对象还没有创建,这时候有很多个线程同时获取 Singleton 对象,这时候会发生什么呢?

用下面的代码可以验证

{% codeblock 懒汉模式 线程安全验证 lang:java %}
    public class Singleton {
      private static int count = 0;

      private static Singleton instance = null;

      private Singleton(){
          try {
              Thread.sleep(10);
          }catch (InterruptedException e){

          }
          System.out.println("Singleton 私有构造方法被调用 " + ++count + "次");
      }

      public static Singleton getInstance() {
          if (instance == null)
          {
              instance = new Singleton();
          }
          return instance;
      }
    }


    public class Test {
      public static void main(String[] args){

          Runnable runnable = new Runnable() {
              @Override
              public void run() {
                  Singleton singleton = Singleton.getInstance();
                  System.out.println("当前线程:" + Thread.currentThread().getName() +
                          " Singleton: " + singleton.hashCode());
              }
          };

          for (int i = 0; i < 10; i++){
              new Thread(runnable).start();
          }
      }
    }
    {% endcodeblock %}

从上面的代码可以看到,我们对懒汉模式做了一点小修正,在创建 Singleton 对象的时候让当前线程休眠了10ms,这主要是因为计算机运算速度太快了,不让当前线程休眠一下的话很难出现想要的结果。关于休眠我们可以把它想象成创建对象的过程中需要消耗一定的时间。

运算部分结果如下:

{% codeblock 懒汉模式 线程安全验证结果 lang:java %}
    Singleton 私有构造方法被调用 1次
    当前线程:Thread-1 Singleton: 2044439889
    Singleton 私有构造方法被调用 4次
    Singleton 私有构造方法被调用 3次
    Singleton 私有构造方法被调用 2次
    当前线程:Thread-0 Singleton: 605315508
    当前线程:Thread-2 Singleton: 2298428
    当前线程:Thread-3 Singleton: 1005746524
    当前线程:Thread-4 Singleton: 1005746524
    当前线程:Thread-5 Singleton: 1005746524
    当前线程:Thread-6 Singleton: 1005746524
    当前线程:Thread-7 Singleton: 1005746524
    当前线程:Thread-8 Singleton: 1005746524
    当前线程:Thread-9 Singleton: 1005746524
    {% endcodeblock %}

从上面的结果可以看到,Singleton 的私有构造方法被调用了不止一次。对此的解释是,当第一次获取 Singleton 对象还没完成的时候,线程被系统挂起了,这时候有其他线程刚好也获取了 Singleton 对象,那么就会产生多个 Singleton 对象。

由此我们可以得出结论:懒汉模式是 非线程安全 的。

同步方法

为了解决懒汉模式非线程安全的缺点,就出现了改进的懒汉模式。其原理是当多个线程同时获取 Singleton 对象时,一次只让一个线程获取,其他线程都在等待,这样就解决了多线程下的对象获取问题。

{% codeblock 同步方法 lang:java %}
    public class Singleton {
      private static Singleton instance = null;

      private Singleton(){}

      public static synchronized Singleton getInstance() {
          if (instance == null)
          {
              instance = new Singleton();
          }
          return instance;
      }
    }
    {% endcodeblock %}

我们通过 synchronized 关键字让 getInstance()方法一次只能让一个线程调用,但是随着而来的又有另外一个问题。

那就是 效率问题,因为只有第一次获取 Singleton 对象时有可能发生线程安全问题,但是使用同步方法却让每次只让一个线程能访问getInstance()方法,而不管 Singleton 对象是不是已经被创建出来了。

那么有没有办法能同时解决线程安全和效率问题呢?

双重校验

双重校验 方式就是为了解决懒汉模式的线程安全和效率问题而产生的。

{% codeblock 双重校验 lang:java %}
    public class Singleton {

      private static Singleton instance = null;

      private Singleton(){}

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

双重校验就是将前面两种懒汉模式结合起来。当第一次获取 Singleton 对象时, instance 为空, 这时候为了解决可能存在的线程安全问题,同步了 Singleton 这个类对象。也就是说,同一时刻只能有一个线程能够执行 synchronized 之后的代码。同时因为同步代码外层有一个条件语句,所以同步代码只有在第一次获取 Singleton 对象的时候执行到,这样就解决了效率问题。

但是这种方法还是有一个问题,那就是 instance = new Singleton() 这一行代码并不是原子性的

具体来说,JVM执行这一行代码时主要做了三件事

  1. 给 instance 分配内存空间
  2. 调用 Singleton 的构造函数来初始化成员变量
  3. 将 instance 变量指向分配的内存空间(执行完这一步之后 instance 就不为 null 了)

由于 JVM 的指令优化存在,上面的第二点和第三点并不能保证一定按顺序执行。也就是说执行顺序有可能为 1-2-3 或者 1-3-2。

假设是 1-3-2,那么如果执行到3的时候,线程被抢占了,有另外一个线程获取了单例对象(这时候 instance 不为 null,但是还没有初始化),那么自然就会出现错误。

为了解决这个问题,我们只要将 instance 变量声明成 volatile 就可以了。

private static volatile Singleton instance = null;

volatile 关键字主要有两个特性

  1. 可见性:保证线程没有变量的本地副本,每次都去主内存获取最新版本
  2. 禁止指令重排序:生成内存屏障

很明显,我们这里利用的是 volatile 的第二个特性。

特别注意的是只有在 Java 5 之后使用这种方式才是完全安全的,原因是 Java 5 之前的 Java 内存模型(Java Memory Model,JMM)存在缺陷,即使变量声明为 volatile 也不能完全避免重排序,这个问题在 Java 5 之后才修复。

恶汉模式

这时候我们可以换个思路,既然懒汉模式是因为需要的时候才创建对象,所以才让程序有机会可以产生多个对象。那如果我一开始就把对象创建好了,不就行了吗?这就出现了恶汉模式。

恶汉模式的意思是不管对象目前有没有使用,都会先创建出来。

{% codeblock 恶汉模式 lang:java %}
    public class Singleton {

      private static final Singleton instance = new Singleton();

      private Singleton(){}

      public static Singleton getInstance() {
          return instance;
      }
    }
    {% endcodeblock %}

从代码中可以看到,由于在 Singleton 类加载时就创建了 Singleton 对象,所以恶汉模式是 线程安全 的。

但是恶汉模式存在的问题就是不管目前对象有没有被使用,都被创建了出来,浪费了内存空间。

静态方法

静态方法的单例模式和恶汉模式的原理一样,都是利用了classloader,在类加载的时候就创建了 Singleton 对象。

{% codeblock 静态方法 lang:java %}
    public class Singleton {

      private static Singleton instance = null;

      static {
          instance = new Singleton();
      }
      private Singleton(){}

      public static Singleton getInstance() {
          return instance;
      }
    }
    {% endcodeblock %}

静态内部类

静态内部类的方法和上面两种方法既有相似的地方,也有不同的地方。

{% codeblock 静态内部类 lang:java %}
    public class Singleton {

      private static class SingletonHolder{
          private static final Singleton INSTANCE = new Singleton();
      }
      private Singleton(){}

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

从代码种我们可以看到,静态内部类的方法和前两种方法一样,都是利用了classloader,在加载类的时候创建 Singleton 对象。

不同的地方在于加载的类不同。静态内部类方法在加载 Singleton 类的时候不会创建 Singleton 对象。而是在加载 SingletonHolder 类的时候才会。那么 SingletonHolder 类是什么时候加载的呢?

根据JVM(Java 虚拟机)的类加载规则,静态内部类只有在主动调用的时候才会加载。也就是说,在第一次调用 getInstance() 方法时才会加载 SingletonHolder 类,同时创建了 Singleton 对象。

也可以说,静态内部类的方法利用JVM解决了前两种方法占用内存的问题。

防止单例受到攻击

到目前为止,我们所分析的所有单例模式都有一个前提,那就是调用者非常听话地使用了 Singleton.getInstance() 方法获取单例对象。但是在现实生活中是不是都是这样的呢?会不会有不怀好意的人使用其他方式破坏我们的单例模式呢?

我们先思考一下,获取一个对象有几种方式

  1. 使用 new 关键字
  2. 通过反射调用
  3. 序列化

我们前面的单例模式都是通过第一种方式获取对象的,那么如果采用其他两种方式,之前的单例模式还安全吗?答案是否定的。

反射攻击

首先我们来看一下反射调用,以双重检验方式为例

{% codeblock 反射攻击 lang:java %}
    public class Singleton {

      private static volatile Singleton instance = null;

      private Singleton(){}

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

    public class Test {
        public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException,
                InvocationTargetException, InstantiationException{

            Singleton singleton1 = Singleton.getInstance();

            Class classType = Singleton.class;
            Constructor constructor = classType.getDeclaredConstructor(null);
            constructor.setAccessible(true);
            Singleton singleton2 = (Singleton) constructor.newInstance();
            System.out.println(singleton1 == singleton2);  //false
        }
    }
    {% endcodeblock %}

输出结果是

{% codeblock 反射攻击结果 lang:java %}
    Singleton 私有构造方法被调用 1次
    Singleton 私有构造方法被调用 2次
    false
    {% endcodeblock %}

从结果可以看到,私有的构造函数被调用了两次,也就是说这样的单例模式并不安全。

为了防止单例模式被反射攻击,我们可以添加一个标志位,在新建对象时判断是否已经新建过对象了。

{% codeblock 防止反射攻击 lang:java %}
    public class Singleton {

      private static boolean flag = false;

      private static volatile Singleton instance = null;

      private Singleton(){

          if (!flag){
              flag = true;
          }else {
              throw new RuntimeException("构造函数被调用多次");
          }
      }

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

当然这种方式也有一个缺点,那就是必须保证 Singleton.getInstance() 方法在反射之前调用,否则将不能正确获取单例对象。

而且,既然我们可以通过反射创建出对象,那么也可以通过反射修改标志位的值,这样一来,使用标志位的方法就不能完全防止反射攻击了。

序列化攻击

接下来我们看一下序列化如何破坏单例模式,以恶汉模式为例。

{% codeblock 序列化攻击 lang:java %}
    public class Singleton implements Serializable{

      private static final Singleton instance = new Singleton();

      private Singleton(){}

      public static Singleton getInstance() {
          return instance;
      }
    }

    public class Test {
      public static void main(String[] args) throws IOException,ClassNotFoundException{

          Singleton singleton1 = Singleton.getInstance();
          Singleton singleton2;

          FileOutputStream fos = new FileOutputStream("SerSingleton.obj");
          ObjectOutputStream oos = new ObjectOutputStream(fos);
          oos.writeObject(singleton1);
          oos.flush();
          oos.close();

          FileInputStream fis = new FileInputStream("SerSingleton.obj");
          ObjectInputStream ois = new ObjectInputStream(fis);
          singleton2 = (Singleton)ois.readObject();

          System.out.println(singleton1==singleton2);
      }
    }
    {% endcodeblock %}

输出结果为 false 表明我们的单例收到了攻击,那么如何防止这种情况呢?

我们可以在被序列化的类中添加readResolve方法

{% codeblock 防止序列化攻击 lang:java %}
    public class Singleton implements Serializable{

      private static final Singleton instance = new Singleton();

      private Singleton(){}

      public static Singleton getInstance() {
          return instance;
      }

      private Object readResolve(){
          return instance;
      }
    }
    {% endcodeblock %}

说了这么多,不知道大家有没有这样一种感慨 「 都说单例模式是最简单的一种模式,这么还这么复杂,以后还让不让人活了 」。

那么有没有一种又简单有能防止所有攻击的方法呢?

枚举

枚举( enum )是 Java1.5 之后新加的特性。

大家一定很奇怪,为什么枚举可以实现单例呢?其实和 Java 的编译特性有关。因为枚举是 Java1.5 之后新加的,一般新加入的功能有一个很重要的问题需要解决,就是对以前代码的兼容性问题。而 Java 是通过 语法糖 的方式解决的。简单来说就是编写代码的时候可以使用新的关键字 enum 编写程序,但是 Java 编译器在编译成字节码的时候,还是会利用现有的技术编译成之前的 JVM 能够识别并正确运行的字节码,这就是语法糖技术。

我们先来看一下枚举编写的单例是什么样子的。

{% codeblock 枚举 lang:java %}
    public enum Singleton {

      INSTANCE;

      public static Singleton getInstance(){
          return INSTANCE;
      }

      public void otherMethods(){
          System.out.println("do something");
      }
    }
    {% endcodeblock %}

这段代码看起来很简单,我们定义了一个枚举类型 INSTANCE, 这就是我们需要的单例。但是为什么这样就能实现线程安全的单例呢?要解决这个疑问,我们必须把这段代码进行反编译,看看 java 编译器究竟是如何编译这段代码的。

我们使用 java 自带的反编译工具 javap 就可以将这段代码反编译

javap -c Singleton

反编译结果如下:

{% codeblock 反编译 lang:java %}
    public final class Singleton extends java.lang.Enum {
    public static final Singleton INSTANCE;

    public static Singleton[] values();
      Code:
         0: getstatic     #1                  // Field $VALUES:[LSingleton;
         3: invokevirtual #2                  // Method "[LSingleton;".clone:()Ljava/lang/Object;
         6: checkcast     #3                  // class "[LSingleton;"
         9: areturn

    public static Singleton valueOf(java.lang.String);
      Code:
         0: ldc           #4                  // class Singleton
         2: aload_0
         3: invokestatic  #5                  // Method java/lang/Enum.valueOf:(Ljava/lang/Class;Ljava/lang/String;)Ljava/lang/Enum;
         6: checkcast     #4                  // class Singleton
         9: areturn

    public static Singleton getInstance();
      Code:
         0: getstatic     #7                  // Field INSTANCE:LSingleton;
         3: areturn

    public void otherMethods();
      Code:
         0: getstatic     #8                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #9                  // String do something
         5: invokevirtual #10                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return

    static {};
      Code:
         0: new           #4                  // class Singleton
         3: dup
         4: ldc           #11                 // String INSTANCE
         6: iconst_0
         7: invokespecial #12                 // Method "":(Ljava/lang/String;I)V
        10: putstatic     #7                  // Field INSTANCE:LSingleton;
        13: iconst_1
        14: anewarray     #4                  // class Singleton
        17: dup
        18: iconst_0
        19: getstatic     #7                  // Field INSTANCE:LSingleton;
        22: aastore
        23: putstatic     #1                  // Field $VALUES:[LSingleton;
        26: return
    }
    {% endcodeblock %}

可能这段代码对于刚刚接触 java 的人来说一时可能看不懂,但是我们只要关注到一下几点就好了。

  1. public final class Singleton extends java.lang.Enum<Singleton> 这说明枚举类型实际上被 java 编译器通过语法糖转换成了不可变类,继承自 Enum 类。
  2. public static final Singleton INSTANCE,说明我们定义的枚举值 INSTANCE 实际上被 java 编译器转换成了不可变对象,只可以初始化一次。
  3. 关注到 INSTANCE 实际上是在 static {} 这段代码里初始化的。也就是说, INSTANCE 是在 Singleton 类加载的时候初始化的,所以一旦 Singleton 类加载了,INSTANCE 也就初始化了,不能再改变了,这就实现了单例模式。

然后如果我们尝试使用序列化或者反射的方式去攻击枚举单例,会发现都不能成功,这是由于 JVM 实现枚举的机制决定的。

最后,引用一下 《Effective Java》一书中的话。

单元素的枚举类型已经成为实现Singleton的最佳方法。

《Effective Java》
冬夏 wechat
欢迎您扫一扫上面的二维码,关注我的个人公众号:Android从入门到精通
坚持原创技术分享,您的支持将鼓励我继续创作