深入理解单例模式(面试必备)

2,894 阅读11分钟

单例模式在平时工作中以及面试中应该算得上是最常见的设计模式之一。如何通过这么简单的一道题目,在众多面试者中脱颖而出呢?

什么是单例模式?

单例模式(Singleton): 保证整个系统中一个类仅有一个对象实例,并提供一个可以访问它的全局访问点。

适用场景

  • 对于无状态的工具类,我们其实只需要一个实例对象即可
  • 全局信息类,保存了一些共享数据或配置。如:网站计数器、数据库连接池等

单例模式的类型

  • 饿汉式 在类加载的时候就创建好单例对象(预先加载)
    • 优点: 实现简单、线程安全
    • 缺点: 可能会造成系统资源浪费(初始化了一些根本用不到的对象);增加服务启动的耗时
  • 懒汉式 在需要使用时才创建单例对象(延迟初始化)
    • 优点: 资源利用率高(只生成需要使用到的实例对象)
    • 缺点: 第一次加载时会比较慢;稍不注意容易写出线程不安全的代码

常见的8种写法

实现单例的主要思路就是构造方法私有化。下面是我们在阐述单例模式时常见的写法,其中会包含几种错误的范例。

在StakcOverflow中,也有关于单例模式写法的讨论:What is an efficient way to implement a singleton pattern in Java?

1、饿汉式——静态常量(线程安全)【可用】

public class Singleton {

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

    public static Singleton getInstance() {
        return INSTANCE;
    }

}

优缺点就是上面所提到的:实现简单,但是在类加载时就会创建该类对象,可能会造成资源浪费。

2、饿汉式——静态代码块(线程安全)【可用】

public class Singleton {

    private static final Singleton INSTANCE;

    static {
        INSTANCE = new Singleton();
    }

    private Singleton() {}

    public static Singleton getInstance() {
        return INSTANCE;
    }

}

效果跟第一种没什么区别,只是写法稍微有点不同。当单例对象需要进行一些比较复杂的赋值操作的时候,一般会使用静态代码块的形式。

3、懒汉式(线程不安全)【不可用】

public class Singleton {

    private static Singleton INSTANCE;

    private Singleton() {}

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

}

多线程并发情况下,假设有两个(或多个)线程同时判断 INSTANCE 为 null,就会导致实例化了多个对象,违反了单例模式的原则,造成线程不安全

4、懒汉式——同步方法(线程安全)【不推荐】

public class Singleton {

    private static Singleton INSTANCE;

    private Singleton() {}

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

}

这种方式虽然是线程安全的,但是每次获取对象前都需要获取锁,并发性能太低(实例化单例对象只发生在第一次调用的时候,后续调用都是直接返回对象引用,因此同步整个方法将导致不必要的性能损失)

5、懒汉式——同步代码块(线程不安全)【不可用】

public class Singleton {

    private static Singleton INSTANCE;

    private Singleton() {}

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

}

由于第4个例子效率太低是因为使用了同步方法,无法多线程并发获取单例对象。那能否当判断单例对象为null时,加锁同步创建对象,这样就不能多线程同时实例化对象,是不是就解决问题了?

答案是否定的。在这个例子中,当两个(或多个)线程同时判断 INSTANCE 为 null 时,就会进入 if 逻辑块,那就无法阻止这两个线程实例化对象。虽然不能同时执行,但是其实已经实例化了多个对象,线程不安全

6、懒汉式——双重检查(线程安全)【推荐使用】

public class Singleton {

    private volatile static Singleton INSTANCE;

    private Singleton() {}

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

}

这种方式是对方式5的改进,只有当 INSTANCE == null 时,才会进入同步代码块,且在同步代码块也做了一次空检查,就可以保证线程安全。

不知道有没有小伙伴发现这里的 INSTANCE 对象引用使用了 volatile 关键字来修饰。

为什么要使用 volatile?

为了解决多线程环境下重排序带来的问题。

创建对象会经过三个步骤(不是原子性):

  1. 创建空的对象(分配内存)
  2. 调用构造方法(对象初始化)
  3. 将构造好的实例地址赋值给引用

