在设计模式里,单例模式绝对是最“看着简单、写起来全是坑”的一个。不管是搜“Java 设计模式”“C# 单例模式”,还是看“C++ 设计模式 全23种”的清单,单例基本都会在很靠前的位置出现;但面试里问崩的人也多,实际项目里看到写坏的代码也不少。很多人以为单例就是“全局只有一个实例”,于是给每个管理器、工具类都套上一个 getInstance;可线程安全怎么做、反射和序列化会不会破坏唯一性、Android 里会不会导致内存泄漏,这些才是真正要命的地方。这篇就把单例模式从“能跑”讲到“能上生产”,顺手拆一下我这些年踩过的坑。
1. 单例解决的是“对象身份”问题:你抢到的是同一份状态
1.1 单例的本质:用共享状态替代全局变量
单例模式之所以被归类为创建型模式,是因为它管住了一件事:对象创建的方式和数量。普通类你想 new 几个就 new 几个,单例类则通过私有构造器、静态访问点,让整个进程里只有一个实例。
但要注意,把“只有一个实例”当成目标,很容易跑偏。单例真正想解决的,是多个模块之间必须共享同一份状态或同一份资源句柄的问题。
比如一个全局配置类。模块 A 在启动时读了一次配置文件,模块 B 又读了一次,如果它们拿到的不是同一份对象,B 修改了配置,A 可能完全不知情;再比如线程池、数据库连接池、缓存管理器,如果每次 getInstance 都返回全新对象,那池子就失去意义了——因为你永远无法复用一个真正统一的资源。
所以单例的底层逻辑,其实是“对象身份一致”。当我说 getInstance() 时,不管谁来调用,拿到的必须是同一个内存地址。
提示:在多线程环境下,这个“同一个”是有代价的。若不处理并发,两个线程同时第一次调用 getInstance,就可能各自 new 出两个对象。这也是后面各种线程安全写法出现的原因。
1.2 什么时候不该用单例:工具类与参数上下文
我在代码评审里经常看到这种写法:把一个日期格式化工具、字符串判空工具做成单例。其实很多纯函数式工具类里没有状态,实例本身也不需要被共享,那不如把方法写成 static,做成静态工具类更干净。
反过来,有些对象虽然你希望全局唯一,但它需要依赖启动参数或上下文来初始化。比如你的 App 启动后才能拿到用户 ID、设备信息、网络配置,如果强行把构造器私有化,并在静态字段里提前 new,那你会发现初始化时机完全不受控,很多字段是空的。这种时候,真正该考虑的是“延迟初始化”或“依赖注入容器”,而不是硬套单例。
另外,单例不适合承载请求级上下文。例如在 Web 服务里,用户会话、请求 ID、租户信息本身是每个请求不同的,把它们放进全局单例,就会出现请求 A 的数据污染请求 B 的严重事故。这类数据应该通过参数、ThreadLocal 或依赖注入来传递,绝不能用单例保存。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. Java 五种经典实现逐项拆解:线程安全不是可选项
2.1 饿汉式:靠类加载保证安全,但可能过早创建
最简单的单例是饿汉式:
java复制public class Config {
private static final Config INSTANCE = new Config();
private Config() {
// 初始化外部配置、加载资源等
}
public static Config getInstance() {
return INSTANCE;
}
}
优点很明显:static final 字段在类加载阶段完成初始化,由 JVM 保证只执行一次,所以天生线程安全,代码简单,没有同步开销。
缺点是类一加载就创建,不管你后面用不用它。如果构造过程中要连接数据库、拉取远程配置,那应用启动时会白白承担这部分开销;或者构造依赖了外部容器还没准备好的组件,就会出现较早初始化失败的问题。
对纯内存型配置对象,饿汉式其实完全够用;但一旦对象有重初始化成本和较强依赖,就要再往下看。
2.2 懒汉式与同步方法:经典的性能教训
懒汉式把创建推迟到第一次调用:
java复制public class Config {
private static Config instance;
private Config() {}
public static synchronized Config getInstance() {
if (instance == null) {
instance = new Config();
}
return instance;
}
}
synchronized 保证了线程安全,却带来了一个隐患:每一次 getInstance 都要经过同步方法。单例的调用频率往往非常高,如果这里还有锁竞争,那性能会肉眼可见地下降。
刚开始接触并发时,很多人觉得反正锁一次也没多大事,直到压测后发现 cache 服务的高频调用都堵在这一行上,才明白把“创建”和“读取”锁在一起确实很笨。正确思路是:创建需要互斥,但创建完成后的读取应该完全无锁。
2.3 双重检查锁与静态内部类:性能和安全的平衡
双重检查锁,英文 Double-Checked Locking,思路是:先不加锁检查一次,为 null 才进入同步块;进同步块后再检查一次,防止多个线程同时通过第一次检查。
java复制public class Config {
private static volatile Config instance;
private Config() {}
public static Config getInstance() {
if (instance == null) { // 第一次检查:无锁快速路径
synchronized (Config.class) {
if (instance == null) { // 第二次检查:真正加锁创建
instance = new Config();
}
}
}
return instance;
}
}
这里 volatile 绝对不能省,原因后文会专门展开。加了 volatile 之后,读取路径上绝大多数情况是无锁的,只有在单例尚未创建时才会短暂进入同步块,兼顾了性能与安全。
如果你觉得双检锁代码太长,还有一个平时更推荐的写法:静态内部类。
java复制public class Config {
private Config() {}
private static class Holder {
private static final Config INSTANCE = new Config();
}
public static Config getInstance() {
return Holder.INSTANCE;
}
}
它利用 JVM 类初始化机制:外部类加载时不初始化内部类,只有第一次调用 getInstance() 并访问 Holder.INSTANCE 时,Holder 才会被 JVM 初始化。类初始化过程由 JVM 保证线程安全,所以既做到了延迟加载,又不需要显式加锁。
2.4 枚举单例:为什么《Effective Java》推荐它
在 Java 里躲开反射和序列化破坏的最好实现,是枚举:
java复制public enum Config {
INSTANCE;
private String serverUrl;
public void configure(String url) {
this.serverUrl = url;
}
public String getServerUrl() {
return serverUrl;
}
}
乍一看很奇怪,但仔细想就通了:枚举常量在 JVM 里天然只有一份;枚举构造器是私有的;反序列化机制对枚举也做了特殊处理,不会像普通类那样重建一个对象;反射要 newInstance 时也会被 Java 拦截,不会允许绕过枚举的限制创建新实例。
所以单纯从“Java 语言层面把单例写死”来说,枚举几乎是最稳的。不过它在实际代码里看起来不够像“传统单例”,接受度有高有低;而且在 Android 启动性能和代码风格上,也不是每个团队都愿意用。我的观点是:能用枚举就用枚举,团队风格不允许时,优先静态内部类或双检锁。
2.5 C# 与 C++ 里的常见等价写法
C# 里最省心的方式是 Lazy<T>:
csharp复制public sealed class Config
{
private static readonly Lazy<Config> _lazy =
new Lazy<Config>(() => new Config());
public static Config Instance => _lazy.Value;
private Config() { }
}
Lazy<T> 默认线程安全,还能指定初始化模式,基本把 Java 双检锁和静态内部类想解决的问题都封装好了。
C++11 之后的“魔法静态变量”也是我比较常用的:
cpp复制class Config {
public:
static Config& instance() {
static Config inst;
return inst;
}
Config(const Config&) = delete;
Config& operator=(const Config&) = delete;
private:
Config() = default;
};
C++ 的局部静态对象在第一次进入函数时初始化,并且标准要求这个初始化过程线程安全。只要编译器支持 C++11,这种写法就比手写互斥锁干净得多。但别忘记删除拷贝构造和赋值运算符,否则“唯一实例”只是个口号,一不小心就能复制出去。
3. volatile 在双重检查锁中的位置:一个新对象的发布过程
3.1 new Singleton() 拆开之后有三步
很多人以为 instance = new Config() 是原子操作,错就错在这。它至少包含三步:
- 在堆上分配内存,得到一个空的对象区域;
- 调用构造器,初始化字段;
- 把对象引用赋值给 instance 变量。
如果你没有额外手段保证顺序,第二步和第三步之间是有可能发生指令重排序的。也就是说,一个线程可能在构造器还没有执行完时,就把“半成品对象”的引用写到了 instance 变量上。
如果所有线程都老老实实先拿锁再读 instance,那不会出问题。坏就坏在双检锁的设计里,读取操作很多时候是无锁的:第一个线程正在创建单例,另一个线程通过 if (instance == null) 发现 instance 已经不为 null,于是直接使用这个半成品,结果就是访问到未初始化的字段,轻则拿到默认值,重则直接抛空指针。
3.2 重排序导致“半成品单例”
我在一次故障里见过类似现象:服务启动时有个配置管理器使用双检锁,但当时漏写了 volatile。上线后并不是每次都会出问题,只在流量高峰、多个线程同时第一次初始化时偶尔出现;部分请求拿到的 manager 字段全是 null,进而导致路由配置异常。
这类问题最讨厌的地方是难以稳定复现。它依赖具体的 JIT 编译策略、CPU 架构和线程调度时机,可能压测几轮都正常,某一个瞬间爆发。排查到最后,往往只能靠对内存模型的理解来定位。
volatile 在这里的作用有两层:
- 禁止相关指令重排序,保证引用赋值不会跑到构造完成之前;
- 建立 happens-before 关系,让其他线程在读取 volatile 变量时,能看到写入线程在写入之前完成的所有可见字段更新。
简单说,volatile 让“对象初始化”和“对象发布”成为一个对外可见的完整过程,外部线程不可能再拿到被提前发布的半成品。
3.3 静态内部类如何绕开这个问题
静态内部类之所以不需要 volatile,是因为它根本没有把“new 对象”放在 getInstance 方法里。第一次真正访问 Holder.INSTANCE 时,JVM 会触发 Holder 的类初始化,而这个初始化过程由 JVM 的锁机制保证:只会执行一次,并且所有线程读取到的都是完整初始化后的常量。
这背后其实是“类加载阶段替你解决了并发问题”。与其在方法里小心翼翼控制重排序,不如把实例创建交给 JVM 更省心。这也是我在新项目里优先推荐静态内部类的直接原因。
4. 私有没有用:反射、序列化与多类加载器如何“再造”一个单例
4.1 反射改构造器权限
私有构造器拦得住普通调用,拦不住反射。写下面这段代码就可以轻松创建第二个实例:
java复制Constructor<Config> constructor = Config.class.getDeclaredConstructor();
constructor.setAccessible(true);
Config another = constructor.newInstance();
System.out.println(another == Config.getInstance()); // false
在 Java 高版本里,通过反射强制访问私有构造器会有模块访问限制,但并不是所有环境都会直接拦截。你只要把这种攻击路径交给安全测试或外部调用方,单例的唯一性就会破功。
防御思路有几种:构造器里加标记位,第二次初始化抛异常;或者直接用枚举单例,让 newInstance 无法生效。
java复制private static int callCount;
private Config() {
if (callCount++ > 0) {
throw new IllegalStateException("instance already created");
}
}
这种防御代码看起来很脏,但若你的单例暴露在不受信任的类路径上,又必须防反射,它是有效的兜底手段。
4.2 序列化时“复制”出来的 instance
如果一个单例实现了 Serializable 并写入磁盘,再反序列化回来,Java 会重新创建一个对象。此时你在反序列化前后比较两个实例,会发现它们不是同一个引用。
修复方法是在单例里增加 readResolve:
java复制protected Object readResolve() {
return getInstance();
}
这样反序列化时不会新建对象,而是直接返回当前唯一实例。此外,所有字段尽量加上 transient,避免序列化过程把短暂缓存数据带出去。
4.3 类加载器和多实例部署
还有一个经常被忽略的维度:单例只在同一个虚拟机进程、同一个类加载器下才成立。
在 Web 容器里如果同一个类被不同类加载器加载两次,那么每个类加载器都会拥有自己的一份“唯一实例”。OSGi 插件环境、热部署场景尤其容易踩这个坑。你以为是全局唯一,实际上不同组件各拿各的。
更宏观地看,分布式部署后,你部署了 5 个应用实例,每个实例都有一份单例,那这个“单例”就变成了“每进程单例”。如果业务需要整个集群只有一个共享状态,那必须依赖外部存储、分布式锁或中间件,不能指望本地单例。理解这个边界,单例才不会在架构层面误导你。
5. Android 项目里的单例:Context 泄漏和系统服务的潜规则
5.1 最常见的错误:在单例里保存 Activity
Android 领域里单例模式出现频率非常高,因为手机应用全局只需要一个悬浮窗控制器、一个媒体播放器管理类、一个网络请求管理器。但 Android 有个 Context 问题,单例很容易写崩。
很多新手会这样写:
java复制public class UiHelper {
private static UiHelper instance;
private Context context;
private UiHelper(Context context) {
this.context = context;
}
public static UiHelper getInstance(Context context) {
if (instance == null) {
instance = new UiHelper(context);
}
return instance;
}
}
调用方一旦传进来的是 Activity,而这个 UiHelper 又被全局静态变量持有,就会一直强引用着那个 Activity。用户退出页面后,Activity 本该被回收,却因为单例还拽着它,内存就无法释放;多进几次页面就可能 OOM。
5.2 正确姿势:只持有 ApplicationContext
如果单例确实需要 Context,构造时应该强制让调用方传 ApplicationContext,或者在方法内自己转换:
java复制public class AppSession {
private static volatile AppSession instance;
private final Context appContext;
private AppSession(Context context) {
this.appContext = context.getApplicationContext();
}
public static AppSession getInstance(Context context) {
if (instance == null) {
synchronized (AppSession.class) {
if (instance == null) {
instance = new AppSession(context.getApplicationContext());
}
}
}
return instance;
}
}
使用 getApplicationContext() 后,单例持有的是整个应用进程级别、生命周期极长的 Context,就算在页面销毁后也不会造成页面泄漏。若依赖外部注入框架,也可以让容器在启动阶段初始化单例并传入 ApplicationContext。
5.3 系统服务为什么像“按需获取的单例”
如果你读过各种 Android 源码设计模式的解析,会发现在系统框架里,很多服务类并非在每个调用点重新创建,而是通过 getSystemService() 返回同一个底层服务代理,比如剪贴板、通知管理、窗口管理。这样既避免了频繁创建系统组件,也保证各业务模块拿到的是同一个后端服务状态。
应用层同样可以采用这种思路:网络请求缓存、数据库实例、账号管理器等对象都适合做单例或由容器管理。但要注意,不能把所有 manager 都做成单例,否则状态一多,单例内部就会变成万金油,字段之间互相耦合,后期很难维护。Android 上更稳妥的实践常常是:Dagger/Hilt 这类依赖注入框架管理生命周期,让框架保证单例范围,而不是手写上百个 getInstance。
6. 可测试与可替换设计:一个能进生产环境的单例,不止满足“只有一个”
6.1 单例如何影响单元测试
纯原始的单例对单元测试很不友好。因为静态的 getInstance 调用会直接返回真实实例,你很难在测试中替换成 mock。比如一个发送短信的 SmsSender 是单例,测试里你并不想真的发短信,可静态方法调用点写死在代码里,mock 框架也无从下手。
另一个问题是测试用例之间的状态污染。一个配置单例在某个用例中被改成了测试环境地址,下一个用例如果没有清理,可能就用错地址了。要解决这个问题,单例需要提供可控的“重置”入口,或者让设计上能注入新实例。
6.2 可测试的单例写法
我自己在代码里会尽量遵守两条约定:
- 单例实现接口或抽象类;
- 提供包内可见的测试替换入口。
java复制public class SmsSender {
private static volatile SmsSender instance;
private SmsSender() {}
public static SmsSender getInstance() {
if (instance == null) {
synchronized (SmsSender.class) {
if (instance == null) {
instance = new SmsSender();
}
}
}
return instance;
}
// 限制可见性,普通业务不要乱调
static void setInstanceForTesting(SmsSender mockInstance) {
instance = mockInstance;
}
}
这样业务代码仍然调用 SmsSender.getInstance().send(...),但单元测试里可以提前 setInstanceForTesting(fakeSender),测试结束再恢复原状,避免触发真实短信通道。
如果不想暴露可变静态字段,更优雅的方案是引入 Provider:
java复制public class SmsSenderProvider {
private static SmsSender instance = new SmsSender();
public static SmsSender get() {
return instance;
}
static void reset(SmsSender newInstance) {
instance = newInstance;
}
}
本质上是把一个“可变位置”独立出来,让正常业务路径稳定,让测试路径可控。
6.3 用 DI 容器替代手写单例
说到“可测试可替换”,现代大型项目里我最推荐的不是手写单例,而是依赖注入容器。比如 Spring 里的 Bean 默认单例作用域、Android 里 Hilt 的 @Singleton、Guice 的单例绑定,它们都由容器负责实例化时机和对象个数,代码里不再散落 static 字段。
容器管理的单例有一个手写单例难以比拟的好处:依赖关系显式化。构造器里需要什么,容器就注入什么,而不是让每个单例在私有构造器里偷偷加载一堆全局状态。这样代码更容易阅读,也更容易替换。
当然,手写单例并不是一无是处。小型项目、工具库、没有引入 DI 框架的场景里,单例依然是成本最低的选择。但我建议你写之前问自己三个问题:
- 这个对象真的必须全局唯一吗?
- 它的初始化时机由谁控制?
- 以后测试时怎么替换、怎么清空状态?
三个问题都能给出明确答案,再动手写也不迟。
如果让我给一个更直接的选型结论:需要延迟初始化又不想引入复杂锁,Java 优先静态内部类;要极致的防反射防序列化,用枚举;必须用双检锁时,别删 volatile;在 Android 里要持有 Context,就只留 ApplicationContext。单例写完不是结束,真正验证它是好设计的地方,往往在并发压测、内存检查、单元测试和维护者接手的那一刻。
