单例背后的技术细节


title: 单例背后的技术细节
date: 2017-12-08 21:40:46
tags:

  • 设计模式
  • Java
    categories: 设计模式

单例是最简单的设计模式,这基本上是大家的共识。然而单例也是最经常被问及的基础面试题,各个语言下单例的实现通常都是以私有构造方法的方式实现,一个优雅的单例往往涉及线程,序列化等内容。 这里以 Java 实现单例,并总结一下各种单例的实现方式的特点。

单例的各种写法

饿汉单例

public class HungrySingleton {
    private static HungrySingleton instance = new HungrySingleton();
  
    private HungrySingleton(){}
  
    public static HungrySingleton getInstance() {
        return instance;
    }
}
  • 优点:简单、线程安全
  • 缺点:无法延迟创建,无法防御反射、序列化重复创建

简单懒汉单例

public class LazySingletonSimple {
    private static LazySingletonSimple instance;

    private LazySingletonSimple(){}

    public static LazySingletonSimple getInstance() {
        if (instance == null) {
            instance = new LazySingletonSimple();
        }
        return instance;
    }
}
  • 优点:简单,延迟创建
  • 缺点:线程不安全,无法防御反射、序列化重复创建

内部类懒汉单例

public class LazySingletonStatic {
    private static class SingletonHolder{
        private static final LazySingletonStatic INSTANCE = new LazySingletonStatic();
    }
//    可以使用静态初始化块的方式实现饿汉单例
//    private LazySingletonSimple instance;
//    static {
//        instance = new LazySingletonSimple();
//    }

    private LazySingletonStatic(){}

    public static LazySingletonStatic getInstance(){
        return SingletonHolder.INSTANCE;
    }
}
  • 优点:延迟创建,线程安全
  • 缺点:较复杂,无法防御反射、序列化重复创建

加锁懒汉单例

public class LazySingletonSafe {
    private static LazySingletonSafe instance;
    private LazySingletonSafe(){}

    public static synchronized LazySingletonSafe getInstance(){
        if (instance == null){
            instance = new LazySingletonSafe();
        }
        return instance;
    }
}
  • 优点:延迟创建,线程安全
  • 缺点:性能差,无法防御反射、序列化重复创建

双锁懒汉单例

public class DoubleCheckLockSingleton {
    // 指令重排可能影响执行指令的执行顺序
    // volatile 声明的变量会禁止指令重排
    // 只在 JDK5 之后生效
    private volatile static DoubleCheckLockSingleton instance;
    private DoubleCheckLockSingleton(){}

    public static DoubleCheckLockSingleton getInstance(){
        if(instance == null){
            synchronized (DoubleCheckLockSingleton.class){
                if(instance == null){
                    instance = new DoubleCheckLockSingleton();
                }
            }
        }
        return instance;
    }
}
  • 优点:延迟创建,线程安全
  • 缺点:无法防御反射、序列化重复创建

改进加锁懒汉单例

public class DefReflectAndSerialSingleton implements Serializable {
    private static DefReflectAndSerialSingleton instance;

    /**
     * 防止反射创建多个单例
     */
    private DefReflectAndSerialSingleton(){
        if (instance != null) {
            throw new RuntimeException();
        }
    }

    /**
     * 防止序列化创建多个单例
     * @return
     * @throws ObjectStreamException
     */
    private Object readResolve() throws ObjectStreamException {
        return instance;
    }

    public static synchronized DefReflectAndSerialSingleton getInstance(){
        if (instance == null){
            instance = new DefReflectAndSerialSingleton();
        }
        return instance;
    }
}
  • 优点:延迟创建,线程安全,可以防御反射、序列化重复创建
  • 缺点:复杂,性能差

枚举单例

public enum EnumSingleton {
    INSTANCE;
    public void functionInEnum(){
        System.out.println("function in enum");
    }
}
  • 优点:延迟创建,线程安全,可以防御反射、序列化重复创建
  • 缺点:用的人太少

单例背后的技术细节

饿汉单例 & 简单懒汉单例