模拟下出问题的流程(假设不加volatile关键字):

  • 当 线程1 判断 INSTANCE 为空,进入同步逻辑,继续检查为空,则创建对象
  • 如果此时发生指令重排序,执行 1->3->2 步骤,先创建了空对象,然后赋值给引用,此时还没来得及调用构造方法
  • 其他线程此时判断 INSTANCE 不为 null,则直接返回 INSTANCE,但是此时对象并没有初始化完毕,就被其他线程使用,可能导致空指针异常

synchronized不能禁止重排序吗?

synchronized采用加锁机制保证程序的有序性,被加锁的代码块多个线程只能串性执行,但是其内部是可以发生指令重排序的,因为指令重排序对单线程是不影响的。

7、懒汉式——静态内部类(线程安全)【可用】

public class Singleton {
    
    private Singleton() {}

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

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

}

静态内部类不会随着外部类的加载而加载,只有静态内部类的静态成员被调用时才会进行加载。该实现方式比较简单,而且可以达到懒加载的效果,同时由JVM保证了多线程并发访问的正确性(利用了classloder的机制来保证初始化instance时只有一个线程)。

8、枚举【最好】

public enum Singleton {

    INSTANCE;

}
  • 这种方式是《Effective Java》作者提倡的方式,其认为:使用枚举实现单例的方法虽然还没有广泛采用,但是单元素的枚举类型已经成为实现Singleton的最佳方法
  • 写法最为简单
  • 线程安全有保障:反编译后可以发现跟懒汉式的写法没什么区别
  • 避免反序列化破坏单例: 对于上述七种写法,单例会被序列化和反序列化机制打破(单例与序列化的那些事儿
    • oracle文档中指出枚举常量的序列化:1.12 Serialization of Enum Constants image.png
      • 大概意思就是:在序列化的时候只是将枚举对象的name方法的返回值输出到结果中,反序列化的时候则是调用java.lang.Enum.valueOf()方法获取反序列化的常量,所以反序列化得到的对象引用其实就是枚举常量。同时,枚举的序列化过程不能被定制。
      • Enum类中,可以看到readObjectreadObjectNoData方法被禁用
        /**
         * prevent default deserialization
         */
        private void readObject(ObjectInputStream in) throws IOException,
            ClassNotFoundException {
            throw new InvalidObjectException("can't deserialize enum");
        }
        
        private void readObjectNoData() throws ObjectStreamException {
            throw new InvalidObjectException("can't deserialize enum");
        }
        
  • 避免反射破坏单例
    • 普通的单例模式,反射是可以绕过私有的构造方法,创建多个实例的:
      public static void main(String[] args) throws Exception {
          Singleton s1 = Singleton.getInstance();
      
          Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
          constructor.setAccessible(true);
          Singleton s2 = constructor.newInstance();
          
          // 输出false,表明两个对象是不同的
          System.out.println(s1 == s2);
      }
      
    • 我们点进去constructor.newInstance()方法中,可以看到当类为枚举类型时,调用此方法会抛异常:
      @CallerSensitive
      public T newInstance(Object ... initargs)
          throws InstantiationException, IllegalAccessException,
                 IllegalArgumentException, InvocationTargetException {
          if (!override) {
              if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
                  Class<?> caller = Reflection.getCallerClass();
                  checkAccess(caller, clazz, null, modifiers);
              }
          }
          if ((clazz.getModifiers() & Modifier.ENUM) != 0)
              throw new IllegalArgumentException("Cannot reflectively create enum objects");
          ConstructorAccessor ca = constructorAccessor;   // read volatile
          if (ca == null) {
              ca = acquireConstructorAccessor();
          }
          @SuppressWarnings("unchecked")
          T inst = (T) ca.newInstance(initargs);
          return inst;
      }
      

枚举属于饿汉式还是属于懒汉式?

关于这个问题,网上铺天盖地认为它属于懒汉式。但其实它是属于 饿汉式 的。

当时在写这篇文章的时候,原本我也差点被这些错误的信息误导。当我打算通过反编译枚举类,来举证这个观点的时候,我发现枚举类会被编译成final class,同时继承枚举父类。它的各个实例都是通过static定义的,在static代码块中完成初始化操作(这么看来,其实就跟我们饿汉式的实现是完全没区别的),所以我很好奇为什么网上这么多文章认为它是懒汉式的。

