概述

单例(Singleton)是被讲得最多、也被误用最多的设计模式之一。这篇只回答三个问题:

  1. 五种写法各自把代价付在了哪里?用 JDK 25 的字节码和实测延迟回答。
  2. “唯一性”能不能被打破?实跑反射、反序列化、Unsafe、clone 四条路径,包括对枚举版的攻击。
  3. 什么时候不该用单例?全局状态、测试隔离、类加载器边界。

代码均在本机 JDK 21 与 JDK 25 编译运行,txt 块是真实终端输出;涉及线程语义的结论都指到 JLS 具体章节。


五种写法

每种实现的构造器里都调用一次 Work.expensive():一段 4000 万次的整数忙循环(for (int i = 0; i < 40_000_000; i++) x = (x * 31 + i) ^ (x >>> 5);,最后返回 x),在本机约 75ms,好让”初始化成本”在实测里被定位到不同时间点。

一、饿汉式:类初始化时就把实例建好

1
2
3
4
5
6
7
8
9
10
11
12
13
public final class EagerSingleton {
// 类初始化时(JLS 12.4.2)即创建实例
public static final EagerSingleton INSTANCE = new EagerSingleton();
public final int payload;

private EagerSingleton() {
this.payload = Work.expensive();
}

public static EagerSingleton getInstance() {
return INSTANCE;
}
}

INSTANCEstatic final 字段,初始化表达式在类初始化阶段执行(JLS 12.4.2 的 <clinit>),getInstance() 退化成一次 getstatic

优点是代码最短、天然线程安全(类初始化由 JVM 加锁)。缺点是不管用不用,类一被初始化实例就建好;构造昂贵或依赖外部资源时,成本与异常都会转嫁给”第一次碰到这个类”的调用方。

二、懒汉式:每次调用都加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class LazySingleton {
private static LazySingleton instance;
public final int payload;

private LazySingleton() {
this.payload = Work.expensive();
}

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

静态同步方法锁的是所在类的 Class 对象(JLS 17.1)。它换来了延迟初始化,却把锁开销加到每一次调用上——实例早就建好了,仍要获取监视器。实测 8 线程吞吐只有饿汉式的 1/28。

三、双重检查锁 DCL:只在第一次加锁

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public final class DCLSingleton {
// volatile 缺一不可:既防可见性问题,又防"先赋值后构造"的重排序
private static volatile DCLSingleton instance;
public final int payload;

private DCLSingleton() {
this.payload = Work.expensive();
}

public static DCLSingleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (DCLSingleton.class) { // 加锁
if (instance == null) { // 第二次检查(锁内)
instance = new DCLSingleton(); // volatile 写
}
}
}
return instance; // volatile 读
}
}

无锁读一次,不为 null 直接返回;为 null 才加锁,锁内再查一次防止两个线程都通过第一次检查。”DCL 是错的”是 Java 5 之前的结论;JSR-133 修正内存模型后,字段声明为 volatile 的 DCL 是正确的——volatile 不能省。

