Loading

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

详解单例模式(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.

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

  1. 有且只有一个实例对象;
  2. 提供一个全局的访问接口。

要满足这两个需求,有一个经典的解法。我们的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的值
public static Kettle getInstance() { null
public static Kettle getInstance() { null
if (instance == null) { null
if (instance == null) { null
instance = new Kettle(); Kettle1
return instance; Kettle1
instance = new Kettle(); Kettle2
return instance; Kettle2

加synchronized锁(性能有浪费)

这引入了一个直观的解法,我直接在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的值
①划分一块内存空间给Kettle实例对象。成员变量的值被初始化为默认值,也就是这里的emptyboiled都被赋值为false null
instance指向这块空间 kettle object(default)
a if (instance == null) { kettle object(default)
return instance; kettle object(default)
用户使用了返回的尚未完全初始化、只有默认值的instance,BOOM!
②初始化Kettle实例到这块内存空间上。也就是运行了构造函数里的语句,emptyboiled被正确地赋值为truefalse kettle object

反射

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

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

打开生成的文件:

// Decompiled by Jad v1.5.8g. Copyright 2001 Pavel Kouznetsov.// Jad home page: http://www.kpdus.com/jad.html// Decompiler options: packimports(3) // Source File Name:   Kettle.javaimport java.io.PrintStream;public final class Kettle extends Enum{    public static Kettle[] values()    {        return (Kettle[])$VALUES.clone();    }    public static Kettle valueOf(String s)    {        return (Kettle)Enum.valueOf(Kettle, s);    }    private Kettle(String s, int i)    {        super(s, i);        empty = true;        boiled = false;    }        /* 其它方法 */    public static final Kettle INSTANCE;    private boolean empty;    private boolean boiled;    private static final Kettle $VALUES[];    static     {        INSTANCE = new Kettle("INSTANCE", 0);        $VALUES = (new Kettle[] {            INSTANCE        });    }}

看静态块里的这句,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;}

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

posted @ 2022-03-21 22:16  vvwantspeed  阅读(222)  评论(0编辑  收藏  举报