最简单的写法,直接私有化构造器,区别在初始化单例的时机。这是初学者最容易掌握的单例写法,并没有什么太多值得说的。在一般的场景下使用也并没有太大问题,但如果考虑线程安全,序列化,反射场景时,这两种写法都不可取。

内部类懒汉单例

使用内部类机制形成懒加载,其过程涉及到了类加载机制

类加载

类加载、使用的完整过程:

加载 -> 验证 -> 准备 -> 解析 -> 初始化 -> 使用 -> 卸载

对内部类懒汉单例来说,初始化阶段影响了单例对象实例化的时间,这里具体看一下初始化阶段

初始化

初始化阶段有两个东西比较重要:

  1. 初始化条件
  2. 初始化过程
初始化条件

类初始化的条件在 JVM 规范中有明确要求:

  1. 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类还没有进行过初始化,则需要先触发其初始化
  2. 使用 Java.lang.refect 包的方法对类进行反射调用时,如果类还没有进行过初始化,则需要先触发其初始化
  3. 当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化
  4. 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先执行该主类
  5. 当使用jdk1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic , REF_putStatic , REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化

在上面代码中的场景,只有 SingletonHolder.INSTANCE; 代码执行时才会初始化 SingletonHolder 类

初始化过程

初始化阶段真正开始执行代码中定义的 Java 程序代码,初始化阶段会执行类构造器 () 方法。

() 方法是 Java 编译器生成的,内容是收集代码中所有类变量的赋值语句和静态语句块中的语句合并而成,顺序由源文件中出现顺序决定。

在上面的场景中,只有类变量赋值的语句:private static final LazySingletonStatic INSTANCE = new LazySingletonStatic();

线程安全

单线程环境下初始化大致的过程清楚后,还需要说明一下 Java 中初始化一个类或接口是是加锁的同步操作,而这个操作是自动,可以确保当多个线程同时加载一个类时的线程安全。

懒汉 or 饿汉

内部类懒汉单例中通过调用实例时调用内部类的静态属性达到延迟初始化目的。如果使用静态代码块的方式则可以在类初始化阶段完成实例变量的初始化实现饿汉单例,如上面的注释代码所示。

但需要注意生成的 () 方法内代码的顺序,一个错误的初始化:

public class Single {

    private static final Single single = new Single();

    private static Map cache;

    static {
        cache = new HashMap<>();
        cache.put("0", "0");
    }

    private Single() {
        if (null == cache) {
            cache = new HashMap<>();
        }
        cache.put("1", "1");
        cache.put("2", "2");
    }

    public static Single getSingle() {
        return single;
    }
}

根据 () 对语句的收集,会先执行 new Single(),然后执行静态代码块,所以 Single() 构造的内容将丢失。

加锁懒汉单例

前面的单例都是线程不安全的。以下的操作不满足原子性,当多个线程访问时会出现竞争:

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

要避免这种竞争的产生最简单的方式是使用 Java 提供的内置锁(synchronized)机制来保护同步代码块。当一个线程进入加锁的同步代码块之前会获得锁,直到离开同步代码块时释放锁,当一个线程持有锁时会,其他线程将无法获得锁而被阻塞。

内置锁可以完全避免竞争的产生,但同时产生了严重的性能问题,所有线程访问 getInstance 方法都必须是顺序进行的,而getInstance 的核心逻辑是对第一次访问生效的,其余每次都判断是没有意义的。

双锁懒汉单例

双锁懒汉单例也是常见的一种单例写法,其主要改进是确保线程安全的同时避免了每次调用 getInstance 方法时都要加锁,锁操作在大多数场景下都是不必要的,竞争只发生在初始情况下(instance 为 null)时,而之后大部分时间的操作加锁就显得没意义了。双锁可以很好的区分这两种场景:

  • 初始情况下(instance 为 null),多个线程同时访问都满足 instance == null,但进一步会在加锁的位置阻塞,以顺序执行
  • 单例实例产生后,后面的线程再访问时将不满足 instance == null 的条件,直接返回实例,从而避免加锁的开销