四、静态内部类 Holder:把延迟初始化交给 JVM

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class HolderSingleton {
public final int payload;

private HolderSingleton() {
this.payload = Work.expensive();
}

public static HolderSingleton getInstance() {
return Holder.INSTANCE; // 首次访问触发 Holder 的初始化(JLS 12.4.1)
}

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

外层类被初始化时不会初始化嵌套类 Holder;只有第一次执行 getstatic HolderSingleton$Holder.INSTANCE 才触发其初始化、才创建实例。延迟加载 + 零手写同步,实测快路径成本最低(0.70 ns/次),也是《Effective Java》推荐的方案。

五、枚举:JVM 层面只有这些实例

1
2
3
4
5
6
7
8
9
10
11
12
13
public enum EnumSingleton {
INSTANCE;

public final int payload;

EnumSingleton() {
this.payload = Work.expensive();
}

public static EnumSingleton getInstance() {
return INSTANCE;
}
}

枚举常量是 static final 字段,在枚举类初始化时构造,因此和饿汉式一样”类初始化时创建”。额外好处:反序列化保证返回同一实例,反射创建会被 JVM 拒绝。缺点是不能再继承别的类,初始化时机也不可控。


规范依据

这一节把每种写法的正确性依据指到 JLS 具体章节。

类初始化本身就是线程安全的:JLS 12.4.2

Holder 方案成立的全部依据在这里。JLS 12.4.2(Detailed Initialization Procedure)原文:

Because the Java programming language is multithreaded, initialization of a class or interface requires careful synchronization, since some other thread may be trying to initialize the same class or interface at the same time.

规范接着规定,每个类或接口 C 都有唯一的初始化锁 $L_C$,其 Class 对象的状态只可能是”已校验未初始化 / 正被某线程初始化 / 已完全初始化 / 出错”四者之一。线程先获取 $L_C$,若发现别的线程正在初始化,就释放锁并阻塞等待直至完成。这保证一个类最多初始化一次,且完成后其静态状态对所有线程可见

触发条件由 JLS 12.4.1(When Initialization Occurs)给出:

A class or interface T will be initialized immediately before the first occurrence of any one of the following:

  • T is a class and an instance of T is created.
  • A static method declared by T is invoked.
  • A static field declared by T is assigned.
  • A static field declared by T is used and the field is not a constant variable.

关键细节:引用一个静态字段,只初始化声明该字段的那个类。Holder 正踩在这条上——getInstance() 访问的 INSTANCE 声明在 HolderSingleton$Holder 里,所以初始化被推迟到第一次访问(规范 Example 12.4.1-2 用 Sub.taxi 演示了同一件事)。

两条线程同时首次访问时:

DCL 为什么必须 volatile:JLS 17.4.5 与 17.5

隐患是重排序。instance = new DCLSingleton() 可被拆成三步并重排:分配内存、调用构造器写字段、把引用写入 instance。没有 volatile 时,第三步可以被提到第二步之前:线程 A 刚写入引用(对象尚未构造完),线程 B 的第一次检查读到非 null,直接返回一个字段还是默认值的半成品。

JLS 17.4.5(Happens-before Order)规定:

A write to a volatile field (§8.3.1.4) happens-before every subsequent read of that field.

配合 JLS 17.4.4(”A write to a volatile variable v synchronizes-with all subsequent reads of v by any thread”)得到:构造器里的写入 happens-before 于那次 volatile 写,该 volatile 写又 happens-before 于读线程随后的 volatile 读。传递过去,读线程要么读到 null,要么读到完全构造好的实例,没有中间态。

容易被忽略的是 JLS 17.5(final Field Semantics)的保证:

An object is considered to be completely initialized when its constructor finishes. A thread that can only see a reference to an object after that object has been completely initialized is guaranteed to see the correctly initialized values for that object’s final fields.

前提是”只能在该对象完全初始化之后才看到引用“。无 volatile 的 DCL 恰恰违反这个前提,所以 17.5 救不了它;起作用的是 volatile 建立的 happens-before。

枚举为什么天然防反射与防反序列化

防反序列化的依据在 Java Object Serialization Specification §1.12(Serialization of Enum Constants):

The serialized form of an enum constant consists solely of its name; field values of the constant are not present in the form. To serialize an enum constant, ObjectOutputStream writes the value returned by the enum constant’s name method. To deserialize an enum constant, ObjectInputStream reads the constant name from the stream; the deserialized constant is then obtained by calling the java.lang.Enum.valueOf method.

枚举只序列化”名字”,反序列化走 Enum.valueOf 查表,返回的必然是已存在的那个常量,不需要你写 readResolve()

防反射的依据来自 java.lang.reflect.Constructor#newInstance 的 javadoc:被反射对象是枚举时抛 IllegalArgumentException,守卫早于任何构造器调用。另外枚举构造器签名是隐式的 (String name, int ordinal),无参的 getDeclaredConstructor() 会先得到 NoSuchMethodException


字节码验证

以下为 JDK 25 javap -c -p -v 输出摘录,省略号表示未贴出的行。

DCL:volatile 是写在字段标志位上的

1
2
3
private static volatile DCLSingleton instance;
descriptor: LDCLSingleton;
flags: (0x004a) ACC_PRIVATE, ACC_STATIC, ACC_VOLATILE

flags 里的 ACC_VOLATILE 就是 JMM 生效的开关。getInstance()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static DCLSingleton getInstance();
...
Code:
0: getstatic #19 // Field instance:LDCLSingleton;
3: ifnonnull 37
6: ldc #14 // class DCLSingleton
8: dup
9: astore_0
10: monitorenter
11: getstatic #19 // Field instance:LDCLSingleton;
14: ifnonnull 27
17: new #14 // class DCLSingleton
20: dup
21: invokespecial #23 // Method "<init>":()V
24: putstatic #19 // Field instance:LDCLSingleton;
27: aload_0
28: monitorexit
29: goto 37
// 偏移 32–36 为编译器为 synchronized 生成的异常路径,略
37: getstatic #19 // Field instance:LDCLSingleton;
40: areturn

偏移 0 的 getstatic第一次检查(快路径的 volatile 读),ifnonnull 37 成立时跳到 37 再读一次并返回。偏移 10 的 monitorenter 对应 synchronized,偏移 11 是第二次检查。偏移 17–24 是构造:newdupinvokespecial <init>putstatic(volatile 写),必须在 monitorexit 前完成;偏移 32–36 是异常路径,保证异常时也解锁。

快路径上有两次 getstatic instance(偏移 0 判断、偏移 37 取值),都是 volatile 读——这解释了实测里 DCL 后续调用开销(1.44 ns)为何明显高于 Holder(0.70 ns)。

Holder:两三条指令,外加一个独立的 <clinit>

1
2
3
4
public static HolderSingleton getInstance();
Code:
0: getstatic #19 // Field HolderSingleton$Holder.INSTANCE:LHolderSingleton;
3: areturn

访问的是 HolderSingleton$Holder.INSTANCE,触发的是嵌套类的初始化。嵌套类 Holder

1
2
3
4
5
6
7
8
9
10
11
12
static final HolderSingleton INSTANCE;
...
flags: (0x0018) ACC_STATIC, ACC_FINAL
...
static {};
...
Code:
0: new #7 // class HolderSingleton
3: dup
4: invokespecial #9 // Method HolderSingleton."<init>":()V
7: putstatic #10 // Field INSTANCE:LHolderSingleton;
10: return

INSTANCE 的 flags 里没有 ACC_VOLATILE——它的初始化安全来自 JVM 的类初始化过程($L_C$):<clinit> 只执行一次,完成后对所有线程可见。所以 Holder 的快路径是纯 getstatic,一次普通静态字段读。

而饿汉式的 <clinit> 与上面几乎一样,区别只在在哪个类的初始化里执行:饿汉式在外层类,Holder 在嵌套类;同样三行字节码,触发时机不同。


三种破坏路径

“单例保证唯一”在反射、序列化和底层 API 面前很脆弱。以下四条攻击全部在本机跑过,输出为真实终端输出。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
import java.io.*;
import java.lang.reflect.*;

public class Break {
static Object roundtrip(Object o) throws Exception {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(o);
oos.close();
ObjectInputStream ois = new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray()));
return ois.readObject();
}
static sun.misc.Unsafe getUnsafe() throws Exception {
Field f = sun.misc.Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
return (sun.misc.Unsafe) f.get(null);
}
public static void main(String[] args) throws Exception {
EagerSingleton a = EagerSingleton.getInstance();
EagerSingleton b = EagerSingleton.getInstance();
System.out.println("[基线] 两次 getInstance 同一实例: " + (a == b));
// 1. 反射 setAccessible 建第二个实例
Constructor<EagerSingleton> c = EagerSingleton.class.getDeclaredConstructor();
c.setAccessible(true);
EagerSingleton r = c.newInstance();
System.out.println("[反射] 与单例不同实例: " + (a != r) + "(构造器被私有仍可调用)");
// 2. Unsafe.allocateInstance 绕过构造器
EagerSingleton us = (EagerSingleton) getUnsafe().allocateInstance(EagerSingleton.class);
System.out.println("[Unsafe] 与单例不同实例: " + (a != us));
// 3. 反序列化(SerialSingleton 无 readResolve)
SerialSingleton s1 = SerialSingleton.getInstance();
SerialSingleton s2 = (SerialSingleton) roundtrip(s1);
System.out.println("[反序列化] 与单例不同实例: " + (s1 != s2));
// 4. clone 绕过构造器
CloneSingleton c1 = CloneSingleton.getInstance();
CloneSingleton c2 = c1.clone();
System.out.println("[clone] 与单例不同实例: " + (c1 != c2));
}
}

