在设计模式里,单例模式绝对是最“看着简单、写起来全是坑”的一个。不管是搜“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 都要经过同步方法。单例的调用频率往往非常高,如果这里还有锁竞争,那性能会肉眼可见地下降。
刚开始接触并
