前几天有个刚工作的读者问我:单例模式到底该怎么学,说网上的博客看了一堆,饿汉、懒汉、双重检查锁、静态内部类、枚举,抄来抄去,代码都能默写了,可面试官一句“JDK 源码里哪里用了单例”,人直接愣住。这个问题我太熟了。单例模式可能是所有设计模式里代码量最小、但坑最多、最能把人从“背写法”拉到“看本质”的一个模式。我自己当年把 java.lang.Runtime 的源码翻出来一行行读完,很多过去想不通的东西瞬间就通了。这篇博文,就顺着 JDK 源码把单例模式重新拆一遍,不背结论,只看代码。
1. 为什么单例模式非要拿 JDK 源码来学
1.1 网上教程和 JDK 源码之间的差距
大多数单例模式教程会给你一张五种写法的对照表:饿汉式、懒汉式、双重检查锁、静态内部类、枚举,然后告诉你“推荐用枚举”。表格背得滚瓜烂熟,但问到“为什么 JDK 的 Runtime 类要用饿汉式,而 Spring 的 Bean 却用容器缓存”,很多同学就答不上来了。
这不能怪学习者,因为网上教程普遍缺少一个最重要的东西:真实工程里的取舍逻辑。JDK 源码恰恰是最权威的“真实工程”。它是 Oracle 和 OpenJDK 社区工程师多年沉淀下来的代码,里面每个类的写法都有明确理由,不是为了演示设计模式而设计的。你能看到 Runtime 这种标准的饿汉式,也能看到 Collections 里用不可变单例思想实现的空集合,甚至能看到反射机制对枚举单例的“天然保护”。这些才是单例模式在现实世界中的真实形态。
1.2 读源码前要有的三个底层概念
看 JDK 源码不是逐行读注释,而是带着底层机制去看。读单例相关源码之前,我建议你先想清楚三件事。
第一,类加载机制。一个类在同一个类加载器下只会被加载一次,静态字段的初始化发生在类加载的初始化阶段,这个阶段由 JVM 保证线程安全。饿汉式和静态内部类单例之所以不需要加锁,靠的就是这一点。第二,对象创建的额外通道。除了 new,反射可以调用私有构造器,反序列化可以不经过构造器直接重建对象,这些都会破坏单例。JDK 源码里对枚举单例的保护,就是在这些通道上做文章。第三,字节码视角。如果你只会看 .java 文件,很多细节会漏掉。比如双重检查锁为什么要 volatile,你去看字节码和 JMM 相关源码才能彻底理解。
有了这三个概念,再去啃 Runtime、Enum、Constructor、ObjectInputStream 这些类的源码,你会发现自己不是在“读设计模式”,而是在读 Java 平台的设计哲学。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Runtime 类源码:JDK 里最标准的饿汉式长什么样
2.1 Runtime 源码里的单例骨架
java.lang.Runtime 是 JDK 中教科书般的饿汉式单例。我以 JDK 8 的源码为例,去掉业务方法,单例骨架大概长这样:
java复制public class Runtime {
private static Runtime currentRuntime = new Runtime();
public static Runtime getRuntime() {
return currentRuntime;
}
private Runtime() {
}
}
就这么几行,但每一个细节都有讲究。
构造器是 private,外部完全没办法 new 一个 Runtime 出来。静态字段 currentRuntime 在类加载阶段就被初始化,也就是 JVM 保证类加载只发生一次,所以这个实例在 JVM 生命周期内全局唯一。getRuntime() 是静态方法,所有调用方都拿到同一个引用。这个结构放到任何面试场景里,都是饿汉式的标准答案。
有一点值得注意:JDK 8 的这个静态字段并没有声明 final。不过这不影响单例的唯一性,因为类加载机制已经保证了只初始化一次。高版本 JDK 里这个字段改成了 final,语义更严谨。去读源码时如果发现不同版本有细微差异,别慌,核心思想没有变。
2.2 为什么 JDK 偏爱饿汉式而不是懒加载
很多同学会问:为什么不把 Runtime 设计成懒加载?等第一次调用 getRuntime() 时才创建实例,不是更高效吗?
答案藏在 Runtime 这个类的定位里。Runtime 代表的是当前 Java 虚拟机运行时环境,JVM 一启动,这个东西就是客观存在的。创建一个 Runtime 实例的成本极低,不涉及数据库连接、不加载配置文件、不启动外部资源。这种情况下搞懒加载,除了增加代码复杂度,没有任何收益。
懒加载的收益场景是“创建代价高”或者“可能根本用不上”。比如一个重量级的线程池,如果应用启动时未必会用到,你可能会希望延迟到第一次使用再创建。但 Runtime 不属于这种情况,它是 JVM 的一部分,你几乎必然要跟它打交道。
更深一层看,饿汉式天然线程安全。它不依赖 synchronized,不依赖 volatile,全靠类加载机制兜底。如果强行引入双重检查锁,反而要在每一次 getRuntime() 调用里多一次 volatile 读、一次判空、一次锁竞争,性能和代码可读性都会更差。JDK 工程师在这个场景下的选择,是彻底的“简单优先”。
2.3 Runtime 的其它能力与单例的关系
Runtime 不只是给你当作单例教材看的,它还封装了很多 JVM 层面的能力。常见的有 availableProcessors() 获取 CPU 核数、freeMemory() 和 totalMemory() 获取内存情况、gc() 请求垃圾回收、exec() 启动外部进程。
这些方法全都挂在同一个 Runtime 实例上,本质上就是提供一个全局访问点,让程序任何地方都能拿到当前 JVM 的运行信息。你不需要到处传参,也不用担心拿到不同的对象导致状态不一致。这也正是单例模式的核心价值:某个概念在系统里只有一个,就用一个对象把它表达清楚。
我见过一些同学在业务代码里把配置类、工具类全部做成单例,理由是“这样访问方便”。这其实是把单例模式用歪了。单例适合保存“系统级状态”,而不仅仅是提供一个“方便的入口”。Runtime 保存的是 JVM 的状态,LogManager 保存的是日志全局配置,这类场景才真正需要单例。
3. System 与工具类:private 构造器不等于单例,这才是最容易混的点
3.1 System 类源码里的“没有实例”
聊完 Runtime,下一个经常被误认为单例的类就是 java.lang.System。System 类的源码里确实有 private 构造器:
java复制public final class System {
private System() {
}
public static final InputStream in;
public static final PrintStream out;
public static final PrintStream err;
}
类名是 final,构造器是 private,看起来和单例模式如出一辙。但仔细看你会发现,System 类里从来没有一个静态字段保存过“System 自己的实例”。它有 in、out、err 这些静态字段,但这些字段的类型是 InputStream、PrintStream,不是 System。System 这个类本身,一个实例都没有。
这是工具类和单例类最本质的区别。
工具类把一堆静态方法组织在一起,禁止实例化,目的是“不让你创建对象”。单例类则是“允许一个对象存在,但只允许一个”。工具类可以理解成“零例模式”,单例是“一例模式”,两个方向就差一个字,设计意图完全不同。
JDK 里这样的工具类非常多:Math、Collections、Arrays、Objects,全都是 private 构造器,没有任何实例。如果你在项目里定义了一个类,里面全是静态方法,然后顺手把构造器私有化,这属于工具类,不是单例。
3.2 工具类、单例类、受控实例:三者的边界
为什么这个边界很重要?因为设计意图不同,后续的维护方式也不同。
工具类强调无状态、纯函数,输入输出可预测,不保存可变的全局数据。比如 Math.max 你调用多少次,行为都不会被环境改变。单例类则可能保存可变状态,比如 Runtime 里的 shutdown hook 列表、LogManager 里的全局日志配置,一旦多线程并发修改,就需要引入同步机制。如果把一个本质是工具类的类设计成单例,等于白白增加了一个全局状态角色,别人调用时还要担心线程安全问题,完全没有必要。
还有一类更容易混淆的“受控实例”,典型代表是 java.util.Currency。Currency 的构造器也是 private,通过 getInstance(Locale) 或 getInstance(String currencyCode) 获取对象。但它不是全局单例,而是按货币代码建立缓存,同一个货币代码返回同一个对象,不同货币代码返回不同对象。这种模式已经不再是单例,而是滑向了享元模式:为每个 key 维护一个共享实例。
所以在读 JDK 源码时,看到 private 构造器先别急着贴“单例”标签。先问自己一句:这个类有没有暴露一个静态字段或 getInstance 方法返回自己的实例?没有,就是工具类;有且只有一个,才是单例;有多个缓存实例,那就是享元或者受控实例。
4. 枚举单例为何能免疫反射和序列化:从 Enum 源码里找答案
4.1 枚举能当单例的底层原因
《Effective Java》里强烈推荐用枚举实现单例,很多人背下了这个结论,却不清楚底层原因。其实只要看一眼 java.lang.Enum 和编译器生成的枚举类,一切都很清楚。
定义一个枚举:
java复制public enum Singleton {
INSTANCE;
}
用 javap 反编译看字节码,你会发现它本质上是一个 final 类,继承自 java.lang.Enum,并且有一个静态字段:
java复制public final class Singleton extends java.lang.Enum<Singleton> {
public static final Singleton INSTANCE;
static {
INSTANCE = new Singleton();
}
}
看到没有?枚举常量的本质就是编译器帮你生成一个 static final 字段,并且在静态初始化块里 new 出来。这跟 Runtime 类的饿汉式单例骨架几乎一模一样,只不过构造器和 static 字段都由编译器代劳了。所以枚举单例天然就是线程安全的饿汉式单例。
4.2 反射攻击为什么对枚举无效
普通单例类可以通过反射拿到私有构造器,然后 setAccessible(true) 强行创建新实例,把一个好好的单例变成“多例”。枚举单例为什么不怕这一招?
答案在 java.lang.reflect.Constructor 的 newInstance 方法里。JDK 源码对反射创建对象做了显式检查,遇到枚举类型会直接拒绝:
java复制public T newInstance(Object ... initargs) {
if ((clazz.getModifiers() & Modifier.ENUM) != 0) {
throw new IllegalArgumentException("Cannot reflectively create enum objects");
}
// 其他逻辑
}
这段保护是写死在 JDK 里的,不是你自己的代码能拦截的。哪怕你调用 Singleton.class.getDeclaredConstructor() 拿到枚举的构造器,再 setAccessible(true),newInstance 那一层也会直接抛异常。也就是说,枚举单例对反射攻击的免疫能力,是语言层面和 JDK 层面共同保证的,根本不需要你在构造器里写 if 判断。
4.3 反序列化为什么不会破坏枚举单例
反序列化是另一个容易破坏单例的通道。普通对象在反序列化时,会绕过构造器直接创建新对象,如果单例类没有实现 readResolve() 方法,反序列化出来的对象和原来的单例就是两个不同对象。
枚举单例的情况完全不同。枚举虽然也实现了 Serializable,但 ObjectInputStream 在处理枚举类型时不会走普通对象的反序列化流程。它读取枚举的 name 和 enumType,然后调用 Enum.valueOf(enumType, name),从已有的枚举常量里查找到对应实例并返回。整个过程不会创建新对象,所以反序列化拿到的还是原来的单例。
这一点同时也是为什么枚举单例在“分布式对象传输”场景里特别稳。你把一个枚举常量写进文件或者发到消息队列,读回来之后,还是同一个实例。
4.4 那 JDK 源码里为什么很少见业务枚举单例
既然枚举单例这么好,为什么 JDK 标准库里很少看到拿枚举做业务单例?这也是我刚读源码时的困惑,后来想明白了。
JDK 里的很多类有历史包袱,Enum 是 Java 5 才引入的,早期的 Runtime、System 这些类不可能去用它。而且 JDK 工程师对全局单例的偏好,更倾向于直接用类加载机制实现饿汉式,理由我在 Runtime 那一节说过:简单、直接、没有额外语义。
枚举真正的强项是表达“有限集合”,比如 TimeUnit、Thread.State、StandardOpenOption 这些。如果拿枚举去实现一个纯单例,它背后的“有限集合”语义其实被浪费了。所以我的建议是:你自己写业务单例,如果不想处理反射和序列化,优先用枚举;但读 JDK 源码时,别期待到处都是枚举单例。
5. Collections 空集合单例:JDK 里被忽略的不可变单例应用
5.1 EMPTY_LIST 的单例实现细节
把单例和“不可变对象”放在一起看,是理解 JDK 源码的一个重要角度。java.util.Collections 里的空集合就是典型例子。
JDK 8 里 Collections.EMPTY_LIST 的实现,本质上就是一个不可变单例:
java复制public class Collections {
private Collections() {
}
public static final List EMPTY_LIST = new EmptyList<>();
public static final <T> List<T> emptyList() {
return (List<T>) EMPTY_LIST;
}
private static class EmptyList<E> extends AbstractList<E> implements RandomAccess, Serializable {
public int size() {
return 0;
}
public E get(int index) {
throw new IndexOutOfBoundsException("Index: " + index);
}
}
}
注意 emptyList() 没有被声明为 synchronized,也没有判空逻辑,因为它每次都直接返回同一个 EMPTY_LIST 实例。它没有任何可修改的状态,size 永远为 0,get 永远抛异常,所以多线程共享这个对象完全安全。这属于不可变单例:允许大家共享,是因为没有状态可以被破坏。
JDK 9 引入的 List.of() 空列表也沿用了同样的思想,内部直接返回 ImmutableCollections.EMPTY_LIST。你在代码里写 List.of() 一百次,拿到的一百个对象都是同一个引用。用 == 比较都能返回 true。
5.2 不可变单例与对象复用的启发
从 EMPTY_LIST 可以看出,单例模式在 JDK 里最广泛的应用,反而不是那些“有状态的全局配置”,而是不可变对象的复用。一个没有状态的对象,创建一万个和创建一个在逻辑上没有任何区别,那为什么不直接复用同一个?
JDK 里还有大量类似的“对象复用”设计。Boolean.TRUE 和 Boolean.FALSE 是静态常量;Integer.valueOf() 对小整数走 IntegerCache 缓存;String 池更是把复用做到了语言底层。这些和单例模式共享同一个思想:能共享的对象,就不要重复创建。
这点对我们的实际编码非常有启发。很多团队在写无状态服务时,喜欢 new 一堆对象,然后扔掉。如果这个对象没有字段,或者字段全是 final 不可变的,完全可以做成一个共享单例,节省内存和 GC 压力。Spring 默认把 Bean 做成单例,也是同样的逻辑:大多数 Bean 没有可变状态,共享一个实例就好了。
这里也顺带区分一下:IntegerCache 那种“按值缓存多个对象”的机制严格来说是享元模式,不是单例。单例强调“一个类只有一个实例”,享元强调“一组复用对象,按 key 区分”。两者在 JDK 集合类里经常混着出现,读源码时注意分辨。
6. 对照 JDK 源码,手写单例最容易踩的坑和修复姿势
6.1 坑一:懒汉式不加锁,高并发下变“多例”
手写单例最容易翻车的,就是最基础的懒汉式。很多初学者会写出这样的代码:
java复制public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
这个代码在单线程下没有问题,但
