单例模式
单例 = 「唯一」这个约束 + 「全局可取」这个入口。所有实现写法的差异,本质都在回答同一个问题:把创建时机推迟到第一次使用(懒加载),怎么保证「唯一」不被并发、反射、序列化打破? 而所有滥用,本质都是把「我想到处访问它…
阅读提示 面向已经会写 Java / Python 的读者。重点不是「单例是什么」,而是三件真正决定成败的事:懒加载为什么会破坏「唯一」保证、单例还能被谁破坏、什么时候压根不该用它。 ⚠️ = 常见陷阱 🆚 = 语言对比 💡 = 选择建议
目录
- 一、单例是什么:一句话 + 三要素
- 二、最小实现:Java 与 Python
- 三、懒加载如何破坏「唯一」:线程安全
- 四、还有谁能破坏单例:反射与序列化
- 五、什么时候该用:两类正当理由
- 六、什么时候别用:90% 的滥用都在这
- 七、速查表
- 核心结论
核心概念 单例 = 「唯一」这个约束 + 「全局可取」这个入口。所有实现写法的差异,本质都在回答同一个问题:把创建时机推迟到第一次使用(懒加载),怎么保证「唯一」不被并发、反射、序列化打破? 而所有滥用,本质都是把「我想到处访问它」误当成了「它必须唯一」。
一、单例是什么:一句话 + 三要素
单例模式(Singleton):保证一个类在整个进程生命周期内只有一个实例,并提供一个全局访问点。
无论哪种语言、哪种写法,都在做同样三件事:
| 要素 | 做法 | 目的 |
|---|---|---|
| 堵住外部创建 | 构造函数私有化 | 别人不能绕过你 new |
| 自己持有唯一实例 | 类级别的静态变量 | 实例的生命周期挂在类上,不挂在调用方 |
| 开一个受控入口 | getInstance() |
所有获取都经过你,你才能控制唯一性 |
为什么是「静态变量」而不是别的 实例必须活得比任何一个调用方都久,所以只能挂在类上。这也正是单例的代价来源:类是全局可达的,所以单例本质上就是有名字的全局变量——后面的所有缺点都由这一句推导出来。
二、最小实现:Java 与 Python
Java:最朴素的懒汉式(先看它错在哪)
public class Singleton {
private static Singleton instance;
private Singleton() {} // 私有构造
public static Singleton getInstance() {
if (instance == null) { // ⚠️ 单线程正确,多线程会出两个实例
instance = new Singleton();
}
return instance;
}
}单线程下完全正确,问题留到 三、懒加载如何破坏「唯一」:线程安全 处理。
Python:__new__ 写法与它的隐藏坑
🆚 Python 没有真正的私有构造,堵不住 Singleton() 调用,只能在对象创建阶段 __new__ 里做手脚——拦不住调用,那就让每次调用都返回同一个对象:
class Singleton:
_instance = None
def __new__(cls, *args, **kwargs):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
a = Singleton()
b = Singleton()
print(a is b) # True,同一个对象到这一步是对的。但只要类带上 __init__,坑就出现了:
⚠️
__new__返回旧实例,__init__仍会被再执行一次 错误操作:class Singleton: _instance = None def __new__(cls, *a, **kw): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __init__(self, value): self.value = value a = Singleton(1) b = Singleton(2)实际结果:
a is b为True,但a.value变成了 2(已实际运行验证)。你以为拿到的是「第一次创建好的那个配置对象」,其实它的状态被后来的调用悄悄改写了。原因: Python 创建对象分两步——
__new__负责分配,__init__负责初始化。只要Singleton(...)被调用,且__new__返回的是本类实例,解释器总会接着调用__init__。__new__只保证了「同一个对象」,没保证「只初始化一次」。正确做法: 用初始化标志短路重复初始化:
class Config: _instance = None _inited = False def __new__(cls, *a, **kw): if cls._instance is None: cls._instance = super().__new__(cls) return cls._instance def __init__(self, value): if Config._inited: # 已初始化过就直接返回 return self.value = value Config._inited = True x, y = Config(1), Config(2) print(x is y, x.value) # True 1
💡 Python 里更该优先考虑的两种写法 模块级单例:Python 的模块天然只被导入一次,
config = ConfigManager()写在config.py里,别处from config import config就是单例,零样板、零线程问题。@functools.lru_cache/@cache装工厂函数:@cache def get_client(): return HttpClient(),语义清晰且天然可测(测试里能get_client.cache_clear())。 只有在必须让Cls()这个调用形式本身返回单例时,才动__new__或元类。
三、懒加载如何破坏「唯一」:线程安全
问题的根源只有一句:if (instance == null) 的「检查」和 new 的「创建」之间存在时间缝隙。两个线程同时挤进这条缝,就各造一个实例,「唯一」当场失效。
四种解法,按由差到优的顺序排:
1. 饿汉式:不给缝隙
public class Singleton {
private static final Singleton INSTANCE = new Singleton(); // 类加载时就创建
private Singleton() {}
public static Singleton getInstance() { return INSTANCE; }
}类加载由 JVM 保证线程安全,所以天然安全。代价:不管你用不用它,都会被创建——如果这个对象很重(比如要连数据库),就白白拖慢启动、占着内存。
2. 双重检查锁 DCL:写对不容易
public class Singleton {
private static volatile Singleton instance; // volatile 不能省
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查:已创建就免锁,不损性能
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:防止排队线程重复创建
instance = new Singleton();
}
}
}
return instance;
}
}两次检查各司其职:外层为了性能(创建完成后再也不进锁),内层为了正确性(阻塞在锁外的线程醒来后必须重新确认)。
而 volatile 是很多人写上去却说不清的那一半:
读图顺序:先看顶部三步——new 拆成「分配内存 → 初始化字段 → 引用赋值」;再对比下面两条时间线。正常顺序下,引用赋值发生在初始化之后,任何线程看到非 null 时对象都已就绪;一旦 JVM/CPU 把后两步重排成 ①→③→②,中间就出现一段危险窗口:instance 已经非 null,但字段还全是默认值。
⚠️ DCL 漏掉 volatile 错误操作: 写成
private static Singleton instance;(没有 volatile),其余 DCL 结构完全正确。实际结果: 绝大多数时候正常,偶发在高并发下线程 B 拿到一个字段为
null/0的「半成品」对象,随后抛 NPE。极难复现,也极难定位。原因:
instance = new Singleton()不是原子操作,JVM 和 CPU 允许对「初始化」与「引用赋值」重排序。线程 B 走的是外层无锁的快路径,它只看instance != null,根本不会被synchronized挡住,于是撞进危险窗口。正确做法: 加
volatile。它禁止这两步重排,并保证写入立即对其他线程可见。记牢分工:synchronized管互斥,volatile管有序性和可见性,DCL 两个都要。
3. 静态内部类:写起来最舒服的懒加载
public class Singleton {
private Singleton() {}
private static class Holder { // 外部类加载时,它不会被加载
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE; // 首次调用才触发 Holder 类加载
}
}原理:JVM 保证类初始化过程是线程安全的,而内部类只在第一次被引用时才加载。于是懒加载和线程安全都由 JVM 兜底,一行锁都不用写。DCL 能做的它都能做,且没有 volatile 这个易漏点——工程里通常比 DCL 更值得推荐。
4. 枚举:唯一能防住反射和序列化的写法
public enum Singleton {
INSTANCE;
public void doSomething() { /* 业务方法照常写 */ }
}《Effective Java》推荐的写法。为什么它这么强,见下一节。
四、还有谁能破坏单例:反射与序列化
前三种写法解决的都是并发造成的多实例。但即使单线程,「唯一」仍有两个缺口:
| 攻击方式 | 怎么破坏 | 常规写法的防御 |
|---|---|---|
| 反射 | setAccessible(true) 强行调用私有构造 |
构造器里判断 instance != null 就抛异常 |
| 序列化 | 反序列化会绕过构造器创建新对象 | 实现 readResolve() 返回已有实例 |
| 多类加载器 | 不同 ClassLoader 各加载一份类,各自持有静态变量 | 指定统一 ClassLoader(较少考) |
// 序列化防御:反序列化时用它替换掉新建的对象
private Object readResolve() {
return INSTANCE;
}💡 枚举为什么免疫这两点 JVM 层面规定:枚举常量由 JVM 保证唯一,
Constructor.newInstance()遇到枚举类型会直接抛IllegalArgumentException;序列化时也只写枚举的名字,反序列化按名字查回原有常量,不走构造器。所以枚举不是「写法简洁」而已,是唯一在语言层面把这三个缺口全堵死的方案。
五、什么时候该用:两类正当理由
读图顺序:从上往下走两个判断,只有命中其中一个才用单例,两个都答「否」就往下落到红框。核心结论:问 1 命中的是必须唯一,问 2 命中的只是最好复用——两者的正当性强度不一样。
理由一:状态必须全局一致,或资源必须集中管控
| 场景 | 造两个会发生什么 |
|---|---|
ConfigManager |
A 模块 set("debug", True),B 模块读到的还是旧实例的旧值,行为不一致 |
| 连接池 / 线程池 | 数据库上限 20 连接,两个池各以为自己能开 20 个,直接打爆数据库 |
| 雪花 ID 生成器 | 两个实例用同一机器号和时间戳 → 生成重复 ID,主键冲突 |
共同点:「多一个实例」不是浪费,是直接出错。
理由二:无状态,但创建成本极高、需要复用内部资源
| 场景 | 单例的真正收益 |
|---|---|
Logger |
复用文件句柄和格式器;否则上百个句柄能把系统拖死,日志还会散落到多处 |
OkHttpClient / ES Client / gRPC Channel |
复用其内部的连接池与线程池;每次 new 则连接无法复用,性能塌方还漏资源 |
这一类的「唯一」是手段,不是目的 官方文档常写「本类线程安全,全局共享一个即可」——注意它说的是 should be shared,不是 must be unique。所以这类完全可以用依赖注入 + 容器单作用域来实现,不必把类本身写死成单例。Spring 的
@Bean默认就是单例作用域,这是比自己写getInstance()更好的做法:唯一性由容器保证,可测试性由注入保证。
六、什么时候别用:90% 的滥用都在这
反例 1:把业务服务做成单例 错误操作: 「用户系统全局只有一套」→ 把
UserService/OrderService写成getInstance()。实际结果: 单元测试写不动。想 mock 一个假的
UserService时,被测代码内部直接UserService.getInstance()硬编码取实例,你没有任何注入点能替换它。测试之间还会因为共享同一实例而互相污染状态。原因: 单例把「依赖谁」这件事从方法签名藏进了方法体。看构造函数根本看不出这个类依赖了什么——依赖关系变成隐式的了。
正确做法: 业务服务保持可
new,通过构造函数注入传进去。想要「全局一份」就交给 DI 容器的单例作用域,而不是类自己。
⚠️ 反例 2:给纯工具类套单例 错误操作:
DateUtil.getInstance().format(d)。实际结果: 多了一层毫无意义的间接调用和一个静态变量。
原因: 单例的价值是管理实例状态。工具类无状态、无资源,压根没有「实例」需要被管理。
正确做法: 直接静态方法
DateUtil.format(d)。
判断口诀:
- new 出两个会数据不一致或资源超限吗?→ 会,用单例。
- 创建一次是否极其昂贵(几百 ms / 内部含连接池)?→ 是,考虑单例(优先交给 DI 容器)。
- 两个都不是 → 别用。你要的是依赖注入或静态方法,不是全局状态。
七、速查表
五种写法对比
| 写法 | 懒加载 | 线程安全 | 防反射 | 防序列化 | 何时选 |
|---|---|---|---|---|---|
| 饿汉式 | ❌ | ✅(JVM 类加载保证) | ❌ | ❌ | 对象轻、必定会用 |
| 朴素懒汉式 | ✅ | ❌ | ❌ | ❌ | 永远别用,只作反面教材 |
| DCL + volatile | ✅ | ✅ | ❌ | ❌ | 理解并发原理的必修课;工程上可被下一行替代 |
| 静态内部类 | ✅ | ✅(JVM 类初始化保证) | ❌ | ❌ | 工程默认选它,无锁无易错点 |
| 枚举 | ❌ | ✅ | ✅ | ✅ | 要求最强保证时的首选 |
关键细节速查
| 问题 | 一句话答案 |
|---|---|
| DCL 为什么要两次检查? | 外层为性能(创建后免锁),内层为正确性(等锁线程醒来需复查) |
| volatile 能省吗? | 不能。new 分三步,重排后会让别的线程拿到半成品对象 |
| volatile 和 synchronized 分工? | volatile 管可见性与有序性,synchronized 管互斥,DCL 两者缺一不可 |
| 静态内部类为什么线程安全? | JVM 保证类初始化过程线程安全,内部类首次被引用时才加载 |
| 单例最大的缺点? | 它是全局状态,隐藏依赖关系,导致无法 mock、测试互相污染 |
| Python 怎么写单例? | 优先模块级单例或 @cache 工厂;用 __new__ 要记得 __init__ 会重复执行 |
| 分布式下的单例? | 进程内单例失效——每个进程各一份。要全局唯一得靠分布式锁 / 中心化服务 |
核心结论
- 三要素:私有构造 + 静态实例 + 全局访问点。单例本质就是一个有名字的全局变量,所有优缺点都由此推导。
- DCL 的两个考点:两次检查分别为了性能和正确性;
volatile因为new的三步可被重排,缺了它会让快路径线程读到未初始化的对象。工程上更推荐静态内部类,把线程安全交给 JVM。- 枚举是唯一同时防住反射和序列化的写法;其他写法要防,得手写构造器检查 +
readResolve()。- Python 特有坑:
__new__保证「同一个对象」,但不保证「只初始化一次」——__init__每次调用都会执行,会悄悄覆盖已有状态。- 该用的判据只有两条:多一个实例会出错(状态不一致 / 资源超限),或创建成本极高需要复用内部资源池。
- 最典型的反例:业务 Service 做成单例 → 无法 mock、测试互相污染。要「全局一份」应交给 DI 容器的单例作用域,而不是类自己写
getInstance()。
相关笔记
- 八股