单例模式这四个字,几乎是每个写 Java 的人学设计模式时撞上的第二个概念——第一个通常是工厂。但真正到了写业务代码的阶段,大多数人反而把它忘得最快,直到某天线上出现"连接数莫名其妙翻了三倍"或者"配置文件改完不生效",才会想起这个看起来最简单、实际最容易翻车的模式。这篇内容就围绕单例模式展开,从它到底解决什么问题讲起,把五种主流写法的取舍、并发安全性背后的内存模型原理、反射与序列化如何击穿单例、框架里的单例 bean 和 GoF 单例是不是一回事,全都拆开揉碎讲一遍。适合刚接触设计模式、想搞清"为什么这么写"的新手,也适合已经工作几年、被单例坑过但没系统整理过的老手。代码以 Java 为主,原理部分是语言无关的。
1. 单例模式到底在解决什么问题
1.1 一次重复建连引发的故障复盘
我印象很深的一次事故,来自一个做数据同步的服务。这个服务的逻辑很简单:启动时连上一个消息队列的客户端,然后长期订阅消息。当时负责的同事图省事,在每个需要发消息的地方都直接new了一个客户端实例。单机测试完全没问题,因为测试环境只有一条消息路径;上线之后不同模块同时触发发送,单机瞬间建立了两百多个长连接,对端连接数配额被打满,后续所有新连接全部被拒,整个同步链路静默失效——注意是静默,因为客户端在构造时并没有抛异常,只是把失败状态吞掉了。
这个问题排查了大半天,最后的修复方案非常朴素:把客户端收敛成一个全局唯一的实例,所有模块统一从一个入口取。改造完之后连接数从两百多降到 1,内存占用下降明显,GC 频率也跟着降了。
这就是单例模式最典型的应用场景:那些创建成本高、持有稀缺资源、需要全局协调状态的对象。具体来说通常包括连接池、线程池、配置中心客户端、缓存管理器、日志记录器、ID 生成器、注册表等等。它们的共同特征是——存在多个实例不仅浪费资源,还会直接导致逻辑错误。
反过来说,如果一个对象是无状态的、创建成本极低的、彼此之间不需要协调的,那给它套单例纯属自找麻烦。这一点后面还会展开讲。
1.2 三条硬约束:唯一、可控、可访问
抛开各种花哨的写法,单例模式的内核其实就三条约束,缺一不可:
- 实例唯一性:整个进程生命周期内,这个类只能有一个实例存在,任何途径都不能绕过。
- 构造可控性:外部不能随意
new,构造函数必须对外关闭。 - 访问全局性:必须提供一个稳定的、线程安全的获取入口。
这三条里,第三条最容易做,第一条最难保证。很多号称"单例"的实现,其实只做到了"通过getInstance()拿到的是同一个对象",但通过反射、序列化、克隆、甚至不同的类加载器,仍然能造出第二个实例。严格意义上说,这是不完整的单例。
我在评审代码时有个习惯,看到单例类会先问一句:"如果有人拿反射来造,你这个单例扛得住吗?"如果对方愣住,说明他只是背下了模板,没想过约束的边界在哪里。当然,业务代码里绝大多数情况不需要防反射,但你需要知道边界在哪,才能判断什么时候该防、什么时候可以偷懒。
1.3 它不适合被当成"全局变量"的遮羞布
单例模式被滥用的情况,比被正确使用的情况多得多。最常见的滥用方式是:把一堆不相干的状态塞进一个单例类里,美其名曰"全局上下文",实际上是给全局变量套了个面向对象的壳。
这种做法的危害是隐性的。因为单例的引用随处可见,模块之间的依赖关系变成了隐式依赖,你在 A 模块改了一个字段,B 模块的行为跟着变了,编译器不会报错,IDE 找不到引用链,单元测试也没法隔离——因为你没法给它注入一个 mock 实例。等到代码量上来,这个类会变成谁都不敢动的"上帝对象"。
我的判断标准很简单:如果这个单例持有可变状态,且这个状态会被多个模块交叉修改,那它就是设计问题,不是单例问题。真正健康的单例,要么是无状态的(比如工具类、策略注册表),要么是状态完全由自己内部管理、外部只能通过明确定义的接口访问(比如连接池)。
提示:单例不等于"全局可访问的变量容器"。如果一个类需要被到处读写的全局状态,优先考虑依赖注入加显式传参,而不是把它做成单例。
2. 五种写法的取舍:从饿汉到枚举
2.1 饿汉式:最简单也最容易被低估
饿汉式的写法几乎是所有教材的第一个例子:
public class ConfigHolder { private static final ConfigHolder INSTANCE = new ConfigHolder(); private ConfigHolder() { // 初始化逻辑 } public static ConfigHolder getInstance() { return INSTANCE; } }它的优点是天然线程安全。因为实例在类初始化阶段就创建好了,而 JVM 保证类的<clinit>方法在多线程环境下会被正确地加锁同步,只有第一个到达的线程会执行初始化,其他线程阻塞等待,且等待结束后能看到完整的初始化结果。这套机制由虚拟机保证,比手写任何锁都可靠。
缺点也很明确:无法延迟加载。只要这个类被主动引用(比如调用了它的其他静态方法,或者被Class.forName加载),实例就会被创建。如果构造过程很重——比如要读几 MB 的配置文件、建立数据库连接、扫描磁盘目录——那就会拖慢启动速度,甚至在某些场景下造成资源浪费(比如这个类在本次运行中根本用不上)。
我在实际项目里的选择是:如果初始化成本在几十毫秒以内,且这个类基本一定会被用到,直接用饿汉,不要犹豫。为了省那点内存和启动时间,去写一堆复杂的懒加载逻辑,收益远小于维护成本。我见过一个项目为了"优化启动性能"把配置类改成了双重检查锁定,结果引入了一个 volatile 相关的 bug,排查了两天,最后收益是启动快了 12 毫秒。
2.2 懒汉式与整方法加锁的代价
懒汉式的原始版本是这样的:
public class LazySingleton { private static LazySingleton instance; private LazySingleton() {} public static LazySingleton getInstance() { if (instance == null) { instance = new LazySingleton(); } return instance; } }单线程下没问题,多线程下一定会出事。两个线程同时通过instance == null判断,就会各自创建一个实例,其中一个会覆盖另一个,最终拿到的引用可能不一致。更糟的是,某些线程可能拿到一个部分构造完成的对象。
最直接的修复是在方法上加synchronized:
public static synchronized LazySingleton getInstance() { if (instance == null) { instance = new LazySingleton(); } return instance; }这样确实安全了,但代价是每一次读取都要抢锁。这个开销在小流量下可以忽略,但在高频调用的路径上就会变成瓶颈。我们可以粗略算一笔账:假设一次synchronized竞争的加解锁加线程上下文切换成本约 50 纳秒到 200 纳秒(不同 JVM、不同核数差异很大,这里取一个偏保守的中位数 100 纳秒),单线程 QPS 上限大约在 1000 万次/秒。听起来还行?但这是理想情况。一旦竞争激烈,线程会被挂起,涉及用户态到内核态切换,单次成本可能上升到微秒级,此时 QPS 上限直接掉到几十万,而且延迟分布的尾部会变得非常难看。
更关键的是,这个锁保护的是一个"只在第一次需要互斥"的操作。99.999% 的调用只是读一个已经初始化好的引用,却要付出加锁代价,这是典型的设计浪费。
2.3 双重检查锁定:volatile 不是装饰品
为了解决"只在初始化时加锁",就有了双重检查锁定(Double-Checked Locking,简称 DCL):
public class DclSingleton { private static volatile DclSingleton instance; private DclSingleton() {} public static DclSingleton getInstance() { if (instance == null) { // 第一次检查,无锁 synchronized (DclSingleton.class) { if (instance == null) { // 第二次检查,持锁 instance = new DclSingleton(); } } } return instance; } }这段代码里,volatile是绝对不能省的。原因在于new DclSingleton()这一行并不是原子操作,它至少包含三个步骤:
- 在堆上分配内存空间;
- 调用构造函数,对对象进行初始化;
- 把引用赋值给
instance变量。
在不影响单线程语义的前提下,编译器和处理器允许步骤 2 和步骤 3 重排序。如果发生重排,执行顺序变成 1 → 3 → 2,那么另一个线程可能在步骤 2 完成之前就通过了第一次检查,看到一个非 null 但内部字段全是默认值的"半成品"对象。这种 bug 极难复现,因为它依赖具体的 CPU 架构、JIT 编译结果和线程调度时机,可能压测一万次都不出现,但在生产环境偶发一次。
volatile在这里起到两个作用:禁止指令重排(通过插入内存屏障),以及保证可见性(写操作后立即刷新到主内存,读操作前从主内存重新加载)。加了volatile之后,步骤 3 不能被重排到步骤 2 之前,上面那个漏洞就被堵上了。
注意:在 JDK 5 之前,即使加了
volatile,DCL 依然是不安全的,因为旧版内存模型对volatile的语义定义不够严格。JDK 5 引入 JSR-133 修正了这个问题。所以如果你看到有人说"DCL 不能用",大概率是参考了很老的资料。
DCL 的优点是延迟加载加无锁读取,缺点是代码复杂度高,且极难通过代码审查发现错误。我在团队里推过一条规则:凡是手写 DCL 的地方,必须在代码注释里写明为什么用volatile,写不出来的不许合并。这条规则听起来有点苛刻,但确实拦下过几次漏写关键字的情况。
2.4 静态内部类:把线程安全交给类加载机制
如果你既想要延迟加载,又不想碰锁和volatile,静态内部类是最优解:
public class HolderSingleton { private HolderSingleton() {} private static class InstanceHolder { private static final HolderSingleton INSTANCE = new HolderSingleton(); } public static HolderSingleton getInstance() { return InstanceHolder.INSTANCE; } }这段代码为什么能兼顾延迟加载和线程安全?关键在于 JVM 的类加载时机。HolderSingleton类被加载时,内部类InstanceHolder并不会跟着被初始化,它要等到第一次被主动引用时才触发初始化。而类的初始化过程由 JVM 加锁保证,多个线程同时触发时,只有一个线程执行<clinit>,其余线程阻塞等待,且初始化完成后所有线程都能看到正确结果。
所以这个方案本质上是把并发控制的工作交给了虚拟机,代码层面干干净净,没有synchronized,也没有volatile,也不用担心重排序。这也是我在绝大多数业务场景下的默认选择——可读性好、不易写错、性能接近懒加载的极限。
唯一的"缺点"是它依赖类加载机制,如果有人在外部通过反射强制初始化内部类,理论上能提前触发创建。但这个场景在实践中几乎不会出现,不用为了防御它牺牲可读性。
2.5 枚举单例:为什么被反复推荐
枚举单例的写法最简:
public enum EnumSingleton { INSTANCE; private final AtomicLong counter = new AtomicLong(0); public long next() { return counter.incrementAndGet(); } }它之所以被经典著作反复推荐,原因有三个,而且每一个都很实在:
第一,天然防反射。Constructor.newInstance()方法对枚举类型有专门的判断,如果目标类是枚举,直接抛IllegalArgumentException: Cannot reflectively create enum objects。这是 JVM 层面的硬拦截,不是靠你自己写判断逻辑。
第二,天然防序列化破坏。普通类实现Serializable之后,反序列化会通过反射创建新对象,破坏单例。而枚举的序列化机制是特殊的,ObjectInputStream对枚举类型有专门处理,反序列化时直接按名字查找已有的枚举常量,不会新建实例。
第三,代码量最少。没有构造函数私有化、没有静态字段、没有getInstance(),编译器全帮你搞定了。
但枚举单例也有两个实际使用中的限制,很多文章不提:
- 它是饿汉式的。枚举常量的初始化发生在枚举类初始化时,无法延迟加载。如果你的单例初始化很重,枚举不合适。
- 它不能继承其他类。因为枚举已经隐式继承了
java.lang.Enum,Java 单继承的限制意味着你的单例没法再扩展某个基类。如果这个单例需要实现某个框架的接口,用implements是可以的,但如果是extends,就没戏了。
另外还有个使用习惯上的问题:枚举单例的获取方式不太符合直觉。别人看到EnumSingleton.INSTANCE可能第一反应是"这是个枚举",需要一点时间适应。这在团队协作中是个真实的成本,我会在类注释里明确写上"本类为单例,请通过 INSTANCE 获取"。
2.6 五种写法横向对比
把上面的分析汇总成一张表,选型的时候直接对照:
| 写法 | 延迟加载 | 线程安全 | 防反射 | 防序列化 | 代码复杂度 | 适用场景 |
|---|---|---|---|---|---|---|
| 饿汉式 | 否 | 是 | 否 | 否 | 极低 | 初始化轻、必被使用 |
| 懒汉式(方法加锁) | 是 | 是 | 否 | 否 | 低 | 并发极低、调用不频繁 |
| 双重检查锁定 | 是 | 是 | 否 | 否 | 高 | 对性能敏感且必须懒加载 |
| 静态内部类 | 是 | 是 | 否 | 否 | 低 | 绝大多数业务场景的首选 |
| 枚举 | 否 | 是 | 是 | 是 | 极低 | 需要绝对防破坏的场景 |
这张表里有个细节值得说:"防反射"和"防序列化"这两列,只有枚举是"是"。如果你在写的是一个会被外部攻击、或者会被序列化传输的东西(比如某些框架的扩展点、RPC 的服务实现),枚举的价值就体现出来了。如果只是内部用,静态内部类足够。
3. 手写一个能上生产的单例:完整实操
3.1 先定需求:一个配置中心客户端
假设我们要写一个配置中心的客户端,需求是这样:启动时从远端拉取全量配置,之后保持长连接接收变更推送;本地要提供get(key)的读取接口,读取必须是内存级的、无锁的、高并发的;配置更新时要通知所有注册的监听器。
这个场景为什么适合单例?因为长连接是稀缺资源,一个进程建立多条纯属浪费;而且配置必须全局一致,如果 A 模块读到的是旧配置、B 模块读到的是新配置,会出现难以解释的行为差异。
反过来,这个类有可变状态(配置内容在变),所以必须格外小心线程安全——单例只是保证实例唯一,不保证内部状态安全,这一点后面第 5 章还会专门讲。
我们先定几个约束:
- 全量配置用
ConcurrentHashMap存,读操作无锁; - 监听器列表用
CopyOnWriteArrayList,读多写少的典型场景; - 初始化放在第一次
getInstance()时做,因为不是所有模块都会用到配置; - 用静态内部类实现单例。
3.2 代码落地与逐行注释
import java.util.List; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; import java.util.function.Consumer; public class ConfigClient { // 全量配置,读多写少,用 ConcurrentHashMap 保证并发读写安全 private final Map<String, String> configMap = new ConcurrentHashMap<>(256); // 监听器列表,注册频率极低,用 COW 避免读遍历时加锁 private final List<Consumer<String>> listeners = new CopyOnWriteArrayList<>(); private volatile boolean connected = false; private ConfigClient() { // 构造函数保持轻量,真正的网络拉取放到 init() 里 // 这样即使构造过程被意外触发,也不会阻塞主线程 } private static class Holder { private static final ConfigClient INSTANCE = new ConfigClient(); } public static ConfigClient getInstance() { return Holder.INSTANCE; } /** * 显式初始化,由启动流程调用,避免在 getInstance 里做重活 */ public void init(String endpoint, int timeoutMillis) { if (connected) { return; } synchronized (this) { if (connected) { return; } // 这里做长连接建立和全量拉取 // 具体实现省略,重点看外层的并发控制 connected = true; } } public String get(String key) { return configMap.get(key); } public String getOrDefault(String key, String defaultValue) { String v = configMap.get(key); return v == null ? defaultValue : v; } public void addListener(Consumer<String> listener) { listeners.add(listener); } void onConfigChanged(String key, String newValue) { configMap.put(key, newValue); for (Consumer<String> l : listeners) { l.accept(key); } } }这段代码里有几个刻意的设计,值得单独解释:
第一,构造函数保持轻量。很多人在单例的构造函数里做网络请求、读文件、建连接池,这是很危险的做法。因为构造发生在类初始化阶段,如果它抛异常,JVM 会把该类标记为Erroneous,之后所有访问都会抛NoClassDefFoundError,而且这个状态是不可恢复的——你没法重试。把重活挪到显式的init()方法里,异常处理就完全在你的控制之下了。
第二,init()用了双重检查 +synchronized(this),但connected是volatile的。这里不加volatile也能"基本正确",因为synchronized本身保证了可见性。但第一次检查是在锁外做的,如果connected不是volatile,其他线程可能长时间看不到它变成true的变化,导致重复进入同步块。虽然进同步块之后第二次检查会拦住,功能上不算错,但会白白付出锁竞争的开销。
第三,get()完全没有加锁。因为ConcurrentHashMap的读操作本身是无锁的(JDK 8 之后基于 CAS 和volatile实现),配置更新通过put完成,读线程要么看到旧值要么看到新值,不会看到中间态。这是"用对数据结构"比"用对锁"更重要的典型例子。
3.3 并发验证:写个压测脚本跑一遍
写完代码不算完,得验证。我一般会写一个简单的并发测试,不开 JMH(太重),用CountDownLatch起一批线程同时打,跑个几百万次看看有没有异常、耗时多少。
import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; public class SingletonBenchmark { private static final int THREADS = 16; private static final int LOOPS_PER_THREAD = 500_000; public static void main(String[] args) throws Exception { CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(THREADS); AtomicInteger errorCount = new AtomicInteger(0); for (int i = 0; i < THREADS; i++) { new Thread(() -> { try { start.await(); for (int j = 0; j < LOOPS_PER_THREAD; j++) { ConfigClient c = ConfigClient.getInstance(); if (c == null) { errorCount.incrementAndGet(); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { done.countDown(); } }).start(); } long begin = System.nanoTime(); start.countDown(); done.await(); long costMs = (System.nanoTime() - begin) / 1_000_000; long totalOps = (long) THREADS * LOOPS_PER_THREAD; System.out.println("总调用次数: " + totalOps); System.out.println("耗时(ms): " + costMs); System.out.println("吞吐(万次/秒): " + (totalOps / 10_000.0 / (costMs / 1000.0))); System.out.println("异常数: " + errorCount.get()); } }在我这台开发机上(8 核、16G 内存、JDK 17),静态内部类的getInstance()在 16 线程下跑 800 万次,耗时大致在 40 到 60 毫秒之间,换算下来吞吐在 1.3 亿到 2 亿次/秒这个量级。这个数字已经接近"空方法调用"的极限,说明单例获取本身完全不是瓶颈。
做个对照实验:如果把getInstance()换成synchronized版本,同样的配置下耗时通常会涨到 300 毫秒以上,差距接近一个数量级。如果线程数继续加到 64,synchronized版本的耗时可能会涨到 2 秒以上,而静态内部类版本基本线性增长。这个对比就是"为什么要在高频路径上避免锁"最直观的证据。
提示:这个压测脚本只用来做相对比较,不要拿绝对数字当性能指标。真实环境的性能受 JIT 预热、CPU 频率调节、NUMA 架构影响很大。
跑完这个测试,基本可以确认单例获取本身没问题。接下来要验证的是状态访问的并发正确性,也就是多个线程同时读配置、同时接收更新时,会不会读到脏数据。这部分需要用CountDownLatch编排"读线程"和"写线程",检查每次读到的值是否都是某个完整写入的值。这段代码稍微长一点,思路就是经典的读写交错验证,这里不展开。
4. 破坏单例的四条路与封堵方案
4.1 反射攻击:构造函数私有化拦不住setAccessible
很多人以为把构造函数设成private就安全了,其实只要拿到Constructor对象并调用setAccessible(true),私有修饰符就形同虚设。攻击代码如下:
Constructor<HolderSingleton> ctor = HolderSingleton.class.getDeclaredConstructor(); ctor.setAccessible(true); HolderSingleton second = ctor.newInstance(); // second != HolderSingleton.getInstance()防御方式是在构造函数里加一道检查:
private HolderSingleton() { if (Holder.INSTANCE != null) { throw new IllegalStateException("单例已被创建,禁止重复实例化"); } }但这里有个坑:静态内部类的写法里,Holder.INSTANCE在第一次调用getInstance()之前是 null,所以如果攻击者在正常获取之前先用反射创建实例,这道防线拦不住。要彻底防住,得用一个静态标志位:
private static final AtomicBoolean CREATED = new AtomicBoolean(false); private HolderSingleton() { if (!CREATED.compareAndSet(false, true)) { throw new IllegalStateException("单例已被创建"); } }这样无论通过哪条路径,第二次创建都会被拒绝。代价是每个单例类都要维护一个额外的标志位,代码变啰嗦。我的实践建议是:内部业务代码不必防反射,因为反射攻击的前提是有人能在你的进程里执行任意代码,到那一步安全问题已经不是单例能解决的了。但如果这个类会被当作扩展点暴露给外部插件,那还是加上。
4.2 序列化与反序列化:最隐蔽的一条路
如果单例类实现了Serializable,那么反序列化一定会创建新对象,这是ObjectInputStream的默认行为——它会绕过构造函数,直接在堆上分配对象并填充字段。
防御方式是加一个readResolve()方法:
private Object readResolve() { return Holder.INSTANCE; }这个方法在反序列化完成后被调用,返回值会替代默认创建的对象,从而保证拿到的是同一个实例。注意方法签名必须是private Object readResolve(),不能是public,也不能带参数,否则不会被识别。这个细节很多人写错。
即便如此,序列化路径仍然有两个隐患:一是readResolve只能保证"返回的是单例",中间那个被创建出来的临时对象仍然存在,只是被丢弃了,如果这个类持有大对象会造成一次额外的内存峰值;二是如果单例类有子类,readResolve的行为会变得复杂。所以更彻底的方案还是枚举——枚举的序列化机制由 JVM 特殊处理,反序列化时按名字查找常量,压根不会创建新对象。
4.3 克隆与多类加载器:两个容易被忽略的角落
如果单例类实现了Cloneable接口并覆写了clone(),那么调用clone()就能得到一个副本,绕过构造函数。防御很简单:不要实现Cloneable,或者在clone()里直接抛CloneNotSupportedException。
多类加载器的问题更隐蔽。在同一个 JVM 里,如果同一个类被两个不同的类加载器分别加载,那它们就是两个完全不同的类,各自持有自己的静态字段,各自的单例互不相干。这在普通的 Web 应用里很少见,但在容器环境、插件化框架、热部署场景下是真实存在的。比如某些应用服务器会给每个部署单元分配独立的类加载器,如果单例类被打在了共享目录之外,就会出现"明明重启了应用,配置却没变"的怪现象——因为旧类加载器没被回收,旧单例还活着。
排查这类问题的技巧是:在getInstance()里打一行日志,把this.getClass().getClassLoader()的toString()打出来,如果看到多个不同的类加载器标识,基本就能确认了。
4.4 分布式环境下的"伪单例"
这是最容易被误解的一点:单例模式的"唯一"只在单个 JVM 进程内成立。如果你的服务部署了 8 个实例,那就有 8 个"单例"。
很多业务逻辑建立在"全局只有一份"的假设上,比如用单例维护一个递增的序列号、用单例做限流计数器,一旦上多实例就全乱了。序列号会重复,限流值会变成配置值的 8 倍。
解决思路有两条:一是把状态外移到共享存储(数据库、分布式缓存),单例退化成这个外部存储的客户端;二是用分布式协调组件做选主,只有主节点执行写操作。第一条更常用,代价是每次操作多一次网络往返。
我见过一个团队用单例做本地缓存,做了很复杂的过期策略,结果多实例部署后每个实例各自缓存一份,缓存一致性问题反而比不用缓存还严重。这个坑的本质不是单例的问题,而是把"进程内唯一"错当成了"全局唯一"。在设计阶段就该问一句:这个唯一性假设,跨进程还成立吗?
5. 框架与中间件里的单例:别把概念混为一谈
5.1 IoC 容器的单例 bean 与 GoF 单例的区别
这是面试里出现频率极高、但很多人答不到点上的问题。
GoF 单例强调的是类级别的实例唯一性,由类自己的静态字段持有实例,通过静态方法访问。而主流 IoC 容器里的"单例作用域",指的是容器管理的 bean 在容器内只有一个实例,它由容器负责创建和持有,类本身完全不知道自己是单例——你可以在代码里随便new它,容器管不着。
更关键的差异在于:容器的单例是按beanName或类型注册的,你可以给同一个类注册两个不同的 bean 定义,得到两个实例,而且容器允许你覆盖、允许你配置原型作用域。这在 GoF 单例里是不可能发生的。
所以在容器环境里,一般不需要手写单例。你把类交给容器管理,默认就是单例的,还能享受依赖注入、生命周期回调、AOP 代理这些能力。手写单例反而会破坏可测试性——你没法在测试里替换掉那个静态实例。
我现在的做法是:业务组件一律交给容器管理,只有在容器管理不到的角落才手写单例,比如:
- 必须在静态上下文中访问的组件(某些遗留框架的回调);
- 初始化极早、容器还没启动时就要用的组件(比如启动参数解析器);
- 第三方库要求传入实例、且库自己会缓存引用的场景。
5.2 连接池、线程池、缓存为什么天然适合单例
这三类对象几乎是单例的教科书案例,原因各有侧重。
连接池的核心价值是复用连接,如果每个调用方各自建一个池,就完全失去了池化的意义——不仅没省下连接,还多了好几套空闲连接的管理开销。更麻烦的是容量规划会失效:你按每实例 20 个连接估算数据库压力,结果因为重复建池实际用了 100 个,数据库连接数直接打满。
线程池除了资源复用的考虑,还有一个关键原因是集中管控。所有异步任务走同一个池,你才能统一设置队列长度、拒绝策略、监控指标。如果分散成多个池,出问题时你根本不知道任务卡在哪个池里。
本地缓存的动机是内存占用。多份缓存意味着同样的数据在堆里存了好几份,GC 压力翻倍,而且失效逻辑要维护多份,很容易出现"这份缓存更新了那份没更新"的问题。
顺带算一下连接池的容量:假设单次数据库查询平均耗时 5 毫秒,池大小设为 20,那么理论最大 QPS 是20 / 0.005 = 4000。如果你的业务峰值只有 800 QPS,那 4 个连接就够了,配 20 是浪费。反过来如果峰值到 3000,配 20 会在高峰期出现排队,平均等待时间会明显上升。池大小不是越大越好,它要和下游的承载能力匹配——你这边开 100 个连接,数据库那边可能只有 50 个 worker 线程,多出来的连接只是在排队而已。
5.3 单例不等于线程安全:一个高频误解
这是我最想强调的一点。单例模式解决的是实例数量问题,不解决并发访问问题。
一个单例对象会被多个线程同时访问,如果它有可变状态且没有正确的同步,照样会出并发 bug。举个小例子:
public class Counter { private static final Counter INSTANCE = new Counter(); private int count = 0; // 非原子,非可见 public static Counter getInstance() { return INSTANCE; } public void increment() { count++; } // 线程不安全! public int get() { return count; } }这个类是标准的单例,但count++在多线程下会丢更新,get()也可能读到过期值。想修好,要么把count换成AtomicInteger,要么加锁。
我见过太多人把"这个类是单例"当成"这个类不需要考虑并发"的理由,然后在压测时发现计数不准、缓存错乱。正确的思维顺序应该是:
- 这个类需要唯一实例吗?→ 需要 → 用单例;
- 这个类会被多线程访问吗?→ 会 → 逐字段检查可变状态,该用原子类用原子类,该加锁加锁,该用不可变对象就用不可变对象。
这两步是独立的,不能合并。理想情况下,单例类最好是不可变的——所有字段final,构造完成后不再修改。这样连同步都省了,天然线程安全。如果做不到不可变,至少要把可变状态收敛到少数几个经过验证的并发容器里。
6. 常见问题与排查技巧实录
6.1 问题速查表
| 现象 | 可能原因 | 排查手段 | 处理方式 |
|---|---|---|---|
| 拿到两个不同实例 | 并发初始化未加锁;反射/反序列化绕过 | 在构造函数打印堆栈 | 改用静态内部类或枚举;加防反射检查 |
| 单例字段值莫名变回初始值 | 多类加载器导致多份静态字段 | 打印getClassLoader() | 统一类加载路径,或把状态外移 |
| 首次调用卡顿几百毫秒 | 构造函数做了重活 | 用jstack抓线程栈 | 重活挪到显式init() |
| 配置改了不生效 | 单例缓存未刷新,或每个实例各缓存一份 | 查缓存刷新逻辑和部署实例数 | 加主动刷新接口;考虑配置中心推送 |
启动报NoClassDefFoundError | 静态初始化抛异常,类被标记为 Erroneous | 查首次加载时的异常日志 | 构造函数不抛异常,重活延后 |
| 单元测试之间互相污染 | 单例状态在测试间共享 | 测试执行顺序敏感即怀疑 | 提供 reset 方法供测试使用 |
| 内存泄漏,老年代持续增长 | 单例持有大对象且未释放;监听器未注销 | 用堆 dump 分析引用链 | 用弱引用;提供显式销毁方法 |
6.2 三个真实排查过程还原
第一个案例:配置不生效。某次发布后发现灰度开关怎么改都没反应,本地复现不了。排查路径是这样:先确认配置中心确实推送了(看服务端日志,推了),再确认客户端收到了(在onConfigChanged里打日志,没打),说明消息没到客户端。继续查,发现客户端的长连接在启动后不久就断了,重连逻辑里判断"已经是单例了,不用重建连接",但实际上连接对象已经失效。根因是单例持有的是连接对象,而连接对象本身有生命周期,单例只保证"引用唯一",不保证"引用指向的东西一直有效"。修复方案是把重连逻辑从"判断实例是否存在"改成"判断连接是否 alive",并且在连接断开时把引用置空触发重建。
第二个案例:两个实例。有个类在启动阶段被两个不同的线程同时触发初始化,一个是主线程的预热逻辑,一个是某个组件的懒加载。代码用的是懒汉式且没有加锁,结果真的创建了两个。诡异的是这个问题在测试环境完全复现不了,因为测试环境的组件加载是串行的。后来在构造函数里加了Thread.currentThread().getStackTrace()的打印,才定位到两条不同的调用链。这个案例的教训是:只要有任何并发可能,懒加载就必须做同步,不要赌"实际不会并发"。
第三个案例:内存泄漏。一个单例维护着监听器列表,业务方每次请求都注册一个监听器,但从不注销。跑了三天堆内存涨到 4G。用堆 dump 看引用链,发现所有监听器都被单例的ArrayList强引用着,且监听器内部又持有了请求上下文,等于每个请求的数据都没法回收。修复方案是把列表换成对监听器的弱引用,并在注册接口上加了文档说明"调用方负责注销"。这个案例说明:单例是长生命周期的,它持有的任何引用都会延长被引用对象的生命周期,这是内存泄漏的高发地带。
6.3 几个不写在文档里的避坑经验
第一条,给单例留一个销毁入口。听起来很反直觉——单例不是应该活到进程结束吗?但在测试场景、热重载场景、以及需要在运行时切换配置源的场景下,没有销毁入口会让你非常难受。我一般会加一个包级私有(default访问权限)的reset()方法,只供测试和内部框架调用,业务代码访问不到。
第二条,单例里不要用final static初始化依赖外部资源的对象。比如private static final Connection CONN = DriverManager.getConnection(url);,这一行如果抛异常,类初始化失败,之后整个类不可用,而且异常信息往往被吞掉,只看到一个NoClassDefFoundError,排查起来极其痛苦。正确做法是延迟到方法里初始化,把异常暴露出来。
第三条,给单例配监控指标。因为它是全局唯一的,它的状态最能反映系统健康状况。比如连接池单例应该暴露当前活跃连接数、等待队列长度、获取连接的平均耗时;配置客户端单例应该暴露最后更新时间、推送延迟。这些指标在出问题时是第一手线索,比翻日志快得多。
第四条,不要用单例做事务边界。有些代码会把事务管理器做成单例,然后在里面维护"当前事务"的状态,这在多线程下必然出问题。事务状态必须绑定到线程(用ThreadLocal)或显式传递,不能挂在单例上。
7. 选型建议:我在不同场景下的取舍
7.1 按场景选写法的决策清单
我给自己总结了一套判断流程,基本能覆盖 90% 的情况:
第一步问:这个类必须唯一吗?如果答案是否定的,别用单例。工具类用静态方法就够了,不需要实例;无状态的策略类可以每次新建,成本可以忽略;需要隔离状态的组件应该用实例,交给调用方管理生命周期。
第二步问:初始化成本高吗?如果在 50 毫秒以内,直接饿汉,或者交给容器管理。超过这个量级,考虑延迟加载。注意这里说的成本包括 IO 等待、内存分配、连接建立,不包括 CPU 计算——CPU 计算通常很快。
第三步问:需要防反射和序列化吗?如果这个类会被序列化传输,或者作为扩展点暴露给不可信代码,用枚举。否则静态内部类足够。
第四步问:有框架容器吗?如果有,优先交给容器,别手写。手写单例在容器环境里是负收益。
第五步问:需要跨进程唯一吗?如果需要,单例解决不了,得上分布式方案。
按这个流程走下来,大部分场景会收敛到两个答案:交给容器管理,或者静态内部类。枚举用的场景比想象中少,DCL 更是几乎没有必要——除非你在维护一段遗留代码,或者在做框架底层的极致优化。
7.2 什么时候应该主动放弃单例
有几种情况,我会明确建议不要用单例,哪怕它看起来很像单例场景:
需要注入不同配置的多实例场景。比如一个类要同时连两个不同的数据源,那它就不能是单例。这时候正确的做法是把状态参数化,创建两个实例,用不同的名字管理。
需要独立生命周期的场景。比如一个会话级的对象,每个用户一个,那它天然不该是单例。硬套单例会导致会话之间互相污染。
需要方便测试的场景。单例的静态访问方式让 mock 变得困难。虽然可以用反射改静态字段,但很脏。如果一个类需要被大量单元测试覆盖,优先用实例加依赖注入。
状态复杂且需要事务性更新的场景。单例上的多字段更新很难保证原子性,一旦出错很难回滚。这种场景更适合把状态放到数据库或专门的状态管理器里。
最后分享一个我在实践中形成的习惯:每写一个单例,就在类注释里写清楚三件事——为什么它是单例、它的初始化时机是什么、它持有哪些需要外部注意的资源。这三行注释看起来不起眼,但在半年后别人接手、或者自己回头排查问题时,能省下大量时间。我见过太多单例类的注释只有一句"单例模式",等于什么都没说。
还有一点值得提:现在很多团队在用静态分析工具做代码规范检查,可以配一条规则——检测到synchronized修饰的static方法时给出警告,提示"考虑是否可用静态内部类替代"。这条规则在我们团队拦下过好几次整方法加锁的懒汉式写法。工具能做的事,就别靠人记。