1. 一个偶发故障,逼我重新理解单例
先讲个真事。前几年我在做一个支付回调对账服务,代码里有个ConfigCache类,负责加载渠道参数、灰度开关和签名密钥。当时一个同事图省事,在业务代码里用了new ConfigCache()去拿配置,另一个模块用的是ConfigCache.getInstance(),两个对象各自持有不同的内存快照。结果灰度放量的时候,一部分请求走了新逻辑,一部分请求还是老逻辑,线上对账差了几分钱,排查了整整一个下午。
这个故障本质上不是配置加载慢,也不是缓存失效,而是单例没有被严格保证——同一个类在进程里被实例化了多次,状态被切碎了。从那天起,我就把"单例模式"四个字从教科书里拎了出来,重新审视它到底在解决什么问题、该怎么落地、有哪些藏在细节里的坑。
单例模式要解决的核心问题其实就两个:第一,确保一个类在进程生命周期内只有一个实例;第二,提供一个全局访问点,让任何地方都能拿到这个实例,而不必关心它是怎么创建、怎么初始化的。
听起来很简单?实际动手写的时候,你会发现"唯一实例"这四个字在Java、C++、Python、Go这些语言里,踩坑的方式完全不一样。尤其是Java,因为JMM(Java内存模型)、反射、序列化、类加载器这些机制的存在,一个看似完美的单例,可能在并发、反序列化、多ClassLoader场景下瞬间碎掉。
这篇东西我不会去复述《设计模式》那本书的定义,而是把我在真实项目里用过的五种实现方式逐一拆开,讲清楚它们的取舍、隐患和适用场景,再聊聊框架时代里"单例"这个词的语义变化。不管你是刚接触设计模式的新人,还是已经在生产环境里写过几十个单例的老手,这篇文章里应该都有一些能直接拿去用的判断依据。
2. 为什么非要"唯一实例":从资源和状态两个维度看问题
2.1 资源维度:重复创建的成本远超你的想象
先看一个最朴素的需求场景:数据库连接池。假设你的应用每次要操作数据库,都new一个ConnectionPool,那意味着每次都要重新创建TCP连接、做认证握手、可能还要初始化连接池里的最小连接数。在高并发下,这不仅仅是性能问题,而是直接把数据库连接数打满,导致连接超时、应用雪崩。
我见过一个真实案例:有个报表系统,每次查询都 new 一个JedisPool(Redis连接池),压测到500 QPS的时候,Redis服务端连接的file descriptor直接耗尽,Redis挂了。后来改成单例,连接复用,同样500 QPS下连接数稳定在几十个。这就是资源维度上单例的核心价值——昂贵资源的创建和销毁,整个进程只需做一次。
类似的资源还有:
- 线程池(
ThreadPoolExecutor),每次 new 一个等于每次重建线程,线程创建开销极重,而且线程一旦多了,上下文切换成本会吃掉所有收益; - 配置中心客户端,比如 Apollo 的
ConfigService,它背后有长轮询和本地缓存,如果每处使用都 new 一个,等于每个调用点都建立一条长连接; - HTTP 连接池、Kafka Producer、Elasticsearch Client,这些客户端类统统是重量级对象,官方文档都明确要求复用同一个实例。
2.2 状态维度:同一份数据必须全进程可见
比资源更隐蔽的是状态一致性。配置、开关、计数器、缓存,这些对象本身携带可变状态,如果存在多个实例,就会出现我开头说的那个故障——不同模块看到的状态不一样。
一个经典例子是灰度开关。运营在后台切了某个开关,单例对象收到通知后更新内存里的布尔值,所有请求都应该立刻看到最新值。但如果存在两个实例,只有被通知到的那一个实例更新了,另一个实例还是旧值,这时候线上行为就分裂了。
再比如ID生成器。如果IdGenerator每次被 new 一个,都在内存里维护自己的段号,那生成的ID就可能出现重复或乱序。单例保证了分配逻辑的串行性和全局可见性。
所以说,单例模式不是"为了用而用"的技巧,它是在有状态共享和资源复用的场景下,保证正确性和性能的必要手段。你要判断一个类是否适合做单例,先问两个问题:这个类的实例是否持有需要跨模块共享的状态?创建这个实例的代价是否高到不能重复做?
如果两个问题的答案都是"是",那单例是合理选择。如果答案都是"否",那这个类做成单例,反而要承担单例带来的耦合和测试负担。
3. 五种经典实现的取舍:从懒汉到枚举,每一行都有讲究
3.1 饿汉式:最简单的方案,但启动时就要付出代价
饿汉式是最直观的写法:类加载时就初始化实例,JVM 保证了类加载过程中的线程安全,所以连synchronized都不用加。
public class ConfigCache { private static final ConfigCache INSTANCE = new ConfigCache(); private ConfigCache() { // 加载配置、初始化连接池等 } public static ConfigCache getInstance() { return INSTANCE; } }优点很明显:实现简单,线程安全由类加载机制天然保证。缺点也很明显:不管你后面用不用它,类一加载,实例就创建了。如果这个类的构造器里有重量级初始化(比如加载远程配置、建立数据库连接),而应用其实很晚才用到它,那这部分开销就白白提前支付了。
另外一个容易被忽略的点:INSTANCE是在ConfigCache类被主动使用的时候才初始化的,并不是JVM一启动就初始化。很多初学者以为饿汉式是"进程启动即创建",其实不是,它是在类首次被主动引用时触发初始化。不过如果这个类被其他静态方法引用,那实例的创建时机就不可控了。
适用场景:实例的构造开销不大,或者你的应用本来就会在启动早期用到它。反过来,如果构造开销很大,而且使用时机不确定,饿汉式会让启动时间莫名其妙地变长。
3.2 懒汉式synchronized:安全但没用,性能豆腐渣
懒汉式的初衷是"用到再创建",按需加载。最原始的实现是在getInstance()方法上直接加synchronized:
public class ConfigCache { private static ConfigCache instance; private ConfigCache() {} public static synchronized ConfigCache getInstance() { if (instance == null) { instance = new ConfigCache(); } return instance; } }这个写法在并发上是安全的,因为方法级别的锁把并发访问串行化了。但问题在于,绝大多数调用都只是读已创建好的实例,根本不需要抢锁。高并发场景下,所有线程都在这个方法上排队,锁竞争成了性能瓶颈。而且synchronized带来的阻塞,在处理大量并发请求时会让吞吐量掉得非常明显。
我在压测里实测过:一个每秒几千次调用的单例访问点,用方法级synchronized,吞吐比无锁版本低一个数量级。这种写法在面试里能过关,但上线就是给自己埋雷。
3.3 双重检查锁(DCL):必须配volatile,一个字节都不能省
双重检查锁(Double-Checked Locking)的思路是:先判断一次实例是否为null,不是null就直接返回,不进锁;是null才进同步块,进块后再判断一次,防止多个线程同时通过第一次判断后重复创建。
public class ConfigCache { private static volatile ConfigCache instance; private ConfigCache() {} public static ConfigCache getInstance() { if (instance == null) { synchronized (ConfigCache.class) { if (instance == null) { instance = new ConfigCache(); } } } return instance; } }这里最关键的,就是instance必须声明为volatile。很多人读到这一步会问:不是已经有synchronized了吗?为什么还需要volatile?
原因藏在JVM的指令重排里。instance = new ConfigCache()这行代码在字节码层面不是原子的,它大致分三步:
- 分配内存空间;
- 调用构造器初始化对象;
- 把对象引用赋值给
instance。
问题在于第二步和第三步可能被JIT编译器重排。如果线程A先执行了第3步(引用已经赋值),但第2步还没执行完(对象还没完全构造好),此时线程B通过第一次if (instance == null)检查发现instance不为null,就直接返回了。但返回的是一个尚未构造完成的对象,线程B去访问它的字段时,读到的可能是默认值(null、0),而不是构造器里赋的真实值。
volatile的作用就是禁止这种重排:它保证了对instance的写操作发生在对象完全初始化之后,同时保证线程B读到instance时,能看到线程A写入的完整结果。换句话说,volatile在这里提供了可见性和禁止指令重排双重保障。
可以用一个生活类比理解:你把快递柜的钥匙插进锁孔(引用赋值),但如果门还没完全打开(对象初始化),另一个人看到钥匙插进去了就以为可以取件,伸手进去拿到的可能是个空柜子。volatile相当于强制规定:必须等门完全打开,钥匙才能插进去。
DCL是我在实际项目中用得最多的一种方式,性能好(大部分调用无锁),又兼顾了懒加载。代价是代码稍微复杂一点,而且容易在后续维护中被人不小心删掉volatile。
3.4 静态内部类:懒加载和线程安全兼得,没有锁
静态内部类方案是我比较推荐的一种,因为它同时实现了懒加载、线程安全、无锁,代码也简洁:
public class ConfigCache { private ConfigCache() {} private static class Holder { private static final ConfigCache INSTANCE = new ConfigCache(); } public static ConfigCache getInstance() { return Holder.INSTANCE; } }原理是:Holder是ConfigCache的静态内部类,只有显式调用Holder.INSTANCE时,Holder才会被加载和初始化。JVM在类初始化阶段会加锁,确保INSTANCE只被创建一次。这本质上用的是"类加载机制"来实现线程安全,和饿汉式类似,但把初始化时机从"外部类被加载"推迟到了"首次调用getInstance()"。
它在性能和安全性上比DCL更省心,因为没有volatile依赖,不需要理解重排语义。如果你的项目没有历史包袱,我建议默认用这种写法。
3.5 枚举单例:抵御反射和序列化攻击的终极武器
很多人不知道,enum在JVM层面就是天然的单例。Joshua Bloch 在《Effective Java》里大力推荐这种方式:
public enum ConfigCache { INSTANCE; private ConfigCache() { // 初始化 } public void reload() { // 业务方法 } }调用方式ConfigCache.INSTANCE,简洁到没朋友。
更重要的是,枚举单例在安全性上碾压其他所有实现。前面几种写法都有一个致命弱点:反射攻击。Constructor.setAccessible(true)可以强制调用私有构造器,从而创建第二个实例;序列化反序列化也会创建新实例。枚举没有构造器暴露出的私有构造调用通道?并不完全是,但JVM对枚举做了特殊保护:反射调用枚举构造器会直接抛IllegalArgumentException,反序列化枚举时用的是Enum.valueOf,返回的是已存在的枚举常量,不会创建新对象。
我在安全测试里验证过这个行为:对一个枚举单例执行constructor.setAccessible(true); constructor.newInstance(),JVM直接抛异常,压根不给创建的机会。
所有实现方式的横向对比,可以这样看:
| 实现方式 | 懒加载 | 线程安全 | 防反射攻击 | 防序列化攻击 | 代码复杂度 |
|---|---|---|---|---|---|
| 饿汉式 | 否 | 是 | 否 | 否 | 低 |
| 懒汉synchronized | 是 | 是 | 否 | 否 | 低 |
| DCL+volatile | 是 | 是 | 否 | 否 | 中 |
| 静态内部类 | 是 | 是 | 否 | 否 | 中 |
| 枚举 | 否 | 是 | 是 | 是 | 最低 |
注意,前四种实现里,如果遇到序列化和反射攻击,都不是真正"不可破"的单例。只有在绝对需要防御这两类攻击的场景(比如写SDK、写基础组件),我才会选择枚举方案。
4. 单例的暗面:反射、序列化、类加载器如何击碎"唯一性"
4.1 反射机制:私有构造器挡不住setAccessible
前面提到的反射攻击,具体路径是这样的:
Class<?> clazz = Class.forName("com.example.ConfigCache"); Constructor<?> constructor = clazz.getDeclaredConstructor(); constructor.setAccessible(true); ConfigCache anotherInstance = (ConfigCache) constructor.newInstance();这段代码执行完后,你就拥有了两个ConfigCache实例。getInstance()返回的是第一个,但反射创建的是第二个,两个实例的字段互不相干。如果这个类持有关键配置,校验逻辑就会失效。
要防御这种攻击,可以在私有构造器里加一个"第二实例禁止"的检查:
private ConfigCache() { if (instance != null) { throw new IllegalStateException("Already initialized"); } // 初始化 }原理是:第一次走构造器时,instance还是null,允许创建;反射第二次调用构造器时,instance已经不是null,直接抛异常。
但注意,这个防御在DCL里有一个微妙的问题:如果你在构造器里检查instance != null,而instance是static变量,那么当第一次创建实例时,instance还没有被赋值(赋值发生在构造器返回之后),检查可以正常通过。第二次反射调用时,由于static变量已经被赋值为第一个实例,检查就能拦截。这个逻辑成立。但当构造器内部触发了其他线程调用getInstance()时,情况会变得复杂,需要你把检查逻辑放在构造器同步块内。否则可能出现递归调用导致栈溢出。稳妥起见,如果项目里没有严格的防反射需求,不必加这层检查,毕竟它增加了构造器的复杂性。
4.2 序列化反序列化:readObject悄悄创建新对象
如果一个单例类实现了Serializable,那么反序列化时,JVM会通过readObject()创建一个新的实例,而不是返回已有的单例。这意味着,即使你在运行时保证了单例,一旦对象被序列化后反序列化,就出现了第二个实例。
解决方法是提供readResolve()方法:
protected Object readResolve() { return getInstance(); }readResolve()的作用是:反序列化时,JVM在创建新对象之后、返回给调用方之前,会调用readResolve(),把返回值替换为方法返回的对象。也就是说,新创建的那个对象会被丢弃,GC回收,最终拿到的是原单例。即使枚举方案天然免疫这个问题,在实现序列化时,readResolve()也是标准写法。
4.3 多类加载器:你以为的"全局唯一"只在当前ClassLoader内成立
这是最隐蔽的一个坑。Java 的类加载机制决定了:同一个类名,被不同的ClassLoader加载,就是两个不同的类。它们的静态变量互不相通。
在普通应用里,业务代码和第三方库通常由同一个AppClassLoader加载,所以单例是全局的。但在以下场景里就会碎:
- 应用服务器采用"每个应用一个ClassLoader"的隔离策略(比如Tomcat);
- 你写了动态插件系统,用自定义ClassLoader加载插件Jar;
- OSGi 环境。
这时候,每个ClassLoader里都会初始化一份INSTANCE,所谓的"全局唯一"就在进程范围内失效了。这不是代码能解决的,是类加载器隔离带来的天然属性。遇到这种场景,你要么接受"单例只是ClassLoader作用域内单例"这个事实,要么把共享状态外移到真正全局的存储里(比如JNDI、系统属性、外部缓存)。
4.4 除了"创建",还要小心单例持有的状态被意外修改
最后说一个实战里更常见的坑:单例保证了实例唯一,但它内部的字段如果是可变的,并且没有做并发控制,照样会出问题。
一个只读配置类,字段都是final,那单例天然线程安全;但如果单例内部有一个HashMap做缓存,多线程同时读写,就需要加锁或改用ConcurrentHashMap。很多人写单例时只管住"创建",忘了"使用",结果创建是唯一了,内部数据在并发下还是乱了套。我的习惯是:单例内部的共享状态,能弄成不可变的就弄成不可变的,不能弄成不可变的就显式声明并发控制策略,别让调用方去猜。
5. 框架时代的"单例":Spring默认单例背后的语义变化
5.1 Spring的Bean容器不是设计模式意义上的单例模式
到了Spring时代,"单例"这个词的语境变了。Spring 默认的Bean作用域就是singleton,但这个"单例"和设计模式里的"单例模式"不是一回事。
设计模式的单例模式,核心是类自己控制实例化过程,用getInstance()类方法获取实例,构造器私有。Spring的单例Bean,是容器管理生命周期:Bean定义注册到容器后,容器负责实例化、注入依赖、管理销毁;调用方通过@Autowired或context.getBean()获取,拿到的都是同一个对象。你的Bean类根本不需要私有构造器,也不需要写getInstance()。
这意味着,在Spring应用里,你通常不"需要"手写单例模式——因为Spring容器已经保证了使用场景下的唯一性。我在项目里见过有人既用Spring又手写静态单例,结果绕过了Spring的代理和初始化管理,导致AOP失效、依赖没注入。这是双重管理的典型反模式。
5.2 进程内单例不等于分布式全局单例
另一个语义变化在分布式架构里体现得很明显。微服务部署多实例,每个进程内存里都有一个"单例",但它们之间各自独立。比如你用单例维护一个本地缓存,进程A更新了缓存,进程B的缓存还是旧的。这时候别说"单例保证全局唯一",它连进程间的数据一致性都保证不了。
所以在分布式系统里,需要共享的状态不要放在单例里,而要放在中间件里:Redis、ZooKeeper、数据库。单例模式的应用范围天然被限制在单个进程内。这个边界想清楚,可以省掉很多无谓的争吵。
另外要注意,ThreadLocal和单例常常被一起提。ThreadLocal本身不是单例,它是"每个线程一份副本"。单例提供全局共享,ThreadLocal提供线程隔离,两者经常配合使用:单例提供全局访问点,ThreadLocal提供线程上下文。比如一个单例的TraceIdHolder,内部用ThreadLocal存当前请求的traceId,既保证工具类的全局访问性,又避免不同请求之间的数据串扰。
6. 什么时候别用单例:反模式的边界案例
6.1 无状态工具类不需要单例
一个类里全是静态方法、没有任何实例字段,它本身就是无状态的,用不用单例都一样。比如StringUtils、MathUtils这种工具类,把它们设置成单例是没有意义的——反正实例里不存任何数据,创建一百个和创建一个没有区别。
反而,如果这种工具类被写成单例。还可能会有个隐藏问题:它占了一个不需要占的对象,而且一旦将来有人给这个类加了个非静态的可变字段,单例模式下这个字段就成了全局共享变量,莫名其妙地把一个无状态工具类变成了有状态。这是给未来的维护者埋雷。相比之下,StringUtils的源码就选择了私有构造器加静态方法,避免被实例化。
6.2 测试场景里的单例噩梦
单例最大的实践问题是可测试性差。因为实例是全局唯一的,测试用例之间无法隔离状态。一个测试改了单例里的某个字段,另一个测试跑的时候,读到的就是被污染的值。你要mock掉一个单例,还得引入Mockito的静态mock能力,或者给单例开一个测试专用的重置方法,非常别扭。
所以我在写业务代码时,如果这个类将来很可能需要被测试,我会倾向于用依赖注入来管理它的生命周期,而不是手写单例。Spring的@Bean默认就是单例,但Bean可以被mock、被替换,测试友好得多。
6.3 单例造出了隐性的全局耦合
还有一个不太好察觉的问题:单例是一个隐式的全局依赖点。调用方ConfigCache.getInstance().getXxx()会直接和这个具体类绑定,代码里散布着一堆对全局状态的访问。随着项目演进,你就会发现很难理清"谁改了谁的状态"。
我的经验是:跨模块共享的配置用单例可以,但业务逻辑里的状态对象,能通过构造器传参或依赖注入就不要用单例。依赖关系越显式,代码越容易理解。
6.4 需要按上下文区分的对象不要强行单例
有些人的第一反应是"这个类创建代价高,做单例吧",但实际上它的实例是需要按上下文区分的。例如一个多数据源的应用,每个数据源对应一个连接池,你强行做成单例,就等于把多个数据源限制成一个了。这种场景的正确做法是"池化"或"多例管理",而不是单例。
判断标准还是回到最初的两个问题:是否需要全局共享同一份状态?实例能否在任意上下文里复用?如果答案是"因上下文而异",那单例就不适用。
7. 根据我个人经验,最终落地时我会这样选
写了这么多年代码,现在让我选单例方案,我有一套自己的判断顺序:
第一,如果是Spring项目,优先用Spring容器管理Bean的单例,不写手写单例。原因很简单:容器帮你管理生命周期,还天然支持AOP、代理、配置注入。手写单例反而绕过了容器,很容易出幺蛾子。
第二,如果是不依赖框架的纯Java模块,需要懒加载,我默认选静态内部类方案。它代码最少,线程安全,没有volatile的心智负担。只有当这个类可能被反序列化时,我才会补上一个readResolve()。
第三,如果这个单例会被外部系统反射实例化(比如作为SDK暴露给别的团队),或者要反复经过序列化传输,我直接写枚举单例。虽然它牺牲了懒加载,但安全性无与伦比。
第四,如果单例的创建开销极大,而且启动时就必须要用(比如网关服务的路由表),我选饿汉式——让启动阶段把该付的代价付掉,避免运行时第一笔请求被初始化打满。
最后,无论选哪种方案,我会在代码注释里写清楚"这个类为什么必须是单例"。不是因为格式规范,而是因为三个月后的维护者可能会问自己"这里能 new 吗",然后顺手把它改成了非单例,直接捅出我开头说的那个线上事故。设计模式的价值不在于背会了几种写法,而在于你清楚地知道每个选择背后的代价和边界。这个判断力,才是真正能带走的经验。