目录

image-20201005121215846

问题思考

[!blue]

  • 说一说常用的设计模式,如何实现一个线程安全的单例模式。
  • 单例模式有哪几种实现方式,饿汉式单例模式和懒汉式单例模式的区别以及代码实现。
  • 双重校验单例模式为什么需要加两层校验,为什么要使用 volatile 关键字,不使用 volatile 关键字会导致线程不安全吗。
  • 使用了 synchronized 为什么还需要使用 volatile,使用 synchronized 的作用是什么。

单例模式

[!blue]
定义:保证一个类仅有一个实例,并提供一个访问它的全局访问点。

特点:

  1. 单例模式只能有一个实例。
  2. 单例类必须创建自己的唯一实例。
  3. 单例类必须向其他对象提供这一实例。

单例模式的分类

[!blue]
饿汉式单例模式:立即加载,类一加载就创建实例,天然线程安全,但不能延迟加载。

懒汉式单例模式:延迟加载,第一次使用时才创建实例,必须自己解决多线程下的并发创建问题。

面试主线:从线程不安全到三种推荐写法

最朴素的懒汉式是这样写的:if (instance == null) { instance = new Singleton(); }它为什么线程不安全?因为「判空」和「创建」不是一个原子操作:线程 A 和线程 B 可能同时通过判空(都看到 instance == null),然后各自创建一个实例——单例被破坏。这就是所有线程安全写法要解决的第一个问题。

各写法的取舍:同步方法 / 同步代码块直接给整个「判空 + 创建」加锁,正确但锁粒度太大,实例创建完成后每次读取仍要抢锁,性能差;饿汉式(静态常量 / 静态代码块)依靠 JVM 类初始化的加锁机制天然线程安全、零同步开销,但不能延迟加载;双重校验在锁外再加一层判空,兼顾正确性与性能,代价是必须配合 volatile 且写法最复杂。

最终演进出三种推荐写法:双重校验(面试必考、可懒加载)、静态内部类(懒加载 + 零同步开销 + 写法简单)、枚举(《Effective Java》推荐,唯一天然防反射与反序列化的写法)。

如何实现一个线程安全的单例模式

《Effective Java》第 3 条:用私有构造器或者枚举类型强化 Singleton 属性。

一个严谨的单例模式除了保证线程安全外,还需要防止反射反序列化clone 破坏单例。下面是加固后的双重校验完整实现。

import java.io.Serializable;

public class Singleton implements Serializable, Cloneable {
    // volatile 禁止指令重排序,并保证写入对其他线程可见
    private static volatile Singleton instance = null;

    // 私有化构造方法,并防止反射调用
    private Singleton() {
        if (instance != null) {
            throw new RuntimeException("单例模式不允许重复创建对象");
        }
    }

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

    // 防止 clone 破坏单例
    @Override
    protected Object clone() throws CloneNotSupportedException {
        return instance;
    }

    // 防止反序列化破坏单例
    private Object readResolve() {
        return instance;
    }
}

[!red]

  1. 防反序列化:在类中添加 readResolve() 方法并返回已有实例,反序列化时会用该方法的返回值替换新创建的对象。
  2. 防 clone:实现 Cloneable 接口并覆盖 Objectclone() 方法,直接返回已有实例,不产生副本。
  3. 防反射:在构造方法中判断 instance != null 就抛异常,让反射在第二次创建实例时失败。注意这只能防「先正常创建、再反射创建」的场景,若反射先执行则防不住——彻底的方案只有枚举。

单例模式的七种实现方式(线程安全)

[!red]
以下七种都是线程安全的实现,按面试主线组织:先看最重要的双重校验(一),再看它的推荐替代静态内部类(二),然后回头看它由之演进而来的粗粒度锁写法(三、四),接着是不能延迟加载的两种饿汉式(五、六),最后是最推荐的枚举(七)。

一、懒汉式-双重校验单例模式

image-20201114235437907

上图即双重校验(Double-Checked Locking)的标准实现,文字版代码如下:两层 if (instance == null) 夹着一个 synchronized 块,实例字段必须用 volatile 修饰。

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;
    }
}

为什么要校验两次?第一次判空在锁外,为了性能:实例创建完成后,后续所有调用直接返回,不必再抢锁;第二次判空在锁内,为了正确性:多个线程可能同时通过第一次判空并排队进入同步块,第一个线程创建完实例释放锁后,若后续线程不再检查一次,就会重复创建实例。synchronized 保证了创建过程的互斥与原子性。

