面了三家公司,两次被问到"单例为什么要加 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 万次调用耗时 |
|---|---|
| 整个方法 synchronize | 412 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,照样能拿到多个实例。这跟设计模式里"一个类全局一个实例"不是一回事。
现在的答案
如果再被问到这道题,我会这么答:
- DCL 里 volatile 的作用是禁止 instance 赋值和对象初始化之间的指令重排,防止其他线程拿到未初始化完成的对象;
- 它同时保证了可见性,让第二个线程能立刻看到第一个线程创建好的实例;
- 但 DCL 写法复杂容易出错,优先用静态内部类实现懒加载单例,需要防止反射和序列化破坏时用枚举。
第三次面试又遇到这道题,我把这三点说完,面试官点了头。虽然那家公司最后没去,但这个问题总算是真正搞懂了,不是背的。