被攻击的另外两个类:

1
2
3
4
5
6
7
8
9
import java.io.Serializable;

public final class SerialSingleton implements Serializable {
private static final long serialVersionUID = 1L;
private static final SerialSingleton INSTANCE = new SerialSingleton();
private SerialSingleton() {}
public static SerialSingleton getInstance() { return INSTANCE; }
// 故意不写 readResolve(),用于演示"反序列化会产生新实例"
}
1
2
3
4
5
6
7
8
9
10
11
12
public final class CloneSingleton implements Cloneable {
private static final CloneSingleton INSTANCE = new CloneSingleton();
private CloneSingleton() {}
public static CloneSingleton getInstance() { return INSTANCE; }

// 故意暴露 clone(),它不调用构造器,直接按位复制字段
@Override
public CloneSingleton clone() {
try { return (CloneSingleton) super.clone(); }
catch (CloneNotSupportedException e) { throw new AssertionError(e); }
}
}

JDK 25 与 JDK 21 上的输出完全一致:

1
2
3
4
5
[基线] 两次 getInstance 同一实例: true
[反射] 与单例不同实例: true(构造器被私有仍可调用)
[Unsafe] 与单例不同实例: true
[反序列化] 与单例不同实例: true
[clone] 与单例不同实例: true

反射:JLS 只规定”构造器可以声明为 private”,访问控制是编译期与反射入口的检查,不是内存层面的封锁;setAccessible(true) 之后 newInstance() 会照常执行私有构造器。唯一的自救是在构造器里判断 INSTANCE != null 就抛异常;但 INSTANCE 的初始化表达式本身会调用构造器,此时它还是 null,守卫得再区分”初始化期间”和”之后”,通常还要加一个标志——这正是枚举想省掉的代码。

