单例模式算是设计模式里代码量最小、面试出现频率最高,但真正能一眼写对的人并不多的一个。我见过太多项目里用懒汉式不加锁,也见过双重检查锁漏了 volatile,更多的情况是单测跑得好好的,上线之后被反射工具或序列化机制一碰,实例凭空多出好几个。这篇文章想借“单例模式全解析:5种写法 + 破坏与防护”这个主题,把单例从原理到写法、从破坏路径到防护手段完整梳理一遍,重点讲清楚那些文档里不会告诉你、但实际项目里最容易踩坑的地方。
单例模式的核心就一句话:保证一个类在进程内只有一个实例,并提供一个全局访问点。但这句话背后的机制细节远没有字面看起来简单。下面我从它解决的问题开始,把五种典型写法逐一拆解,再动手演示反射、序列化、克隆如何打破单例,最后给出对应的防护方案,顺带聊聊类加载器、框架容器这些容易被忽略的隐性破坏因素。
1. 单例模式真正的用武之地:资源复用与全局访问控制
1.1 先看一个每天都在发生的资源问题
写业务代码的时候,你最常遇到的资源密集型对象是什么?配置解析、日志对象、线程池、数据库连接池这一挂都算。比如一个配置管理器,启动时要读取磁盘上的 YAML 文件,解析完还可能走一次远程配置中心拉取。要是每次调用都临时 new 一个出来,光配置文件解析就要重复执行几十上百次,性能损耗还是小事,更麻烦的是本地缓存的状态没法共享。一个地方更新了配置,另一个对象手里还是旧数据,排查起来非常头痛。
单例模式在这种情况下是一个强约束工具。它强制所有调用方共享同一个实例,把状态维护在一个可控点上。想更新配置就统一走这个实例的刷新方法,所有后续读取立刻能看到新数据。这也是单例在连接池、线程池场景里天然适用的原因——池子本身就是有状态的资源容器,重复创建等于每个调用方各搞一套,资源池存在的意义就没有了。
有些观点会说单例的目的只是省内存,这句话不算错,但没有说到根上。省内存是副产品,真正的价值是把“全局唯一”这个语义固化下来,避免状态分裂。打个比方:系统里有多个数据库连接池实例,每个实例维护自己的连接水位,你怎么做全局限流和监控?连确切的连接总数都没法统计。用单例把连接池管起来之后,监控、限流、熔断都变得有据可依。
1.2 全局访问点的价值:状态一致性
再往深处想一步。单例模式除了保证唯一实例,还提供了一个全局访问点,相当于给系统中重要的资源开了一个明确的“服务窗口”。调用方不用自己 new,也不用传参传递对象引用,任何时候需要都能通过 getInstance 拿到同一个对象。这种设计对全局计数器、ID 生成器、配置中心客户端来说尤其重要。
拿全局计数器来说,如果允许多个实例存在,每个实例各自维护自己的计数,那全局总数就是错的。你需要在所有实例之上再做一次合并,这不但增加复杂度,还会引入一致性风险。单例把问题简化为“只有一个计数对象”,所有线程共享它的原子变量,计数结果天然正确。
我得顺便提一句,状态一致性是要付出代价的。单例对象一旦内部持有可变状态,就要考虑并发安全。你拿到的是同一个实例,意味着所有线程都在同一个内存地址上读写,这时候不加锁、不用 Atomic 类,数据迟早会乱。很多人容易忽略这一点,以为单例只要解决实例数量就行,结果在单例里塞了一堆成员变量,又没做线程安全处理,最后调线上问题才发现根源在这里。
1.3 不是万物皆可单例,先分清场景
单例是个好工具,但我不太赞成把任何类都套上单例模板。判断一个类要不要做成单例,我会看两个指标:这个类有没有必要全局唯一?它的状态是否需要跨调用共享?
如果是无状态的工具类,比如字符串处理、日期格式化辅助方法,用静态方法就足够了,硬套单例反而多了一些无意义的样板代码。真正需要单例的,是那些持有状态且状态需要全局共享的对象。当然也有例外:有些对象创建成本极高,即便是无状态的,每次 new 也很浪费,这种情况下单例作为缓存也合理,只是侧重点变成了性能优化。
还有一类类不适合单例——职责本身就是要为不同上下文保存不同数据的场景。比如多租户系统的租户上下文,如果搞成全局单例,A 租户的业务请求还没处理完,B 租户的数据就把上下文覆盖了,这就是典型的滥用。所以很多框架里用 ThreadLocal 而不是单例来保存上下文数据,恰恰是因为单例的“全局唯一”语义在这种场景里是毒药而不是解药。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 五种单例写法详解,按易错程度从高到低排个序
2.1 饿汉式:代码最简洁,但初始化时机不可控
饿汉式是单例里最直观的写法:类加载时就创建唯一实例,之后 getInstance 直接返回。
java复制public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
这段代码有三个细节值得说。第一,INSTANCE 是 static final 的,JVM 保证类的静态初始化只会执行一次,所以天然线程安全,不需要任何同步。第二,构造函数是 private,外部无法直接 new。第三,getInstance 是静态方法,调用方可以绕过对象实例直接获取单例。
不过饿汉式有一个让我不太满意的地方:初始化时机不可控。只要这个类被加载,实例就会立刻创建,哪怕整个程序从头到尾根本没用过它。如果构造过程恰好要读文件、建连接,而项目启动阶段又因为某种原因提前加载了这个类,就会拖慢启动速度。加上 final 修饰的 INSTANCE 在类加载后没法原地替换,想用不同配置做单元测试也不方便。
但话说回来,对于绝大多数不带初始化副作用的对象,饿汉式是我心里最稳定的兜底选择。代码少,线程安全,不像懒汉式那样在极端并发下翻车。如果你能接受“用不到也会先创建”这一条,它完全不丢人。
2.2 懒汉式(线程不安全版):只能当反面教材
既然饿汉式是“类加载就创建”,懒汉式追求的就是“真正第一次调用才创建”。最简单的懒汉式长这样:
java复制public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
单线程下这段代码没有任何问题,但一到多线程环境就出事故。假设线程 A 和线程 B 同时进入 getInstance,都判断 instance == null 成立,然后各自执行 new 操作。于是内存里出现两个 Singleton 实例,单例的唯一性承诺当场作废。
为什么判断 null 和赋值不是原子操作?这里拆开看就很清楚。instance 的读取和写入发生在不同的指令上,中间没有任何同步机制,所以两个线程可以同时看到 null,也可以同时往 instance 变量写不同的对象引用。这个问题不是“概率低就能忽略”——在服务刚启动的瞬间,所有线程集中调用 getInstance,撞车的概率一点都不小。
所以懒汉式这个版本我建议直接不用,了解它错在哪就够了,别写进生产代码。如果坚持用懒汉式的思路,必须用 synchronized 锁住方法,但那又带来每次调用加锁的性能开销,属于一步错步步错。
2.3 双重检查锁(DCL):volatile 少一个字,线上就等着出幺蛾子
带锁的懒汉式就是给 getInstance 方法加 synchronized,简单但性能不好,因为每次读实例都要竞争锁。双重检查锁(Double-Checked Locking,DCL)登场:外层先判断一次 null,避免不必要的加锁;内层再加锁,并再判断一次,确保多线程并发下只创建一次。
java复制public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
代码看着简单,但有一个著名的大坑:volatile 关键字不能省。原因要从指令重排序说起。instance = new Singleton(); 这一行在字节码层面并不是一个原子操作,它大致包含三步:分配内存、调用构造函数初始化对象、把引用赋值给 instance 字段。编译器和 CPU 在实际执行时,可能把第三步和第二步的顺序调换,也就是说出现“先把引用地址赋给 instance,构造函数还没执行完”的窗口。
如果线程 A 在这个窗口里执行到一半,线程 B 恰好在外层判断 instance != null,就直接返回了一个半初始化的对象。后面对这个对象的字段访问,可能读到默认值而不是构造函数里设置的值。volatile 在这里的作用就是禁止这种重排序,同时保证线程 A 写入 instance 后,线程 B 能立刻看到最新值。没有 volatile,DCL 的正确性就无法保证。
我在代码评审里看到最多的错误,和这一模一样:DCL 写得头头是道,volatile 忘写了,还信誓旦旦说“本地测了很多次没问题”。本地没问题是因为并发压力不够,真正多线程高并发热启动,就是间歇性诡异问题。这种 bug 最折磨人——现场复现不了,日志看不出明显异常,最后查出来才发现是最经典的 DCL 错误。
2.4 静态内部类:兼顾懒加载与无线程同步代码
如果说 DCL 是用“显式同步”来实现线程安全,那静态内部类就是利用 JVM 的类加载机制来做这件事。
java复制public class Singleton {
private Singleton() {}
private static class Holder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
这个写法的核心逻辑是:外部类 Singleton 被加载时,并不会去加载静态内部类 Holder,只有第一次调用 getInstance 方法,才会触发 Holder 的加载和初始化,这时候才创建 INSTANCE。所以它天然实现了懒加载——真正用到的时候才初始化。又因为类的加载和静态字段初始化由 JVM 保证线程安全,所以不需要任何 synchronized 关键字,代码干净利落。
与 DCL 相比,静态内部类少了一个易错点:不需要手动加 volatile。JVM 对类初始化过程有严格的锁机制,多个线程同时首次触发 Holder 的加载时,只有一个线程会执行静态初始化,其余线程等待结束后直接读取。这里不会出现半初始化的对象,因为类初始化的完成发生在 INSTANCE 赋值之后,且对观察者可见。
这种写法是我个人最推荐的非枚举实现。代码可读性高,线程安全,懒加载也顺手实现。唯一要提的小缺点,是它不能再通过参数控制初始化时机,也没有枚举那么极致的反序列化防护,但对大多数项目来说完全够用。
2.5 枚举式单例:防御属性拉满
最后一种写法也是江湖上号称“最优雅”的写法:直接定义一个枚举,把唯一实例放到枚举常量里。
java复制public enum Singleton {
INSTANCE;
private String config;
public String getConfig() {
return config;
}
public void setConfig(String config) {
this.config = config;
}
}
调用方直接用 Singleton.INSTANCE 就能访问实例。枚举常量的初始化由 JVM 保证线程安全,天然具备懒加载能力。更狠的是,枚举对本文后面要讲的破坏手段几乎免疫。具体免疫机制我在防护章节详细展开,这里先记住一个结论:Java 的枚举在序列化、反射攻击面前有语言层面的保护,比普通类安全得多。
枚举单例也有一些争议。有人说它不够灵活,不能继承别的类(枚举类型默认继承 java.lang.Enum),也有人说在早期 Android 或某些特殊环境里有限制。如果你维护的是常规 Java 服务端应用,这些争议基本不会影响到你。我个人做新项目时,如果单例对象不需要继承需求,就会优先用枚举:几行代码搞定,还省得额外做防护。
2.6 五种写法怎么选,一张表说清楚
| 写法 | 线程安全 | 懒加载 | 破坏防护 | 代码量 | 适用建议 |
|---|---|---|---|---|---|
| 饿汉式 | 安全 | 否 | 一般 | 少 | 初始化无副作用,能接受启动即创建 |
| 懒汉式(线程不安全) | 不安全 | 是 | 无 | 少 | 仅做教学反面教材,别用 |
| 双重检查锁 | 安全(要加 volatile) | 是 | 一般 | 中 | 需要懒加载且想显式锁控制 |
| 静态内部类 | 安全 | 是 | 中 | 少 | 非枚举场景的首选 |
| 枚举 | 安全 | 是 | 最强 | 最少 | 新项目首选,无继承需求时 |
选择逻辑很简单:如果项目对初始化时机无所谓,饿汉式省心;如果在意延迟加载,优先静态内部类;如果还想把防御属性拉满,那就枚举。DCL 在我这里的排位不高,除非你明确需要手动控制同步逻辑,或者团队已有约定,否则静态内部类基本能达到同样的效果,还少承担一个 volatile 的隐患。
3. 破坏单例的三条路径:反射、序列化、克隆
3.1 反射攻击:私有构造挡不住 setAccessible
很多人以为把构造函数做成 private,外部就绝对创建不了了。那是没考虑反射。Java 的反射机制允许你拿到私有构造器,然后调用 setAccessible(true) 强行打开访问权限:
java复制Class<?> clazz = Singleton.class;
Constructor<?> constructor = clazz.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton s1 = Singleton.getInstance();
Singleton s2 = (Singleton) constructor.newInstance();
System.out.println(s1 == s2); // false
运行结果会出乎很多人的意料:s1 和 s2 是两个不同的对象。反射调用构造函数时,并不关心这个构造函数是不是 private,setAccessible(true) 直接把访问检查关掉,构造函数照样执行,一个全新的实例就这么出来了。
这算不算破坏单例?严格来说,这是绕过了单例模式的约定,而不是改变了原有单例的逻辑。原实例还是那个原实例,只是多了一个绕过通道。但在安全要求较高的场景里,这已经构成问题——单例的全局唯一性被打破,里面维护的状态会被第二个实例遮蔽,全局一致性随之瓦解。
防反射的思路也比较直接,常见做法是在构造函数里埋一个哨兵:用静态 boolean 变量标记实例是否已经被创建,构造函数里如果发现已经创建过,就直接抛异常。这个方法我在防护章节给出完整代码。
3.2 序列化攻击:从字节流里再捞一个实例
如果单例类实现了 Serializable 接口,那反序列化又会成为第二条破坏路径。Java 序列化机制在从字节流恢复对象时,底层会绕过构造函数,直接基于字节流在内存里重建对象。所以即使你的构造函数是 private,反序列化也不 care,它不走构造函数。
话不多说,直接看复现代码:
java复制public class Singleton implements Serializable {
private static final long serialVersionUID = 1L;
// ...
}
Singleton instance = Singleton.getInstance();
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton.bin"));
oos.writeObject(instance);
oos.close();
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton.bin"));
Singleton another = (Singleton) ois.readObject();
ois.close();
System.out.println(instance == another); // false
反序列化读出来的对象,和内存里已有的单例对象,不是同一个引用。这在单例场景下就是致命的:本来依赖全局唯一状态做的缓存、计数,因为多了一个“复制品”,数据可能不一致。很多企业项目里,单例类被放到分布式缓存或者消息队列的载荷里,不知不觉就被序列化了,跑一跑才发现单例失效。
要对付这条路径,标准答案是提供一个 readResolve 方法,在反序列化完成后让它返回已有的单例实例,而不是返回新重建的对象。具体代码下一章写。
3.3 克隆:看似冷门,也得留个心眼
相比反射和序列化,克隆攻击确实冷门一些,但原理值得了解。如果单例类或者它的父类实现了 Cloneable 接口,并且 getInstance 拿到的对象可以被调用 clone(),就有可能出现第二个实例:
java复制public class Singleton implements Cloneable {
// ...
@Override
protected Object clone() throws CloneNotSupportedException {
return super.clone();
}
}
clone() 方法直接对原有对象进行字段拷贝,同样不调用构造函数,效果就是得到一个内容相同但引用不同的对象。如果单例内部没有明确禁止克隆,这种行为就会绕过单例约束。
要防它也很简单:要么让单例类不实现 Cloneable,要么重写 clone() 直接抛异常,要么直接返回当前实例。这条路径实际生产里遇到得少,原因是大多数单例类不会真的实现 Cloneable,但一旦某个单例为了业务需要继承了某个实现了 Cloneable 的父类,隐患就埋下了。所以我在代码评审里看到单例类时,会顺手看一眼它的继承关系。
3.4 做个能被“破坏”的对照组,比上来就防更有效
我在团队内部做单例培训时,经常先让大家用常规方式写一个非常完美的 DCL 单例,然后分组做破坏试验:一组用反射去 new 一个,一组把实例序列化后再读回来,再看看两组搞出来的对象和原单例是不是同一个。这个环节每次都能引起一片惊呼,原因也很简单:大多数人写单例的时候,从来没有想过要验证它的唯一性边界。
这里我还想说一个方法论层面的经验。测试单例是否是“真的单例”,不要只用内存地址判断,更可靠的方式是给单例类加一个静态计数器,记录构造函数被实际调用的次数。不管是通过反射还是反序列化创建,只要构造函数真的执行了,计数就会增长。这样你能直观看到单例到底被“私建”了几次,排查绕过问题时也方便定位。
4. 防护三件套:哨兵、readResolve、枚举兜底
4.1 哨兵拦截:在构造函数里设置守卫
针对反射攻击,最直接的防护是往构造函数里加一个“是否已创建”的标记。常规写法是这样:
java复制public class Singleton {
private static boolean created = false;
private static Singleton instance;
private Singleton() {
synchronized (Singleton.class) {
if (created) {
throw new RuntimeException("单例模式被破坏,禁止创建多个实例");
}
created = true;
}
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
这个哨兵的核心逻辑是:构造函数只允许被执行一次。第一次调用 getInstance 时,构造函数里把 created 置为 true;如果之后有人想通过反射调用构造函数再来一次,created 已经为 true,直接抛异常。
注意这里我给 created 的检查和赋值加了 synchronized,是为了避免多线程同时通过反射调用构造函数时,两个线程都看到 created 为 false,然后各自成功创建实例。虽然反射攻击多半是单线程行为,但既然要防护,就防严实一点。还有一个细节:created 是静态字段,它属于类而不是实例,所以才能在多个反射实例之间共享这个状态。
不过我要做个重要提醒:哨兵方案防得住“调用构造函数的反射”,防不住那种先拿到 getInstance 返回的原实例、再通过序列化或者 clone 造出第二个实例的手段。所以哨兵只是第一道防线,后面两道还得跟上。
4.2 readResolve:让反序列化“偷换”成原实例
对于实现了 Serializable 的单例类,只要在类里加一个 readResolve 方法,反序列化机制在读完字节流、构造出临时对象后,会调用 readResolve 方法,把返回值作为最终的解析结果替换掉临时对象。外部拿到的就不是新对象,而是原单例:
java复制public class Singleton implements Serializable {
private static final long serialVersionUID = 1L;
private static final Singleton INSTANCE = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
private Object readResolve() {
return INSTANCE;
}
}
加了这个方法之后,前面那段序列化/反序列化的复现代码,最终 another 拿到的引用就和 instance 指向同一个对象了。原理是 ObjectInputStream 在反序列化过程中会检查目标类有没有 readResolve,如果有,就用它返回的对象作为反序列化结果,临时对象直接丢弃。
这个方法名很短,很容易被忽略,但它在分布式缓存、消息中间件场景里作用很大。举个例子:单例类参与 RPC 序列化,调用方拿到一个反序列化后的对象,如果没有 readResolve,它操作的就是一个全新对象,原本的单例状态全部丢失。加上 readResolve,至少能保证每个 JVM 内部拿到的还是自己的单例。
4.3 枚举单例为什么能“免疫”这些破坏
现在说回枚举单例。为什么前面我强调枚举是最强防护?因为 JVM 对枚举有专门的语言级保护。
针对反射:Java 反射机制在创建枚举实例时,会检查目标类是否是枚举类型,如果是,就会抛出 IllegalArgumentException,提示不能通过反射创建枚举对象。这相当于语言层面禁止了反射破坏枚举单例。
针对序列化:枚举在序列化时,写入的只是枚举常量的名称;在反序列化时,JVM 会根据名称去查找已有的枚举常量,根本不会新建对象。所以即使你把枚举单例序列化后再读回来,得到的仍然是同一个枚举常量,不会有第二个实例出现。
克隆也同理。Enum 类本身实现了 Serializable 和 Comparable,还重写了 clone 方法,直接拒绝克隆,枚举常量也没有机会被拷贝出第二个。所以用枚举写单例,等于一次性把反射、序列化、克隆三条破坏路径全部堵死,不需要再加其它防护代码。
4.4 做防护前,先想清楚防护的边界
聊到这里,我要泼一点冷水。防护措施并不是越多越好,投入要跟风险评估匹配。如果你的单例类根本不会参与序列化,readResolve 就不必多此一举;如果类没有被外部框架扫描反射的需求,哨兵也能不加。过度的防护会让代码变得臃肿,干扰阅读。
另外要明白,所有这些防护都只是防御“程序内部的非法创建”。如果攻击者能直接操纵 JVM 进程、改写字节码,那再完美的单例也防不住,这是另一个层面的安全问题,不是设计模式能解决的。我们做防护,防的是代码层面的误操作和常规攻击路径,而不是防不死任何环境下的恶意篡改。
5. 常见环境下的隐性破坏:类加载器、框架容器与分布式
5.1 类加载器环境:一个 JVM 里可能同时存在多个“单例”
反射、序列化、克隆是显式的破坏路径,而类加载器带来的问题更隐蔽。同一个 JVM 中,一个类可以被多个不同的类加载器加载,即使类名完全一样,只要类加载器不同,这两个类在 JVM 看来就是完全不同的两个类。
这意味着,你在 Tomcat 里部署两个 Webapp,各自用独立的 ClassLoader 加载了 com.example.Singleton,于是这个 Singleton 就会分别初始化出一个实例。从整个 JVM 层面看,它的数量超过了一个;从每个 Webapp 自身看,又确实只有一个。
这类问题在常规的单体应用里很少遇到,但在 OSGi、动态插件、热部署、微服务框架的场景里会出现。解决方案说起来有点绕:如果你要的是 JVM 级别的全局唯一单例,就得保证类加载器是同一个;如果你要的是每个应用上下文内的单例,那用枚举或者静态内部类也没问题,因为它只在当前类加载器内保证唯一。
5.2 框架容器里的“单例”语义,和 GoF 单例是两回事
很多使用 Spring 的团队,会把 Spring 管理的 bean 默认为单例,顺手在所有业务类上标一个 @Service 或者 @Component,就默认它是单例了。但这里有一个容易混淆的概念:Spring 的默认 scope 是 singleton,指的是在同一个 Spring 容器上下文里,该 bean 只创建一次,对象由容器缓存、复用。
这和 GoF 单例模式的区别在于:保障唯一性的机制不同。Spring 靠 IoC 容器保证唯一,实例的创建和缓存都归容器管;GoF 单例靠类自身的静态字段和私有构造保证唯一。两者都能让同一个类只有一个实例,但前者不需要类自己实现私有构造,后者也不依赖容器管理。
那么问题来了:如果一个类既是 Spring 管理的 bean,又在代码里写了懒汉式单例,会怎样?结果通常是:Spring 容器创建一个实例,手动 getInstance 又创建一个实例,两个实例并存。这种“双重管理”是项目里最常见的隐性破坏,比反射攻击还常见。所以遇到框架容器时,不要搞双重管理,二选一:要么全交给容器,要么自己在类里实现,并确保外部不会绕开。
5.3 跨进程、跨 JVM 环境里,单例还成立吗
最后一个话题,跨进程。在单体 JVM 内,单例是成立的;但一旦开了多进程部署,或者引入分布式架构,每个 JVM 里都会各自持有一个单例实例。这时全局范围内并不只有一个实例——同一时刻可能有十几个 JVM 各跑着一个 Singleton。
那该怎么办?要区分你的单例管的是什么。管的是 JVM 内共享的线程池、配置客户端,那没问题,每个进程维护自己的单例完全合理;管的是全局锁、分布式 ID、分布式配置,那就不能用本地单例,得借助分布式协调服务、注册中心之类的方案,保证集群层面的唯一性。
这个问题的核心认知是:单例模式的边界就是 JVM,别把它当成分布式环境下全局唯一性的银弹。很多从单体迁移到微服务的团队在上面栽过跟头,以为某个组件原来做成了单例,迁移到多节点后依然能保证唯一,结果集群里每个节点各搞一套状态,业务逻辑怎么对都对不上。这类问题的排查方向,从一开始就应该看向单例的边界定义是否合理。
坦白说,我自己在实际项目里被单例坑过不止一次,事后复盘发现,大多数问题都不是单例模式本身错了,而是把它用在了超出适用范围的场景,或者没有考虑到序列化、反射这类语言机制的存在。写代码的时候不一定每次都能用到这么复杂的防御手段,但把这些底层的加载机制、并发逻辑和边界条件想清楚,遇到诡异问题时的排查方向会准很多。在我看来,这才是把单例模式真正吃透的价值所在。
