我前阵子参加一次架构评审,一位同事在PPT里放了张单例模式的类图,底下立刻有人问:“这玩意儿不是面试题吗?我们系统里真的需要专门讨论吗?”我当时没接话,但心里清楚,这恰恰是系统架构设计里最容易被低估的一个模式。表面上看,单例模式就是“保证一个类只有一个实例,并提供一个全局访问点”,好像三分钟就能讲完,但真到了生产环境,光“实例唯一”这四个字就能衍生出线程安全、类加载、序列化、反射攻击、分布式部署等一系列问题。
这篇文章我想认真聊聊,在系统架构设计的语境下,单例模式到底在解决什么问题,什么场景真的该用,什么场景是在滥用,以及Java、C#、Python这三种主流语言在落地单例时各自有哪些坑。这篇是“上”篇,先聚焦理论和实现形态,适合正在做系统设计、或者准备重构老项目的人参考,也适合那些把单例模式背得滚瓜烂熟、但没在真实项目里踩过坑的开发者看。
1. 架构视角下的单例模式:它到底在管什么
1.1 单例的本质是生命周期管理,而不是“限制 new”
很多教材把单例模式定义为“确保一个类只有一个实例”,这个说法没错,但它太偏语法层面了。从系统架构设计的角度看,单例模式真正做的是对象生命周期管理——它规定了一个对象从创建到销毁的完整策略:何时创建、创建几份、谁能访问、什么时候释放。
我习惯把系统里的对象分成两类。一类是“无状态工具型”,比如字符串工具类、日期转换器,它们内部不保存任何成员变量,谁调用都一样,这种对象其实不需要单例,每次new一个也不会有问题,只是浪费点内存。另一类是“有状态共享型”,比如配置文件加载器、数据库连接池、线程池、日志记录器,它们内部保存着全局唯一的状态,如果系统里存在两份,轻则配置不一致,重则连接资源被重复创建、日志写入混乱,甚至并发场景下出现脏数据。
单例模式真正要管的是第二类。它通过限制构造函数和统一访问入口,把“一个类只能有一份实例”这个约束,从程序员的自律变成了代码层面的强制规则。我个人认为,这才是架构师关注单例模式的核心原因:你是在用代码结构约束整个团队的行为边界,而不是依赖每个人写代码时都“记得”不要new多个实例。
1.2 全局变量和单例之间,差着“可控性”
有人会说:想要全局唯一,用静态变量不就行了?public static Config config = new Config(); 一句话的事。对,早期很多系统确实这么干,但静态变量有三个问题。
第一,初始化时机不可控。静态变量在类加载时初始化,如果这个类的构造过程依赖其他资源(比如读取配置文件、建立网络连接),那它在类加载阶段就强制执行,一旦资源暂时不可用,整个应用启动就失败。第二,访问权限不可控。静态变量是公开可写的,任何代码都能把它重新赋值,不巧某个同事在业务代码里写了一句Config.config = null,全系统直接崩溃,而且这种问题极难排查。第三,没有统一的创建逻辑。如果后续你需要在创建实例时做参数校验、做延迟加载、做代理增强,静态变量这种写法完全没法扩展。
单例模式本质上是在静态变量的基础上,增加了“可控的创建时机”和“不可绕过的访问入口”。构造器私有化,全局访问点统一走getInstance(),这样你可以在方法里加延迟加载、加锁、加校验,甚至未来换成IoC容器管理,只改这一个方法即可,对调用方完全透明。
1.3 架构设计中单例的“优先级”问题
还有一个架构层面的点容易被忽略:单例模式是设计模式里少有的“创建型模式中影响面最大”的一个。它影响的不是某个类内部怎么写,而是这个类在整个系统中的角色定位。你在架构设计阶段决定“这个组件是单例”,实际上就决定了它的扩展方式。
举个例子,一个消息推送服务,如果设计成单例,意味着同一时刻全系统只有一份推送通道。单机部署时没问题,但将来要扩展成多实例部署时,“每个JVM里都有一份单例”这件事本身就制造了跨实例的共享状态问题。所以架构师在设计单例时,必须同时思考一个问题:“这份唯一性,是只在进程内唯一,还是需要在集群维度唯一?”如果是后者,单例模式本身已经不够用了,你得引入分布式锁、共享存储或者专门的状态中心。这个认知非常重要,能帮你避免“单例导致水平扩展困难”这种经典架构事故。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 什么场景适合单例,什么场景是误用
2.1 适合场景:持有全局资源的“管理器”
从实际项目经验来看,最适合用单例的就是资源管理器类。这类对象的核心特征是自己持有底层资源,并且资源创建成本高、重复创建会有副作用。
- 配置管理器:加载一次配置文件,全系统共享。如果每次使用都重新加载,性能和内容一致性都出问题。
- 连接池/线程池:数据库连接池、HTTP连接池、线程池,这些池化资源天然需要单例,否则池化就失去意义。
- 日志门面:日志Logger通常是单例的,日志文件句柄、日志级别、日志上下文,必须全局统一。
- 硬件访问接口:比如访问串口、USB设备、摄像头,物理设备通常只允许一个客户端持有控制权。
- 系统级缓存:本地缓存组件,如果每个调用方各建一份缓存,内存翻倍且数据无法同步。
这些场景的共同点是:你想让某个“全局状态”在整个系统里保持一致,而且这个状态值得被集中管理。单例模式就是这种诉求的制度化表达。
2.2 误用场景:把单例当“省内存”的手段
有一种很常见的误用是用单例来“省对象创建的开销”。一个无状态的对象,比如OrderValidator、DateUtil,每次创建的开销几乎可以忽略不计,完全不需要单例。但有些团队会统一规定“所有Service必须写成单例”,这种一刀切的做法,短期看没啥问题,长期看会让代码陷入“伪单例”的泥潭。
什么叫伪单例?就是一个类被设计成单例,但它在方法里使用了实例字段保存临时状态。比如有人写了一个ReportService,单例,里面有个private List<String> errors字段,每次生成报告时往里塞数据。如果两个线程同时调用,errors列表就会串数据。这种问题在普通类里也有,但当类是单例时,被多线程共享的几率大大增加,bug出现得更隐蔽、更随机。
所以我的建议是:单例模式应该留给真正需要“全局唯一”的资源,而不是作为节省对象的通用手段。JVM创建对象的开销比你想象中小得多,单例带来的风险却比你想象中大得多。
2.3 需要警惕的“自动单例”:Spring Bean 默认就是单例
提到系统架构设计,就不能不提Spring。很多人在Spring项目里根本没手写过单例模式,但系统里的Service、Mapper、Controller几乎全是单例——因为Spring的Bean默认作用域就是singleton。框架帮你做了这件事,你在代码里再手写一套单例模式,反而画蛇添足。
但这里有个容易翻车的地方:Spring单例和纯粹的单例模式并不完全等价。Spring单例是“每个Spring容器里一个实例”,如果你的系统里有多个Spring容器(比如一个应用里嵌入了多个AnnotationConfigApplicationContext),那同一个类可能存在多份实例,彼此互不感知。另外,Spring的@Scope("prototype")注解能打破单例,如果你在单例Bean里注入了一个原型Bean,还会出现“单例持有多例”的经典问题。
所以在架构设计中,你要搞清楚“单例的边界在哪里”——是JVM级、ClassLoader级、Spring容器级,还是进程级。我见过不少团队排查了几天的问题,最后发现只是由于同一个类被多个ClassLoader加载,导致静态字段各自独立,“单例”了但没完全单例。下文的实现部分会再展开这个问题。
3. 从饿汉到枚举:六种经典实现的演进逻辑
3.1 饿汉式:简单可靠,但可能白等一场
先看最经典的饿汉式实现:
java复制public class ConfigManager {
private static final ConfigManager INSTANCE = new ConfigManager();
private ConfigManager() {
// 加载配置
}
public static ConfigManager getInstance() {
return INSTANCE;
}
}
饿汉式的思路是“类加载时就创建实例”。它的好处显而易见:实现简单、线程安全(类加载机制天然保证了线程安全性,JVM保证了<clinit>方法在多线程环境下只会执行一次)、不会有竞态条件。
但坏处同样明显:初始化时机被提前了。如果应用启动时某些配置文件还没就绪,或者构造过程很耗时,饿汉式会让应用启动时间变长。更尴尬的是,如果这个类在整个生命周期里根本没人调用,这个对象也被白白创建了,属于“为了可能的使用提前买单”。
在实际架构中,饿汉式适合那种“几乎肯定会被用到、并且初始化不依赖外部环境”的类。比如日志门面,几乎任何请求都会用到,早加载无所谓。但如果你的类要连接外部服务(比如初始化一个RPC客户端),我一般不推荐饿汉式,因为外部服务在应用启动时不一定可用。
3.2 懒汉式:延迟加载的“天真版”与“靠谱版”
懒汉式是为了解决饿汉式“过早创建”的问题,思路是“用的时候才创建”。最原始的版本长这样:
java复制public class ConfigManager {
private static ConfigManager instance;
private ConfigManager() {
// 加载配置
}
public static ConfigManager getInstance() {
if (instance == null) {
instance = new ConfigManager();
}
return instance;
}
}
这个方法在单线程下没问题,但多线程环境下,两个线程同时判断instance == null为true,然后各自创建实例,单例就失效了。所以最简单粗暴的修复方式是给方法加synchronized:
java复制public static synchronized ConfigManager getInstance() {
if (instance == null) {
instance = new ConfigManager();
}
return instance;
}
加了synchronized后线程安全了,但性能也降了:每次调用getInstance()都要获取锁,而绝大部分时间实例已经存在,获取锁纯属浪费。这个方法在早期很流行,但在高并发的架构里,它会成为热点瓶颈。我见过一个压测报告,单例方法加锁后TPS掉了一半,定位到这儿时大家都挺无语的——明明可以不加锁的。
3.3 双重检查锁定(DCL):性能和安全都要,但必须配 volatile
双重检查锁定是目前最常用的懒加载单例实现:
java复制public class ConfigManager {
private static volatile ConfigManager instance;
private ConfigManager() {
// 加载配置
}
public static ConfigManager getInstance() {
if (instance == null) {
synchronized (ConfigManager.class) {
if (instance == null) {
instance = new ConfigManager();
}
}
}
return instance;
}
}
这段代码的核心思路是:先做一次无锁的判空,如果实例已经存在就直接返回,避免锁竞争;只有第一次调用时,两个线程才有机会同时进入判空分支,此时再用锁保证只有一个线程创建实例。锁内部的第二个判空必不可少,否则第一个线程创建完实例后释放锁,第二个线程进锁后又会重新创建一个实例,单例又被破坏了。
这里最关键的是volatile关键字,这也是面试里最爱问的点。instance = new ConfigManager()这一行并不是原子操作,它底层分为三步:分配内存、调用构造器初始化对象、把引用赋值给instance变量。JVM在某些情况下会进行指令重排,把“赋值”这个动作提前到“构造器初始化”之前。如果在重排后、对象还没初始化完成时,另一个线程读到了instance不为null,就会直接返回一个半初始化的对象,后续使用必然出问题。volatile的作用就是禁止这个重排,保证“赋值”必须发生在对象完整构造之后。
注意:在JDK 5之前,JMM和
volatile的实现存在缺陷,DCL在Java里并不安全。但JDK 5之后volatile的语义被修正,DCL才能正确工作。如果你维护的是老项目,要确认JDK版本。
从架构角度看,DCL适合那种“创建成本高、不启动时加载、需要多线程安全”的类。它是懒加载和并发安全的平衡点,也是我日常使用最多的写法。
3.4 静态内部类方式:利用类加载机制,兼顾懒加载和线程安全
除了DCL,还有一种非常优雅的方式——静态内部类:
java复制public class ConfigManager {
private ConfigManager() {
// 加载配置
}
private static class Holder {
private static final ConfigManager INSTANCE = new ConfigManager();
}
public static ConfigManager getInstance() {
return Holder.INSTANCE;
}
}
这个写法的巧妙之处在于:Holder是一个静态内部类,它不会在ConfigManager类加载时被加载,只有在getInstance()方法第一次被调用、主动引用Holder.INSTANCE时,JVM才会加载Holder类。而JVM在加载类并执行其<clinit>方法时,会隐式地加锁,保证多线程环境下只初始化一次。所以这个实现同时具备了懒加载和线程安全两个特性,代码也比DCL简洁很多,不需要volatile,不需要显式synchronized。
从我的实际使用体验来说,静态内部类方式在大多数场景下可以替代DCL。它的唯一“缺点”是无法控制构造时抛出的异常对调用方的影响,但这个问题在真实项目中几乎不会触发。如果你在写Java 5以上的代码,我优先推荐这种方式。
3.5 枚举单例:最安全的物理级单例
用枚举实现单例是Joshua Bloch在《Effective Java》里强力推荐的方式:
java复制public enum ConfigManager {
INSTANCE;
private Properties config;
ConfigManager() {
// 加载配置
}
public void load(String path) {
// 具体加载逻辑
}
}
枚举单例之所以被认为是“最安全”的,是因为JVM从底层保证了枚举实例只被创建一次,而且枚举天然能抵御两种常见的破坏手段:反射和序列化。
反射攻击单例的原理是通过setAccessible(true)把私有构造器强行改为可访问,然后调用newInstance()创建新实例。但枚举类没有构造器(内部是有构造器的,但反射API明确规定不允许通过反射创建枚举实例),所以这条路被堵死了。序列化破坏单例的原理是:反序列化时,JVM不调用构造器,而是通过特殊机制创建对象,如果单例类的readResolve()没写对,就可能得到一个新实例。而枚举的序列化由JVM特殊处理,保证序列化前后是同一个对象。
我对枚举单例的评价是:适合那种“严格不允许被外部创建、需要在序列化/反射场景下依然保持单例”的类。但它也有个不太方便的地方——枚举的初始化时机在类加载时,所以它实际上是“饿汉式”的变种,延迟加载能力有限。另外,枚举不能继承类(可以实现接口),如果你需要单例类继承某个父类,枚举就无能为力了。
3.6 六种实现横向对比:怎么选才不纠结
| 实现方式 | 线程安全 | 延迟加载 | 防反射破坏 | 防序列化破坏 | 适用场景 |
|---|---|---|---|---|---|
| 饿汉式 | 安全 | 否 | 弱(需额外防护) | 弱(需额外防护) | 启动时必定使用的全局对象 |
| 懒汉式(方法加锁) | 安全 | 是 | 弱 | 弱 | 不推荐,性能太差 |
| DCL双重检查 | 安全 | 是 | 弱 | 弱 | 高并发且需要懒加载 |
| 静态内部类 | 安全 | 是 | 弱 | 弱 | 最推荐的Java实现方式 |
| 枚举 | 安全 | 否 | 天然防护 | 天然防护 | 严格要求物理唯一的场景 |
补充一个小知识点:饿汉式、懒汉式、DCL和静态内部类其实都无法天然防反射。如果项目里存在反射攻击风险(比如对外提供SDK,调用方可能恶意使用反射),要么升级为枚举单例,要么在构造器里加一个标志位,第二次进入构造器时直接抛异常。这个技巧后面讲排查时会再提。
4. C# 和 Python:不同语言生态里的单例落地
4.1 C# 单例:Lazy<T> 是首选
C#发展到现在,手写DCL已经不是最优解了,因为微软在.NET 4.0里直接提供了Lazy<T>类型,专门用来解决延迟初始化问题。用Lazy<T>实现单例,代码非常干净:
csharp复制public class ConfigManager
{
private static readonly Lazy<ConfigManager> _lazy =
new Lazy<ConfigManager>(() => new ConfigManager());
private ConfigManager()
{
// 加载配置
}
public static ConfigManager Instance => _lazy.Value;
}
Lazy<T>的默认线程安全模式是ExecutionAndPublication,它保证“初始化代码只会执行一次”,并且所有等待的线程都能看到初始化完成的实例。这相当于把DCL的复杂逻辑全部收敛到框架内部,开发者不需要理解指令重排,不需要写volatile,也不容易写错。
.NET 6之后甚至还有更简洁的写法,可以直接用Lazy<ConfigManager>的Value属性做全局访问点。此外,C#也支持静态构造函数实现单例,因为静态构造函数由CLR保证只执行一次,不过要注意它的执行时机——静态构造函数是在第一次访问该类型的静态成员时触发,所以它实际上是“半饿汉式”。
C#生态里另一种常见单例风格是“完全静态类”,即把所有成员声明为static。但静态类和单例模式有个本质区别:静态类不能实现接口、不能作为参数传递、不能做依赖注入。这在架构设计中是个大问题,因为你想mock这个类来做单元测试会非常困难。所以即使是在C#里,我也建议优先使用真正的单例实例,而不是静态类。
4.2 Python 单例:模块导入机制自带“天然单例”
Python实现单例的方式和Java、C#比有个非常独特的点:Python的模块机制本身就自带单例效果。一个.py模块只会被导入一次,后续再import时直接使用缓存,所以你在模块里创建的对象天然是唯一实例。
python复制# config_manager.py
class ConfigManager:
def __init__(self):
self.config = {}
config_manager = ConfigManager()
别的文件里这样用:
python复制from config_manager import config_manager
config_manager.load("app.yml")
无论config_manager这个模块被多少个文件导入,config_manager变量始终指向同一个实例。这就是“模块级单例”,它的优点是零样板代码、绝对线程安全(导入锁由解释器保证),缺点是导入时机不太好控制——如果模块导入时这个类的构造依赖环境变量或外部参数,你可能会在其他代码执行前就意外触发了初始化。
如果你的确不想用模块级单例,想用“类”本身来保证唯一性,那最常见的做法是在__new__方法里做控制:
python复制class ConfigManager:
_instance = None
def __new__(cls):
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
但要注意,这个写法不是线程安全的。多线程环境下需要加锁,或者使用threading.local来隔离。Python还有一个更“神奇”的方式是使用元类(metaclass),本质上是把单例逻辑抽到元类里,让所有使用该元类的类自动变成单例。这个方案适合框架级别的封装,日常业务代码没必要上这么重的机制。
还有一个容易踩的坑:__new__控制单例时,如果类里有__init__,每次调用ConfigManager()都会重新执行__init__,导致实例属性被重置。你明明以为拿的是旧实例,结果它的内部状态被新调用“清空”了。这是Python单例实现里最常见的隐蔽bug,下一篇讲实战排查时我会再详细说。
4.3 三种语言的单例对比:各有各的“心智负担”
| 语言 | 推荐实现 | 最隐蔽的坑 | 架构层面注意点 |
|---|---|---|---|
| Java | 静态内部类或枚举 | DCL忘写volatile导致半初始化对象 | 多ClassLoader环境下单例会失效 |
| C# | Lazy<T> | 静态构造函数时机不可控 | 静态类无法面向接口设计 |
| Python | 模块级单例 | __new__控制但__init__仍会执行 |
多进程部署时每个进程各自单例 |
做跨语言架构设计时,千万不要把一个语言里“想当然”的语义直接套到另一个语言。比如Python的模块级单例在单进程内非常可靠,但如果你用multiprocessing开多进程,每个子进程的模块导入是独立的,单例在整个集群维度就完全失效了。这就回到我在开头说的:单例的边界必须在一开始就定义清楚。
5. 常见问题与排查技巧实录
5.1 序列化竟然能破坏单例?
Java的单例类如果实现了Serializable接口,就有一条隐秘的路径可以创建新实例:反序列化。反序列化时,JVM会通过ObjectInputStream读取字节流并创建对象,这个过程不调用构造器,也不走getInstance(),所以即使你的构造器是私有的,它也能硬生生造出一个新实例。
解决方法是给类加一个readResolve()方法:
java复制protected Object readResolve() {
return getInstance();
}
这样反序列化时,ObjectInputStream会检查有没有readResolve()方法,如果有,就用它的返回值替代反序列化创建的对象,从而保证返回的仍然是那个单例实例。这个坑非常隐蔽,很多人在线上压测时发现“单例类出现了好几百个实例”,追查下来才发现是Redis存了对象、取出时反序列化导致的。
5.2 反射攻击:私有构造器也不是铁桶
Java的反射API非常强大,它可以调用setAccessible(true)把私有构造器改成可访问的,然后直接newInstance()。这在某些框架里是正常操作(比如Spring实例化Bean时就会用反射绕过私有构造器),但对单例模式来说就是攻击路径。
如果你对单例的安全性有硬要求,最简单的防御是加一个标志位:
java复制public class ConfigManager {
private static boolean created = false;
private ConfigManager() {
synchronized (ConfigManager.class) {
if (created) {
throw new RuntimeException("单例实例已存在,禁止反射创建");
}
created = true;
}
}
}
这个方案配合DCL或静态内部类都能用。不过要提醒一句:如果框架本身就依赖反射创建这个对象(比如某些IoC容器),那这个防线会误伤框架逻辑。所以加这个防御之前,先想清楚你的类到底会被谁实例化。
5.3 多ClassLoader环境下“单例失效”
这个问题的隐蔽性极高。同一个类如果被两个不同的ClassLoader加载,它们在JVM里就是两个完全不同的类,各自的静态字段互相独立,所以单例实例也会出现两份。常见的触发场景包括:应用服务器(比如Tomcat)的WebAppClassLoader和公共类加载器加载了同一个类;或者你在一个Java应用里动态加载插件,每个插件有自己独立的ClassLoader。
排查这类问题时,不要急着看代码逻辑,先用:grep全局搜索类的全限定名,看看它被哪些ClassLoader加载了。一种确认方案是在类里加一个静态字段记录ClassLoader信息,打印出来对比,如果多个实例的getClass().getClassLoader()不一致,那基本就是ClassLoader隔离的问题。对这个场景,单例模式本身是无解的,你得从部署架构层面解决,比如把公共类放到共享类加载器路径下,或者接受“每个ClassLoader一份单例”的事实。
5.4 并发初体验:如何快速验证你的单例是安全的
写了一个单例实现后,如何验证它真的安全?我有一个非常简单的“暴力测试法”:写一个多线程测试,让几百个线程同时调用getInstance(),然后检查拿到的对象引用是否全部相同。
java复制public class SingletonTest {
public static void main(String[] args) throws Exception {
int threadCount = 200;
final Set<ConfigManager> instances = ConcurrentHashMap.newKeySet();
CountDownLatch latch = new CountDownLatch(threadCount);
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
instances.add(ConfigManager.getInstance());
latch.countDown();
}).start();
}
latch.await();
System.out.println("不同实例数量: " + instances.size());
}
}
如果输出是不同实例数量: 1,说明当前的并发实现没有创建出多个实例。但这只是第一步,更深入的测试还要检查“半初始化”问题——也就是验证对象内部的字段是否完整可见。这类问题不容易通过简单的引用比较发现,你需要在单例实例里放一个复杂对象,多线程读它的内部状态,如果出现部分字段为null的情况,就说明指令重排或者可见性出了问题,这时要检查是不是漏了volatile。
5.5 框架里的“假单例”:每次注入都拿到同一个吗
最后分享一个排查频率很高的场景。在Spring项目里,很多开发者以为只要Bean默认是单例,就万事大吉了。但有人会发现同一个Service每次注入的实例似乎“不一样”,其实可能不是同一个类的问题,而是你注入了一个代理对象。比如@Transactional标注的Service,Spring会创建一个CGLIB代理类,你拿到的实际是代理实例,而不是原始类实例。这个代理实例的内部会转发到真实Bean上,所以行为上仍然是单例,但如果你用==去比对,拿到的两个代理对象可能不相等(因为每次注入时Spring可能返回同一个代理,但某些配置下会不同)。
排查这类问题时,不要依赖==判断,应该用applicationContext.getBean()获取实例后,检查它的Class类型和cglib标志。如果类的名称里包含$$EnhancerByCGLIB$$,说明你看到了代理对象,真正的单例在后面。理解这个层面,有助于你在架构排查中快速定位问题边界,避免误把框架机制当成单例bug。
写在(上)篇的最后:我的几条单例设计原则
回头整理这篇内容时,我自己重新反思了一遍过去几年在项目里用单例的经验。有几次单例给我们团队惹了麻烦,回过头看,其实问题不是单例模式本身,而是我们没有想清楚“这个实例在哪个维度上唯一”。
我现在已经形成了一个很朴素的设计习惯:先问自己三个问题——这个对象是全系统共享的吗?创建这个对象的成本高吗?破坏单例会带来数据错误还是只是性能损耗?如果三个问题的答案都是“是”,那单例模式值得用;如果有任何一个答案模糊,我会倾向于让容器来管理生命周期(比如Spring),而不是手写一个静态的单例。
下篇我会重点讲单例模式在真实业务系统里的扩展话题:Spring中的单例Bean和设计模式意义上的单例有什么异同,单例组件的并发性能压测方法,以及单例与线程池、分布式锁结合时容易踩的坑。如果这篇文章帮你梳理清了一些模糊认知,那这篇“上”篇的任务就算完成了。