反序列化:普通 Serializable 的默认反序列化不走构造器,由 ObjectInputStream 分配内存再回填字段。INSTANCEstatic,默认序列化会跳过,读回来的是字段全默认值的新实例。修法是自己加 private Object readResolve() { return INSTANCE; }

Unsafeclonesun.misc.Unsafe.allocateInstance(Class) 直接分配对象内存、完全不执行构造器,构造器里写什么守卫都拦不住;Object.clone() 默认实现是”字段按位复制”,同样不调用构造器,只要实现 Cloneable 并暴露 clone() 就能复制出第二个实例。修法:不实现 Cloneable、让 clone() 抛异常;Unsafe 则没有修法——本机 javac 警告”Unsafe是内部专用 API, 可能会在未来发行版中删除”,JEP 471 已把它标记为待移除,JEP 498 会在调用时发运行时警告。

对枚举版跑同样的攻击

EnumBreak.javamainimportroundtrip()getUnsafe() 同上面的 Break.java,此处省略):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class EnumBreak {
public static void main(String[] args) throws Exception {
EnumSingleton a = EnumSingleton.INSTANCE;
// 1. 反射无参构造
try {
Constructor<?> c0 = EnumSingleton.class.getDeclaredConstructor();
c0.setAccessible(true);
c0.newInstance();
System.out.println("[反射无参] 意外成功");
} catch (Exception e) {
System.out.println("[反射无参] " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
// 2. 反射真实构造器 (String, int)
try {
Constructor<?> c2 = EnumSingleton.class.getDeclaredConstructor(String.class, int.class);
c2.setAccessible(true);
c2.newInstance("FAKE", 0);
System.out.println("[反射(String,int)] 意外成功");
} catch (Exception e) {
System.out.println("[反射(String,int)] " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
// 3. 反序列化
Object d = roundtrip(a);
System.out.println("[反序列化] 仍是同一实例: " + (a == d));
// 4. Unsafe.allocateInstance
try {
sun.misc.Unsafe u = getUnsafe();
Object us = u.allocateInstance(EnumSingleton.class);
System.out.println("[Unsafe] 意外成功: " + us);
} catch (Exception e) {
System.out.println("[Unsafe] " + e.getClass().getSimpleName() + ": " + e.getMessage());
}
}
}

JDK 25 上的输出:

1
2
3
4
[反射无参] NoSuchMethodException: EnumSingleton.<init>()
[反射(String,int)] IllegalArgumentException: Cannot reflectively create enum objects
[反序列化] 仍是同一实例: true
[Unsafe] 意外成功: null
  • [反射无参]:枚举没有无参构造器,直接 NoSuchMethodException,对应 javap 里看到的 <init>(String,int)
  • [反射(String,int)]:签名对上了,但 Constructor.newInstance 的枚举守卫抛 IllegalArgumentException——反射被封死。
  • [反序列化]:返回原实例,符合上文引用的序列化规范。
  • [Unsafe]竟然”成功”了,但打印出来是 null

反直觉的一步:Unsafe 能击穿枚举,但造出的是废对象

把 Unsafe 那条路径单独放大,打印身份信息:

1
2
3
4
5
类型: EnumSingleton
是不同实例: true
toString(): null
name(): null
ordinal(): 0

allocateInstance 分配出一个 EnumSingleton 类型的对象,且与 INSTANCE 不是同一个。但它是残缺的:枚举的 nameordinalEnum 的构造器写入,而 allocateInstance 不执行构造器,这两个字段停在 null / 0。toString()name() 返回 null、ordinal() 返回 0 都是这个原因。

所以结论要分两句:枚举挡住了反射、反序列化和克隆,但挡不住 Unsafe.allocateInstance;后者产出的是一个字段残缺的废对象,且需要先拿到 Unsafe 实例本身。 枚举把”正常途径”全部堵死,剩下的只有”你已经处在能任意操作内存的位置”这条自救路径。


实测

测量方法与环境

  • 机器:Apple M1 Pro(arm64),macOS;JDK:21.0.8+12-LTS25+37-LTS。每个写法各起一个新 JVMjava -cp out Bench <mode> 8 5000000),避免交叉污染。分四段:
    1. 类加载+初始化Class.forName(name, true, loader)true 表示触发初始化)的耗时;
    2. 首次 getInstance:类已初始化后第一次调用;
    3. 后续调用:先 200 万次 warmup,再取 3 轮各 500 万次里的最小值,折算 ns/次;
    4. 8 线程吞吐:8 线程各循环 500 万次,测总耗时折算 ops/s。
  • 为阻止 JIT 把”结果没人用”的循环整个消除,第 3、4 段把每次返回值写入 static volatile Object sink;这个 store 对五种写法是同一份固定开销,所以横向比较成立,但绝对值不代表硬件上限。没有 JMH,热身后分层编译与 CPU 频率仍有影响,以下数字仅供量级参考,不是硬件基准

首次调用延迟:成本落在哪里

JDK 25 实测:

写法 类加载+初始化 (ms) 首次 getInstance (ms) 后续调用 (ns/次) 8 线程吞吐 (M ops/s)
饿汉 75.171 0.0076 0.74 478
懒汉(同步) 0.020 75.347 8.72 16.9
DCL 0.018 75.044 1.44 192
静态内部类 0.020 75.173 0.70 487
枚举 74.870 0.0055 0.71 526

初始化成本落在哪里:五种写法总成本都在 75ms 量级,区别只在代价被付在类加载还是首次调用

  • 饿汉与枚举的成本全在”类加载+初始化”里(75.17 / 74.87 ms),首次 getInstance() 只要几微秒——构造早在类初始化时做完。
  • 懒汉、DCL、静态内部类的类加载几乎为 0(0.018–0.020 ms,只加载字节码),成本全落在首次调用(75.04–75.35 ms)。
  • 五者总成本几乎相同,差别只在什么时候付;延迟初始化只是把同一笔成本挪到首次调用。

后续调用开销与 8 线程并发吞吐:静态内部类与饿汉式快路径最便宜,懒汉式的同步底座把吞吐压到 1/28

后续调用:volatile 读不是免费的

  • 静态内部类(0.70 ns)、饿汉(0.74 ns)、枚举(0.71 ns)几乎相同——都是纯 getstatic
  • DCL 是 1.44 ns,约为静态内部类的 2 倍。 原因在字节码里:快路径上有两次 volatile 读,而 volatile 读带获取语义(acquire),JMM 要求插入必要的内存屏障。
  • 懒汉(同步)8.72 ns,又高一档——每次都要走 monitorenter/monitorexit

DCL 为线程安全引入的 volatile,让它比”完全不写同步代码”的 Holder 慢了近一倍。 DCL 省掉的是”每次都加锁”,代价是”每次都要一次 volatile 读”;Holder 用 JLS 的类初始化锁把同步成本压到只发生一次,快路径上什么都不用付。

8 线程吞吐:同步底座的真实差距

  • 静态内部类 487、饿汉 478、枚举 526(M ops/s)——都在 5 亿次/秒量级。
  • DCL 192 M ops/s,约为静态内部类的 2.5 倍慢。 单线程只差 0.7 ns,多线程下 volatile 读的获取语义与各核写缓冲、缓存行交互,差距被放大。
  • 懒汉(同步)16.9 M ops/s,约为静态内部类的 1/29。 8 个线程争抢同一个监视器,时间几乎全花在锁转移上。

Java 21 上的同组数据(趋势一致,写法顺序:饿汉 / 懒汉 / DCL / 静态内部类 / 枚举):类加载+初始化 76.674 / 0.019 / 0.017 / 0.019 / 77.253 ms;首次 getInstance 0.0082 / 76.636 / 77.249 / 77.370 / 0.0056 ms;后续调用 0.76 / 9.91 / 1.43 / 0.80 / 0.78 ns 每次;8 线程吞吐 438 / 31.6 / 156 / 521 / 453 M ops/s。

两个 JDK 结论一致:Holder/饿汉/枚举 的吞吐在 4.4–5.3 亿次/秒,DCL 落在 1.5–1.9 亿,懒汉落在 1.7–3.2 千万。懒汉两版抖动较大(16.9 与 31.6),因为它对同步竞争的调度敏感,但无论哪一版都比 Holder 低一个数量级以上。

选型结论getInstance() 在热路径上(每请求、每循环都调)时别用 synchronized 懒汉,也别用 DCL,用 Holder 或枚举;一天只调几次的话,按可读性选即可。


什么时候不该用单例

单例最常见的失败是场景错误

全局状态

持有可变状态(计数器、缓存、连接池)的单例等价于一个全局变量:任何代码都能改它,出问题时无法定位是谁改的;并发访问要靠它自己保证;测试里无法在用例之间重置。判别很简单:如果这个类的字段会变,它多半不该是单例。 无状态的配置/工厂可以是单例,有状态的”管理者”通常不是。

测试隔离

JUnit 在同一 JVM 里跑一个类的多个测试方法,单例的静态状态会在方法间残留——前一个用例改了字段,后一个读到脏数据。更麻烦的是无法替换实现:单例把”创建”硬编码进 getInstance(),测试里塞不进 mock,而常见补救(反射改私有静态字段)本身就是前面那种攻击路径。替代方案是依赖注入:让使用者通过构造器/参数接收依赖,容器或测试各自提供实现。

类加载器边界

单例的”唯一”由 static 字段保证,而 static 字段归属于某个 Class 对象,Class 对象由某个类加载器定义(JLS 12.2.1:ClassLoader.defineClass 从字节码构造 Class 对象)。同一份 AppSingleton.class两个不同类加载器加载时,会得到两个 Class 对象,也就有两个 INSTANCE——Web 容器、OSGi、插件框架、热部署都会这样。此时 getInstance() 只满足”每个类加载器内唯一”;若这个单例是跨模块注册表,或在 equals/instanceof 上有类型判断,就会以”明明单例却有两份”的形式炸掉。

替代方案

  • 依赖注入:把”实例从哪来”从类内部搬到外部(构造器、工厂、容器)。这是多数”想用单例”场景的正解。
  • 作用域对象:请求级、会话级、线程级的对象,生命周期由框架管理。单例是”每 JVM 一个”,作用域对象粒度更合适。
  • 显式传递:把依赖作为参数一路传下去,让依赖关系在类型签名里可见;无状态的类则直接写成 static 工具方法。

只有”全局唯一 + 状态不可变 + 初始化昂贵或必须延迟”三条同时成立时,单例才是正解,这时选 Holder 或枚举。


总结

  • 正确性来源各不相同:饿汉/枚举靠类初始化(JLS 12.4.1 触发、12.4.2 加锁);Holder 靠嵌套类初始化的延迟触发(同一机制,触发点推迟);DCL 靠 volatile 的 happens-before(JLS 17.4.5);懒汉靠方法级 synchronized
  • DCL 必须volatile。没有它,引用可能先于构造完成被发布,另一个线程会拿到半成品;JLS 17.5 的 final 字段保证救不了,因为它以”引用在构造完成后才可见”为前提。
  • 枚举同时挡住反射Constructor.newInstanceIllegalArgumentException)和反序列化(规范规定走 Enum.valueOf 返回原实例),但挡不住 Unsafe.allocateInstance——后者产出 name=nullordinal=0 的残缺对象。”枚举绝对安全”要打个折。
  • 代价分布:五者总初始化成本相近(75ms),差别只在何时支付;快路径上 Holder/饿汉/枚举 是纯 getstatic0.7 ns),DCL 因 volatile 读贵约一倍,8 线程吞吐差 2.5 倍;synchronized 懒汉吞吐不足 1/28。
  • 选型之前先问:这个类该不该是单例?再问用哪种写法。

参考资料


系列索引:设计模式系列