这种做法确实很好的做到加锁同时兼顾了性能,但最大的问题在于人们常常忘记 volatile 关键字,因为原子性和指令重排造成多线程下不安全的操作。

原子操作

前面已经提到 if 代码块不满足原子性,这里看更局部的地方:instance = new DoubleCheckLockSingleton();

尽管只是一条语句但它也不是原子操作,在执行过程中会以三条指令执行,伪代码:

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

指令重排

非原子的操作本身并不影响程序的执行,我们甚至感受不到这是一个非原子的操作。但为了执行效率,指令可以发生重新排序,而指令重排让程序的执行充满了不确定性。上面的三条指令有可能会以下顺序执行:

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

同样我们对此毫无感受,在单线程下程序的正确性也没有收到影响。然而多线程环境下问题将变得很诡异,比如下面的场景:

时间 线程A 线程B
t1 A1:分配对象的内存空间
t2 A3:设置 instance 执行内存空间
t3 B1:判断 instance 是否 null
t4 B2:instance 不为 null,访问 instance 引用对象
t5 A2:初始化对象
t6 A4:访问 instance 引用对象

在 t3, t4 时刻,线程B 居然访问到了一个没有完全初始化的对象。

volatile

将实例变量用 volatile 修饰后就可以解决这个问题。在 volatile 背后,JVM 实际做了两件事:

  1. 每次读取/写入变量都访问线程共享的存储数据,而不是线程私有区域缓存的数据
  2. 禁止指令重排优化

这样上面演示的可能的重排操作将被避免,在多线程的环境下线程也将不会访问到未完全初始化的对象

改进加锁懒汉单例

单例属于创建型的设计模式,所以先看看 Java 中创建对象的几种方式:

  1. 使用 new 关键字,最常见的方式
  2. 反射创建,更具体有两种方式:Class.newInstance 和 Contructor,newInstance
  3. clone 对象,需要实现 Cloneable 接口,并实现 clone 方法
  4. 反序列化:需要实现 Serializable 接口

对于每种创建方式的禁用方式也很简单:

  1. 私有构造禁止外部创建
  2. 构造方法添加判断逻辑,如果已将判断抛出异常
  3. 直接在 clone 方法抛出异常
  4. 构造方法添加判断逻辑,定制 readResolve 方法

枚举

枚举这个东西一直用起来,怪怪的。《Effective Java》里建议的单例实现方式也是使用枚举,但基本没见过有人这么用。具体的使用没什么太多好说的,这里总结一下枚举的相关特性吧。

首先枚举是 Java 的句法糖,枚举最终会被转换为普通类。例如下面定义的枚举:

public enum Color  {  
  RED,BLUE,BLACK,YELLOW,GREEN  // 最后没有分号,也没有逗号
}   

会被转换成:

public final class Color extends  java.lang.Enum{   
  public static final Color RED;   
  public static final Color BLUE;   
  public static final Color BLACK;   
  public static final Color YELLOW;   
  public static final Color GREEN;   
  static {};   
  public static Color[] values();   
  public static Color valueOf(java.lang.String);   
} 

可以看到枚举继承了 Enum,因此枚举获得了如下方法:

  • ordinal:返回枚举值在枚举类种的顺序

    Color.RED.ordinal(); // 返回结果:0

  • name:枚举类型名称

  • compareTo:比较象与指定对象的顺序

    Color.RED.compareTo(Color.BLUE); // 返回结果 -1

  • values:静态方法,获得全部枚举值的数组

    Color[] colors=Color.values();
    for(Color c:colors){
      System.out.print(c+","); 
    }
    //返回结果:RED,BLUE,BLACK YELLOW,GREEN,
    
  • toString:获得枚举常量的名称

    Color c=Color.RED;
    System.out.println(c);//返回结果: RED
    
  • valueOf:静态方法,获得带指定名称的指定枚举类型的枚举常量

    Color.valueOf("BLUE");  // 返回结果: Color.BLUE
    
  • equals:比较两个枚举对象的引用

你可能感兴趣的:(单例背后的技术细节)