跳到主要内容
Cowers://
全部文章
设计模式

单例模式

单例 = 「唯一」这个约束 + 「全局可取」这个入口。所有实现写法的差异,本质都在回答同一个问题:把创建时机推迟到第一次使用(懒加载),怎么保证「唯一」不被并发、反射、序列化打破? 而所有滥用,本质都是把「我想到处访问它…

阅读提示 面向已经会写 Java / Python 的读者。重点不是「单例是什么」,而是三件真正决定成败的事:懒加载为什么会破坏「唯一」保证单例还能被谁破坏什么时候压根不该用它。 ⚠️ = 常见陷阱 🆚 = 语言对比 💡 = 选择建议

目录


核心概念 单例 = 「唯一」这个约束 + 「全局可取」这个入口。所有实现写法的差异,本质都在回答同一个问题:把创建时机推迟到第一次使用(懒加载),怎么保证「唯一」不被并发、反射、序列化打破? 而所有滥用,本质都是把「我想到处访问它」误当成了「它必须唯一」。

一、单例是什么:一句话 + 三要素

单例模式(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 bTrue,但 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 是很多人写上去却说不清的那一半:

单例模式 DCL 指令重排

读图顺序:先看顶部三步——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)

判断口诀:

  1. new 出两个会数据不一致或资源超限吗?→ 会,用单例。
  2. 创建一次是否极其昂贵(几百 ms / 内部含连接池)?→ 是,考虑单例(优先交给 DI 容器)。
  3. 两个都不是 → 别用。你要的是依赖注入或静态方法,不是全局状态。

七、速查表

五种写法对比

写法 懒加载 线程安全 防反射 防序列化 何时选
饿汉式 ✅(JVM 类加载保证) 对象轻、必定会用
朴素懒汉式 永远别用,只作反面教材
DCL + volatile 理解并发原理的必修课;工程上可被下一行替代
静态内部类 ✅(JVM 类初始化保证) 工程默认选它,无锁无易错点
枚举 要求最强保证时的首选

关键细节速查

问题 一句话答案
DCL 为什么要两次检查? 外层为性能(创建后免锁),内层为正确性(等锁线程醒来需复查)
volatile 能省吗? 不能。new 分三步,重排后会让别的线程拿到半成品对象
volatile 和 synchronized 分工? volatile 管可见性与有序性,synchronized 管互斥,DCL 两者缺一不可
静态内部类为什么线程安全? JVM 保证类初始化过程线程安全,内部类首次被引用时才加载
单例最大的缺点? 它是全局状态,隐藏依赖关系,导致无法 mock、测试互相污染
Python 怎么写单例? 优先模块级单例或 @cache 工厂;用 __new__ 要记得 __init__ 会重复执行
分布式下的单例? 进程内单例失效——每个进程各一份。要全局唯一得靠分布式锁 / 中心化服务

核心结论

  1. 三要素:私有构造 + 静态实例 + 全局访问点。单例本质就是一个有名字的全局变量,所有优缺点都由此推导。
  2. DCL 的两个考点:两次检查分别为了性能和正确性;volatile 因为 new 的三步可被重排,缺了它会让快路径线程读到未初始化的对象。工程上更推荐静态内部类,把线程安全交给 JVM。
  3. 枚举是唯一同时防住反射和序列化的写法;其他写法要防,得手写构造器检查 + readResolve()
  4. Python 特有坑__new__ 保证「同一个对象」,但不保证「只初始化一次」——__init__ 每次调用都会执行,会悄悄覆盖已有状态。
  5. 该用的判据只有两条:多一个实例会出错(状态不一致 / 资源超限),或创建成本极高需要复用内部资源池。
  6. 最典型的反例:业务 Service 做成单例 → 无法 mock、测试互相污染。要「全局一份」应交给 DI 容器的单例作用域,而不是类自己写 getInstance()

相关笔记

  • 八股