以下是反编译得到的代码:

   public final class Singleton extends java.lang.Enum<Singleton> {
       public static final Singleton INSTANCE;
       public static Singleton[] values();
       public static Singleton valueOf(java.lang.String);
       static {};
   }

所以以后不要再说枚举是懒汉式的了。

针对这个问题,我还专门去查询了大量的文章,最终让我找到了这篇讨论,里面涉及的一些评论正好解答了我的疑惑:Singleton via enum way is lazy initialized?

image.png

懒汉式真的比饿汉式好吗?

在大众一贯的观点,单例模式中懒汉式是优于饿汉式的,但事实真的是这样吗?饿汉式的对象实例会在类加载的时候就进行初始化,而懒汉式在真正使用对象实例的时候才初始化,但这两种形式的区别很大吗?

首先,我个人认为懒汉式跟饿汉式的区别并没有很大,但是懒汉式却引来了更高的代码复杂度。下面我会根据JVM类的初始化机制,来举证我的观点。

JVM类的初始化机制

参考:www.cnblogs.com/wpjamer/art…

什么是类的加载

类的加载就是指将 .class 文件中的字节码读入内存,将其放在运行时数据区的方法区(method code)内,最终在堆区(heap)中创建一个 java.lang.Class 对象。

何时进行类加载

一般来说,只有在 第一次主动调用 某个类时才会去进行类加载。如果一个类有父类,会先去加载其父类,然后再加载其自身。(也就是说,每个类有且只有一次初始化的操作)

那么什么是 主动调用 呢?JVM 规定了以下六种情况为主动调用:

  1. 一个类的实例被创建(new操作、反射、cloning,反序列化)
  2. 调用类的static方法
  3. 使用或对类/接口的static属性进行赋值时(这不包括final的与在编译期确定的常量表达式)
  4. 当调用 API 中的某些反射方法时
  5. 子类被初始化
  6. 被设定为 JVM 启动时的启动类(具有main方法的类)

为什么我会认为懒汉式跟饿汉式的区别并没有很大呢?

  • 首先我们从大的方面来看,JVM是在 第一次主动调用 某个类时才会去进行类加载。这里我们其实可以看出,JVM的类加载机制已经在某种程度上实现了类的懒加载
  • 那我再来分析下JVM的六种主动调用对于单例模式的影响:
    1. 类实例被创建: 对于new操作,由于单例模式是对构造方法进行私有化,所以我们都是通过调用类似getInstance()方法的形式暴露实例引用,所以饿汉式和懒汉式是没什么区别的。对于反射和反序列化,其实一般我们程序开发的时候并不会很关注这种情况。
    2. 调用类的static方法: 这种应该就是最有可能触发饿汉式缺点的了。但是我们仔细想想,我们设置static方法是为了什么?static修饰的方法作为类方法,由所有对象共享。但是我们现在写的就是单例模式,就只会有一个实例对象,那我们为啥还要写static方法呢?(getInstance方法除外,因为它是作为我们访问单例对象的全局访问点)所以这个角度看来,我们在单例类中,也不是很需要其他static方法。
    3. 使用或对类/接口的static属性进行赋值: 这点跟第二点类似,就不多赘述了。
    4. 当调用 API 中的某些反射方法时: 这种确实会导致类加载,导致饿汉式的单例实例被创建。但是反射的情况我们比较少去关注。
    5. 子类被初始化: 这种在单例模式下不会出现,因为单例类的构造方法是私有的,不能被继承。
    6. 被设定为 JVM 启动时的启动类: 这种情况,我想在座各位在生产中都不会将服务启动的main方法写在单例类中吧?有的话当我没说。

总结: 这么分析下来的话,不知道大家会不会跟我有同样的感觉,其实懒汉式和饿汉式的区别并没有很大。更何况被认为最好的单例写法(枚举)也是饿汉式的实现。

写在最后

以上就是本文的所有内容,如果文章帮助到你,希望大家动动手指 点赞关注 支持下~