Administrator
发布于 2018-10-14 / 382 阅读
3

线程安全的单例模式该怎么写?双重检查锁与 volatile

面了三家公司,两次被问到"单例为什么要加 volatile"

十月中旬开始投简历试水,面试被问了三次单例模式。第一次我背出了双重检查锁的写法,面试官追问"为什么 instance 要加 volatile",我卡住了,说了句"保证可见性",他摇摇头。第二次还是同一个问题,我还是没答到点上。

回来自己翻了几天资料,把这块彻底搞明白了。这篇就是给当时的自己补的课。

先放一个完整写法

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {                    // 第一次检查,避免每次都加锁
            synchronized (Singleton.class) {
                if (instance == null) {            // 第二次检查,防止重复创建
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

三个要素缺一不可:volatile、两次判空、同步块。逐个说为什么。

为什么需要第二次判空

这个最好理解。假设线程 A 和 B 同时通过了第一次检查,都卡在 synchronized 外面:

A 拿到锁 → instance == null → 创建 → 释放锁
B 拿到锁 → 如果没有第二次判空 → 又创建一个

第二次判空挡掉的就是 B。去掉它,多线程下会 new 出多个实例。

为什么需要第一次判空

如果只留 synchronized 和内层判空,那每次 getInstance() 都要抢锁。synchronized 在 JDK 8 里虽然做了锁升级优化,无竞争时开销很小,但终究比一次 volatile 读要贵。实测 1000 万次调用:

实现方式1000 万次调用耗时
整个方法 synchronize412 ms
双重检查锁28 ms
静态内部类21 ms

第一次判空的作用就是:实例创建好之后,后续所有调用只需读一次 volatile 变量就返回,永远不进同步块。

重点:volatile 到底防的是什么

这才是面试官想听的部分,也是我第一次答错的地方。答案不是可见性,是禁止指令重排序

instance = new Singleton() 这一行,在字节码层面被拆成好几步。我用 javap -c 看了一下:

$ javap -c Singleton.class
   ...
   17: new           #3                  // 1. 分配内存空间
   20: dup
   21: invokespecial #4                  // 2. 调用构造器,初始化对象
   24: putstatic     #2                  // 3. 把引用赋值给静态字段 instance

关键在第 2 步和第 3 步。它们之间没有数据依赖,instance 指向哪块内存,跟这块内存里的内容初始化到什么程度无关。所以 JIT 编译器和 CPU 都可能把顺序重排成 1 → 3 → 2。

重排之后会发生什么:

时刻 T1:线程 A 执行到 1、3,instance 已经非 null,但对象还没初始化
时刻 T2:线程 B 调用 getInstance(),第一次判空 instance != null,直接 return
时刻 T3:线程 B 拿到这个"半成品",调用它的方法 → 空指针 / 读到零值

这个概率极低,但一旦出现就极难排查,因为它依赖具体的时序。我在本地写了个模拟(用 Unsafe 强制在分配和初始化之间插入延迟,再用多线程跑),跑了 80 万次触发了 3 次读到了未初始化字段的情况。

volatile 是怎么挡住重排的

JVM 在 volatile 写操作前后插入内存屏障。JSR-133 定义的规则是:

  • volatile 写之前的操作,不能被重排到 volatile 写之后(StoreStore 屏障);
  • volatile 写之后的操作,不能被重排到 volatile 写之前(StoreLoad 屏障);
  • volatile 读之后的操作,不能跑到 volatile 读之前(LoadLoad + LoadStore 屏障)。

具体到 instance = new Singleton()instance 是 volatile 的,所以它的写操作前面有 StoreStore 屏障,第 2 步(初始化)不能越过屏障排到第 3 步(赋值)后面。顺序被锁死成 1 → 2 → 3。

在 x86 平台上,这些屏障大部分是空操作(x86 是强内存模型,只有 StoreLoad 需要真正的 mfence 指令),所以 volatile 的读在 x86 上几乎零成本。我上面测出双重检查锁 1000 万次 28ms,就是因为这个。

更好的写法:静态内部类

搞明白 DCL 之后,师傅跟我说"你以后别写 DCL 了,用这个":

public class Singleton {
    private Singleton() {}

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

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

它依赖 JVM 的类初始化机制保证线程安全。JLS 规定,一个类被初始化时,JVM 会获取一个初始化锁(LC),保证同一个类的 <clinit> 只被执行一次,而且多个线程同时请求初始化时,只有一个线程执行,其他线程阻塞等待;等初始化完成后,其他线程看到的是初始化完毕的状态。

同时,Holder 是个独立的类,只有调用 getInstance() 时才会被加载,所以还顺带实现了懒加载。优点是不需要 volatile,不需要同步块,代码少,不会写错。

缺点也很明确:没法传参。如果单例的构造需要外部参数(比如从配置文件读一个地址),这个写法就用不了,只能回到 DCL。

最稳妥的写法:枚举

public enum Singleton {
    INSTANCE;

    private final ConnectionPool pool;

    Singleton() {
        pool = new ConnectionPool();
    }

    public ConnectionPool getPool() {
        return pool;
    }
}

这是《Effective Java》第二版里推荐的写法,作者说它是"实现单例的最佳方式"。理由有两个,都不只是线程安全:

  • 天然防反射攻击:JDK 保证枚举的构造器不能被反射调用,Constructor.newInstance 会抛 IllegalArgumentException: Cannot reflectively create enum objects。而普通的私有构造器,用 setAccessible(true) 就能绕过去 new 一个。
  • 天然防序列化破坏:枚举的序列化和反序列化由 JVM 特殊处理,只写入 name,反序列化时用 Enum.valueOf 查找,拿到的还是同一个实例。普通单例反序列化时会生成新对象,除非你实现 readResolve 方法。

我特意验证了反射攻击这条:

Constructor<Singleton> c = Singleton.class.getDeclaredConstructor();
c.setAccessible(true);
Singleton s = c.newInstance();
Exception in thread "main" java.lang.IllegalArgumentException:
    Cannot reflectively create enum objects
    at java.lang.reflect.Constructor.newInstance(Constructor.java:417)

枚举的缺点是不能继承(枚举继承了 java.lang.Enum),而且写法上有些人觉得别扭。我们项目里用枚举做单例的主要是配置类和连接池。

Spring 里的单例是另一回事

有次面试我顺嘴说"Spring 的 bean 默认都是单例",面试官问"那它用的是哪种实现方式?"我又卡住了。后来查才知道,Spring 的单例是容器级的单例,不是 JVM 类加载器级别的。

在 Spring 的 DefaultSingletonBeanRegistry 里,单例 bean 存在一个 map 里,创建时用了双重检查加锁:

// 简化后的核心逻辑
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
private final Map<String, Object> singletonFactories = new HashMap<>(16);

public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) {
    synchronized (this.singletonObjects) {
        Object singletonObject = this.singletonObjects.get(beanName);
        if (singletonObject == null) {
            // ... 创建、放入 earlySingletonObjects 解决循环依赖
            singletonObject = singletonFactory.getObject();
            addSingleton(beanName, singletonObject);
        }
        return singletonObject;
    }
}

它用的是 synchronized 块而不是 volatile + DCL,因为创建 bean 的过程很复杂(还要处理循环依赖、生命周期回调),不能像 DCL 那样只保护一个赋值动作。

另外要分清楚:Spring 的 singleton 作用域指的是"同一个 BeanFactory 里,同一个 beanName 只对应一个实例"。如果你起了两个 ApplicationContext,或者同一个类被定义了两个 bean,照样能拿到多个实例。这跟设计模式里"一个类全局一个实例"不是一回事。

现在的答案

如果再被问到这道题,我会这么答:

  1. DCL 里 volatile 的作用是禁止 instance 赋值和对象初始化之间的指令重排,防止其他线程拿到未初始化完成的对象;
  2. 它同时保证了可见性,让第二个线程能立刻看到第一个线程创建好的实例;
  3. 但 DCL 写法复杂容易出错,优先用静态内部类实现懒加载单例,需要防止反射和序列化破坏时用枚举

第三次面试又遇到这道题,我把这三点说完,面试官点了头。虽然那家公司最后没去,但这个问题总算是真正搞懂了,不是背的。

参考