相关文章推荐
严肃的豌豆  ·  TLS1.2 ...·  1 月前    · 

详解单例模式(Singleton Pattern):以烧水壶为例,Java实现

本文极大地参考了以下书籍文章的相关内容:

  • Head First Design Patterns : Building Extensible and Maintainable Object-Oriented Software》2021年第二版。写得极好,基本梳理了入门单例模式你所需要知道的一切,烧水壶这个例子也是取自该书第五章的巧克力例子。详解了经典实现、预加载、加锁、双重锁和枚举类实现,提到了反射和序列化会破坏,但没有具体写会怎么破坏。
  • wikipedia DCL
  • The "Double-Checked Locking is Broken" Declaration
  • wikipedia Initialization-on-demand holder idiom
  • 《Effective Java》2018年第三版。条目三。
  • 设计模式是理论,不同的编程语言因语法特性不同有不同的实现,本文用Java来实现。

    场景引入:烧水壶

    我们假设房间里有一个烧水壶,抽象出烧水壶的类,用Java语言描述如下:

    public class Kettle {
        private boolean empty;
        private boolean boiled;
        public Kettle() {
            empty = true;
            boiled = false;
         * 壶里没水,倒入冷水
        public void fill() {
            if (isEmpty()) {
                empty = false;
                boiled = false;
         * 壶里有水且水没在烧时,烧水
        public void boil() {
            if (!isEmpty() && !isBoiled()) {
                boiled = true;
         * 壶里有水且水烧好了,把水倒出来
        public void drain() {
            if (!isEmpty() && isBoiled()) {
                empty = true;
        public boolean isEmpty() {
            return empty;
        public boolean isBoiled() {
            return boiled;
    

    甲和乙都想用这个壶烧水,但是壶只有一个。在程序里模拟,甲和乙可以当作是不同的线程,要拿壶烧水就是要先拿到这个唯一的实例,再去烧水。

    很显然,在我们这个类里,没有做任何保证实例唯一的实现。任何其它类都可以new出一个新的kettle实例来。

    要怎么保证在任何情况下都只会有一个kettle实例呢?这就是单例模式所做的事情。

    我们先来看一下它的定义,再看怎么去实现。

    《Design Patterns》(《Head First Design Patterns》沿用此定义)中对单例模式的定义如下:

    The Singleton Pattern ensures a class has only one instance, and provides a global point of access to it.

    也就是说,单例模式的实现需要满足两个要求:

  • 有且只有一个实例对象;
  • 提供一个全局的访问接口。
  • 要满足这两个需求,有一个经典的解法。我们的Kettle类对外提供一个公共接口(global access point),保证使用这个接口的用户能拿到这个唯一的实例对象(only one instance),并且再没有别的方法可以创建Kettle实例了。

    公共接口(这里的接口指API而不是Java的Interface)的方法签名如下,getInstance是一个在单例模式里约定俗成的方法名:

    public static Kettle getInstance()
    

    再加一个私有的静态成员变量:

    private static Kettle instance;
    

    要保证没有别的方法可以创建实例,可以将Kettle的构造函数设为私有,使其只能在类内调用:

    private Kettle() {
        /* 初始化成员变量 */
    

    私有化构造函数也是,比如JDK里的Math类:

    public final class Math {
         * Don't let anyone instantiate this class.
        private Math() {}
    

    这就是实现单例模式的基础。

    根据静态成员变量instance是在第一次运行getInstance()时赋值还是在类加载时赋值,可以将单例模式的实现大致分类两类,懒加载(懒汉式)和预加载(饿汉式)。

    懒加载,是在程序需要时才实例化对象,不会占用不必要的空间。

    预加载,是在类加载时实例化对象,有可能一直到程序结束都不会用到这个对象,白白地浪费了内存空间;在程序刚运行时就实例化好了,之后要用的时候就可以直接调用,在这里节省了时间。

    双重检测锁模式(DCL)是典型的懒加载实现,枚举类(Enum)是典型的预加载实现。不同的实现适用于不同的使用场景。

    应用场景:

  • 数据库连接池
  • Spring的单例Bean
  • 在以下模式中,多数情况下只会生成一个实例:

  • Abstract Factory
  • Builder
  • Facade
  • Prototype
  • 懒加载的实现

    懒加载(lazy initialization/loaded)的四种实现中,经典解法在多线程下无法保证唯一实例;加synchronized锁做到了线程安全,满足了单例模式的要求,但在性能上有不必要的浪费;双重检测锁模式满足了单例模式的要求,性能上比加synchronized锁有优化,是较好的实现;JVM确保了静态内部类的线程安全。

    经典解法(线程不安全)

    public class Kettle {
        private static Kettle instance;
        private Kettle() {
            /* 初始化成员变量 */
        public static Kettle getInstance() {
            if (instance == null) {
                instance = new Kettle;
            return instance;
        /* 成员变量和方法 */
    

    经典解法在每次调用getInstance()方法时校验一下instance变量是否为空,为空则先给它赋值,然后向用户返回变量。

    在多线程情况下很有可能拿到不同的实例。

    虽然线程开小了不太能看到这种错误。这里开了一百万个线程,跑了几分钟,打印出来一看,set里还是只有一个对象:

    Set<Kettle01> set = new HashSet<>();
    for (int i = 0; i < 100_0000; i++) {
        new Thread(() -> {
            set.add(Kettle.getInstance());
        }).start();
    System.out.println(set.size());
    

    可以来脑内模拟一下两个线程交替执行的特殊情况,可以看到,instance变量先后被赋予了不同的值,显然违背了单例模式的要求:

    线程1(甲) 线程2(乙) instance的值

    这引入了一个直观的解法,我直接在getInstance()方法上加把锁不久好了?

    public class Kettle {
        private static Kettle instance;
        private Kettle() {
            /* 初始化成员变量 */
        // 在静态方法上加锁
        public static synchronized Kettle getInstance() {
            if (instance == null) {
                instance = new Kettle();
            return instance;
        /* 成员变量和方法 */
    

    加锁很好地解决了多线程下获取不同实例对象的问题,但是又引入了性能的浪费。

    试想一下,在程序运行的初期,我们称其为阶段一时期,instance还是空值,这时有两个线程想要来获取Kettle的唯一实例,调用了getInstance()方法,因为有加锁,先拿到锁的线程完成了实例的赋值,后来的线程拿到锁时instance已经不为空了,直接返回instance,这符合我们对程序的要求。

    在此之后,我们称其为阶段二时期,instance已经有值了,再有多个线程来调用getInstance()方法时,依旧要挨个拿到锁来进入方法体,但实际上,可以直接返回instance变量,不再需要排队以防初始化变量出错,因为已经给变量赋过值了,这时的加锁就是不必要的了。

    由此引入双重检测锁模式。

    双重检测锁模式(DLC)(perfect!)

    双重检测锁模式就是只在变量需要初始化的时候再加锁。双重检测的意思就是两次判断变量是否为空,一次在加锁前,一次在加锁后。如果第一次检测的时候,发现变量为空,说明是在程序运行的初期,也就是阶段一时期,需要加锁来保证变量正确地初始化;如果发现变量不为空,说明这是在阶段二时期,不需要加锁了。

    public class Kettle {
        // volatile 禁止指令重排
        private static volatile Kettle instance;
        private boolean empty;
        private boolean boiled;
        public Kettle() {
            empty = true;
            boiled = false;
        public static Kettle getInstance() {
            if (instance == null) {				    // a
                // 加锁
                synchronized (Kettle.class) {		// b
                    if (instance == null) {			// c
                        instance = new Kettle();	// d
            return instance;
        /* 成员变量和方法 */
    

    volatile

    注意,这里的instance变量上加了一个volatile关键字,这是用来禁止指令重排序的。

    instance = new Kettle()这条语句在Java里不是原子性的,它会分成三个指令:①在内存里划分一块要放变量的空间、②在这块空间上初始化一个Kettle实例、③变量/指针instance指向这块空间。

    在编译执行时,如果没有特殊声明,这三条语句会被编译器在确保单线程下运行结果最后正确的前提下,更改这三条指令的运行顺序。也就是说,在多线程下,可能发生这种情况:线程1发现instance为null,加锁,执行instance = new Kettle()语句的时候,三条指令被重排序了;先分配了一块内存空间,里头的值被初始化为默认值,然后将instance指向这块空间,instance就不是null了;这时候线程2过来,getInstance()方法发现instance != null,于是返回了尚未完全初始化、成员变量都是默认值的instance,客户用了这个instance,发生错误。

    volatile的含义是内存屏障(memory barrier),确保变量被写入之前的所有其它写操作都发生在这个操作之前,放在这里,就是③instance变量被赋值这个操作发生之前,②初始化Kettle实例到这块内存空间上、初始化成员变量的操作必然已经执行完毕了,也就是禁止了指令重排序,不会再发生上面提到的错误。

    线程1(甲) 线程2(乙) instance的值

    构造函数虽然是私有的,但我们通过反射,很容易就能获取到它:

    Constructor<?> constructor = Kettle.class.getDeclaredConstructor(null);
    constructor.setAccessible(true);
    Object a = constructor.newInstance();
    Object b = constructor.newInstance();
    System.out.println(a == b);
    

    打印出来是false,显然不满足单例模式的要求啦。

    虽然也可以在构造函数里再做一些处理,比如再判断一下变量是不是空、加一个flag来判断变量是否已经初始化之类的,不过也可以通过反射轻松破解:

    Constructor<?> constructor = Kettle.class.getDeclaredConstructor(null);
    constructor.setAccessible(true);
    Object a = constructor.newInstance();
    // 把instance变量再次置空,就可以继续初始化了
    Field field = Kettle.class.getDeclaredField("instance");
    field.setAccessible(true);
    field.set(a, null);
    Object b = constructor.newInstance();
    System.out.println(a == b);
    

    序列化也可以轻松破坏单例模式。readObject会创建新的对象。

    Kettle a = Kettle.INSTANCE;
    FileOutputStream fos = new FileOutputStream("Kettle.obj");
    ObjectOutputStream oos = new ObjectOutputStream(fos);
    oos.writeObject(a);
    oos.flush();
    oos.close();
    FileInputStream fis = new FileInputStream("Kettle.obj");
    ObjectInputStream ois = new ObjectInputStream(fis);
    Kettle b = (Kettle) ois.readObject();
    ois.close();
    System.out.println(a == b);
    

    得到的输出是false

    怎么解决呢?加一个readResolve()方法:

    public class Kettle {
        private static volatile Kettle instance;
        public Kettle() {
            /* 初始化成员变量 */
        public static Kettle getInstance() {
            if (instance == null) {				    
                synchronized (Kettle.class) {		
                    if (instance == null) {			
                        instance = new Kettle();	
            return instance;
        // 解决序列化问题
        private Object readResolve() {
            return instance;
        /* 成员变量和方法 */
    

    再次运行测试程序,得到的结果就是true了。

    查看源码,调用链如下:

    package java.io;
    public class ObjectInputStream
        extends InputStream implements ObjectInput, ObjectStreamConstants {
        public final Object readObject()
            throws IOException, ClassNotFoundException {
            return readObject(Object.class);
        private final Object readObject(Class<?> type)
                throws IOException, ClassNotFoundException {
            Object obj = readObject0(type, false);
            return obj;
    

    readObject0()方法里,如果是object,会调用readOrdinaryObject()方法:

    private Object readObject0(Class<?> type, boolean unshared) throws IOException {    case TC_OBJECT:        ...        return checkResolve(readOrdinaryObject(unshared));}
    

    readOrdinaryObject()方法里,有对是否有readResolve()方法的判定,hasReadResolveMethod()方法:

    /* java.io.ObjectInputStream.java */
    private Object readOrdinaryObject(boolean unshared)
        throws IOException
        if (obj != null &&
            handles.lookupException(passHandle) == null &&
            desc.hasReadResolveMethod())	// 是否有`readSolve()`方法
            Object rep = desc.invokeReadResolve(obj);	// 有的话,直接调用该方法
    

    hasReadResolveMethod()则是在ObjectStreamClass类中,在构造函数里,通过反射获取这个方法:

    package java.io;
    public class ObjectStreamClass implements Serializable {
        boolean hasReadResolveMethod() {
            requireInitialized();
            return (readResolveMethod != null);
        private Method readResolveMethod;
        // 构造函数
        private ObjectStreamClass(final Class<?> cl) {
            if (serializable) {
                // 通过反射获取
                readResolveMethod = getInheritableMethod(
                            cl, "readResolve", null, Object.class);
    

    也就是说,测试程序里的ois.readObject()最后调用了我们在Kettle类里写的readResolve()方法,返回instance变量。

    静态内部类

    静态内部类(Initialization-on-demand holder idiom)的实现是将instance变量转交给静态内部类。Kettle类被加载和初始化时,并不会初始化它的静态内部类LazyHolder。只有在JVM确定LazyHolder要执行的时候,才会初始化它。而只有在getInstance()方法被调用时,LazyHolder类才会被加载并初始化。Java语言特性确保了类的初始化是线程安全的。

    public class Kettle {
        private Kettle(){
            /* 初始化成员变量 */
        private static class LazyHolder {
            static Kettle instance = new Kettle();
        public static Kettle getInstance() {
            return LazyHolder.instance;
        /* 成员变量和方法 */
    

    这里,反射和序列化的情况与DCL一致。

    预加载的实现

    预加载的两种实现都是线程安全的,因为是在类加载时赋值,利用Java语言特性,JVM帮助我们实现了多线程下的安全赋值。

    在类加载时为静态变量赋值,JVM保证了多线程安全。

    public class Kettle {
        private static final Kettle instance = new Kettle();
        private Kettle() {
            /* 初始化成员变量 */
        public static Kettle getInstance() {
            return instance;
        /* 成员变量和方法 */
    

    JDK里有用到这种方法,比如RunTime类:

    package java.lang;
    public class Runtime {
        private static Runtime currentRuntime = new Runtime();
        public static Runtime getRuntime() {
            return currentRuntime;
        /** Don't let anyone else instantiate this class */
        private Runtime() {}
    

    这里,反射和序列化的情况与DCL一致。

    枚举类(enum)

    public enum Kettle {    INSTANCE;        private boolean empty;    private boolean boiled;    Kettle() {        curSize = 0;        empty = true;        boiled = false;    }    /* 成员变量和方法 */}
    

    枚举类是怎么在类加载时就初始化实例的呢?因为enum的实现机制,它的枚举变量都是在枚举类加载时初始化赋值的。可以从反编译的代码中一窥究竟。

    Java自带的反编译工具是javap。自行编译一下, javac Kettle.java -encoding utf-8,得到.class文件。再进行反编译,javap Kettle

    λ javap KettleCompiled from "Kettle.java"public final class Kettle extends java.lang.Enum<Kettle> {  public static final Kettle INSTANCE;  public static Kettle[] values();  public static Kettle valueOf(java.lang.String);  public void fill();  public void boil();  public void drain();  public boolean isEmpty();  public boolean isBoiled();  static {};}
    

    可以看到,变量INSTANCE是类Kettle的静态变量。那它是怎么初始化的呢?这里看不到,我们换个工具。

    搜索Java反编译,看前面的搜索结果,有一个jd-gui-windows-1.6.6的,我们用它打开一下Kettle.class

    public enum Kettle {  INSTANCE;    private boolean boiled;    private boolean empty;    Kettle() {    this.empty = true;    this.boiled = false;  }  	/* 其它方法 */}
    

    跟我们源代码一样,换一个工具jad,虽然老但有用:

    λ jad KettleParsing Kettle... Generating Kettle.jad
    

    打开生成的文件:

    看静态块里的这句,INSTANCE = new Kettle("INSTANCE", 0);INSTANCE确实是在类加载时执行静态代码块来舒适化的,同样是JVM来确保线程安全。

    枚举类能不能用反射来破坏呢?不能。

    写程序测试一下,注意,这里找构造函数的时候,传入参数不能是null了,如果是null的话,会报java.lang.NoSuchMethodException: singleton.enuma.Kettle.<init>()错误。可以先打印一下枚举类的构造函数:

    for (Constructor<?> c : Kettle.class.getDeclaredConstructors()) {    System.out.println(c);}
    

    得到结果private Kettle(java.lang.String,int)

    为什么明明写的是无参构造函数,这里却显示有两个参数呢?这是因为所有的枚举类都继承自java.lang.Enum<E>,这个类的构造函数有两个参数:

    package java.lang;public abstract class Enum<E extends Enum<E>>        implements Comparable<E>, Serializable {    protected Enum(String name, int ordinal) {        this.name = name;        this.ordinal = ordinal;    }}
    

    从上面反编译出来的代码里也可以看到:

    // jad verprivate Kettle(String s, int i){    super(s, i);    empty = true;    boiled = false;}
    

    用这个类型的构造参数来获取:

    Constructor<?> constructor = Kettle.class.getDeclaredConstructor(String.class, int.class);constructor.setAccessible(true);Object a = constructor.newInstance();Object b = constructor.newInstance();System.out.println(a == b);
    

    程序报错为java.lang.IllegalArgumentException: Cannot reflectively create enum objects。看源码Constructor.newInstance()

    package java.lang.reflect;public final class Constructor<T> extends Executable {    @CallerSensitive    public T newInstance(Object ... initargs)        throws InstantiationException, IllegalAccessException,               IllegalArgumentException, InvocationTargetException    {        //...        if ((clazz.getModifiers() & Modifier.ENUM) != 0)            throw new IllegalArgumentException("Cannot reflectively create enum objects");        //...   }}
    

    判断是否是枚举类,是枚举类的话,不允许通过反射来创建枚举对象。也就是同Java语言特性确保了枚举类实现的线程安全一样,确保了它不会被反射特性破坏。

    用同样的测试程序来序列化和反序列化一下枚举类实现:

    Kettle a = Kettle.INSTANCE;FileOutputStream fos = new FileOutputStream("Kettle.obj");ObjectOutputStream oos = new ObjectOutputStream(fos);oos.writeObject(a);oos.flush();oos.close();FileInputStream fis = new FileInputStream("Kettle.obj");ObjectInputStream ois = new ObjectInputStream(fis);Kettle b = (Kettle) ois.readObject();ois.close();System.out.println(a == b);
    

    打印出来的结果是true

    查看源码,调用链如下:

    package java.io;public class ObjectInputStream    extends InputStream implements ObjectInput, ObjectStreamConstants {        public final Object readObject()        throws IOException, ClassNotFoundException {        return readObject(Object.class);    }            private final Object readObject(Class<?> type)            throws IOException, ClassNotFoundException {        ...        Object obj = readObject0(type, false);        ...        return obj;        ...    }}
    

    readObject0()方法里,如果是enum类型:

    /* java.io.ObjectInputStream.java */private Object readObject0(Class<?> type, boolean unshared) throws IOException {	case TC_ENUM:        ...        return checkResolve(readEnum(unshared));}
    

    readEnum(),通过名字直接从enum类里获取枚举变量:

    /* java.io.ObjectInputStream.java */private Enum<?> readEnum(boolean unshared) throws IOException {    Class<?> cl = desc.forClass();    if (cl != null) {        try {            @SuppressWarnings("unchecked")            Enum<?> en = Enum.valueOf((Class)cl, name);            result = en;        }    }    return result;}
    

    所以反序列化回来的对象与原先枚举对象完全一致。