为什么还要 volatile?(必考)instance = new Singleton() 不是原子操作,字节码层面分三步:① 分配内存;② 调用构造方法初始化对象;③ 把 instance 引用指向这块内存。②③ 之间没有数据依赖,编译器 / CPU 可能重排序为 ① → ③ → ②。若线程 A 刚执行完 ①③(引用已非空、对象尚未初始化),线程 B 恰好在锁外做第一次判空,会看到 instance != null 而直接返回这个半初始化对象,使用其字段时就会读到默认值甚至出错。volatile 写之前的操作不能被重排到写之后,即禁止 ②③ 重排,同时保证写入对其他线程立即可见。

有了 synchronized 为什么还不够?synchronized 只保证临界区内的互斥,以及进出临界区时的可见性;而出问题的第一次判空是锁外读,完全不受 synchronized 保护,所以必须由 volatile 兜底。两者分工明确:synchronized 管「只创建一次」,volatile 管「锁外读不到半成品」,缺一不可。

二、懒汉式-静态内部类单例模式

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

    private Singleton() {
    }

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

为什么行:外部类 Singleton 被加载时并不会初始化 SingletonHolder,只有第一次调用 getInstance() 触发内部类初始化时才创建实例——这就是延迟加载;而类的初始化由 JVM 在内部加锁、保证只执行一次,天然线程安全。既有懒汉式的延迟加载,又没有任何同步开销,也不需要 volatile,写法比双重校验简单得多,是推荐的实现方式之一。

三、懒汉式-同步代码块单例模式

public class Singleton {
    private static Singleton instance = null;

    private Singleton() {
    }

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

为什么不推荐:线程安全没问题——判空和创建整个被锁住;但无论实例是否已经创建,每次调用 getInstance() 都要先获取锁,锁粒度大、并发性能差。在锁外再加一层判空,就演进成了双重校验。

四、懒汉式-同步方法单例模式

public class Singleton {
    private static Singleton instance = null;

    private Singleton() {
    }

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

为什么不推荐:与同步代码块等价——synchronized 加在静态方法上,锁的同样是 Singleton.class。写法最简单,但同样每次调用都要加锁,性能与写法三相同,不推荐。

五、饿汉式-静态代码块单例模式

public class Singleton {
    private static Singleton instance = null;

    static {
        instance = new Singleton();
    }

    private Singleton() {
    }

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

为什么行 / 局限:类的初始化阶段由 JVM 加锁执行且只执行一次,静态代码块里的赋值天然线程安全,还可以在代码块中写额外的初始化逻辑。缺点是类一加载就创建实例,不能延迟加载——如果实例很重且可能整个生命周期都用不到,就白白占用资源。

六、饿汉式-静态常量单例模式

public class Singleton {
    private static final Singleton SINGLETON = new Singleton();

    private Singleton() {
    }

    public static Singleton getSingleton() {
        return SINGLETON;
    }
}

为什么行 / 局限:与静态代码块写法等价,是最简单直接的线程安全实现,同样不能延迟加载。另外注意:前六种写法如果不做额外防护,都可能被反射反序列化破坏单例,加固方式见上文完整实现。

七、枚举类单例模式

public enum Singleton {
    INSTANCE
}

class Test {
    public static void main(String[] args) {
        Singleton instance1 = Singleton.INSTANCE;
        Singleton instance2 = Singleton.INSTANCE;
        System.out.println(instance1 == instance2);
    }
}

:::fold 运行结果

[!green]
true

:::

为什么最推荐:枚举是《Effective Java》推荐的最佳单例写法,它绝对防止多次实例化,原因有三:

  1. 线程安全:枚举实例由 JVM 在类初始化时创建,和饿汉式一样天然线程安全,无需任何同步代码。
  2. 防反射Constructor.newInstance() 检测到枚举类型会直接抛出 IllegalArgumentException,从 JDK 层面禁止反射创建枚举实例——这是前六种写法靠构造方法判断都做不到的「绝对防护」。
  3. 防反序列化:枚举的序列化只写出实例名字,反序列化时通过 valueOf() 返回已有实例,无需自己实现 readResolve()

虽然这种方法还没有广泛采用,但是单元素的枚举类型经常成为实现 Singleton 的最佳方法

注意:枚举已隐式继承 java.lang.Enum,如果 Singleton 必须扩展一个其他超类则不宜使用这个方法(虽然可以声明枚举去实现接口);另外枚举本质是饿汉式,不能延迟加载。

单例模式在 JDK 中的应用

[!blue]

  • java.lang.Runtime#getRuntime()
  • java.awt.Desktop#getDesktop()
  • java.lang.System#getSecurityManager()