单例模式大概是所有设计模式里最“两极分化”的一个。面试必问、期末必考、简历必写,但真正能在项目里用对的人,说实话不多。我见过不少同事把单例当成万能膏药,哪里需要全局唯一就往哪里贴,最后贴出一堆隐藏的线程安全问题和难以测试的静态依赖。也见过有人为了一个简单的配置读取器,硬是写上双重检查锁加Volatile,看得人头皮发麻。
这篇博客不打算只讲“怎么实现单例”——那点东西随便翻翻书都有。我想从一个实际开发者的角度,把单例模式从原理、写法演进、框架源码里的真实应用,到它被滥用的边界,系统拆一遍。无论你是正在准备设计模式期末考的学生,还是在项目中纠结该不该用单例的Java或C#开发者,这篇文章应该都能给你一些不一样的视角。
1. 单例模式到底在解决什么问题
1.1 从“全局唯一”到“状态一致”的真实需求
先回到最根本的问题:我们为什么需要单例?
很多人会说,单例就是保证一个类只有一个实例。这句话没错,但它太表面了。如果你只是想要“一个实例”,直接用静态类或者静态方法不就行了?何必绕那么大圈子搞一个类出来?
单例模式真正的价值在于两点:可控的全局访问点和一致性的状态维护。
举个例子,你在写一个应用,里面有一个全局配置管理器,负责读取配置文件、缓存配置项。如果这个管理器被new出好多个实例,每个实例各自读一遍配置文件,各自维护一份缓存,那就会出现一个经典问题:A模块改了一个配置项,B模块拿到的还是旧值,因为两个模块用的是不同的配置管理器实例。
遇到这种情况,你需要的是一个“大家拿去用的是同一个东西”的保证。单例模式就是干这个的。它通过把构造方法私有化,让外部无法随意new新实例,再通过一个静态方法统一发放实例,从而保证整个进程内只有一个配置管理器的存在。
1.2 单例、静态类和依赖注入的区别
顺着上面的问题,你会发现一个很自然的疑问:同样的需求,用静态类不是也能实现吗?为什么要用单例?
这里涉及一个容易被忽视的点:静态类天然不支持接口和多态。你把一个工具类写成静态方法集合,没问题;但如果哪天你想要替换实现——比如从文件配置换成数据库配置,或者为了测试mock一个假实现——静态类就会让你很难受,因为调用方写的是具体的类名和静态方法,你没法通过接口去抽象它。
单例类则不同,它虽然限制了实例数量,但本质上还是一个正常的类,可以实现接口、可以继承父类、可以传递引用。这意味着你可以定义一个配置接口,然后让单例类去实现它,调用方依赖接口编程,将来换实现的时候只需要换掉那个单例的创建逻辑。
至于依赖注入容器(比如Spring),它在很多场景下确实可以替代单例模式——容器帮你管理Bean的存活周期,默认就是单例的。但依赖注入带来的是额外的框架依赖和运行时复杂度。在一个不需要框架的轻量级场景里,比如Android的一个工具类、一个普通的Java命令行工具,手写单例反而是最轻量、最直接的选择。
单例、静态类、依赖注入,这三者不是互斥关系,而是不同复杂度层级下的不同解决方案。理解了这一点,你才能在不同的场景里做出合适的选择,而不是一上来就无脑单例。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 六种单例写法完整演进:从最基础到最严谨
单例模式的写法五花八门,网上随便一搜能搜出七八种。但如果你真去研究每一种写法,会发现它们不是简单的并列关系,而是一条围绕两个核心问题不断演进的主线:线程安全和懒加载。下面的演进路径是代码层面的“how”,第5节会专门讲“为什么这么设计”,两者结合才能吃透。
2.1 饿汉式:线程安全但不够优雅
先看最简单的一种,饿汉式:
java复制public class EagerSingleton {
private static final EagerSingleton INSTANCE = new EagerSingleton();
private EagerSingleton() {}
public static EagerSingleton getInstance() {
return INSTANCE;
}
}
类加载的时候,INSTANCE就被创建出来了。因为类加载机制本身就保证了线程安全(同一个类加载器下,一个类只会被加载一次,静态初始化也只会执行一次),所以这种写法天然是线程安全的,不需要加锁。
优点很明显:实现简单、线程安全、性能好。缺点也很明显:不管你要不要用,类一加载实例就创建了。如果这个单例对象初始化很重(比如要读取配置文件、建立数据库连接池),而你的应用启动时根本不用它,那就白白浪费了初始化的时间和内存。
不过话说回来,对于绝大多数场景暴,这种“提前创建”的开销其实可以忽略不计——一个JVM启动都要几百毫秒甚至几秒,多创建一个轻量级对象能有啥影响?饿汉式被很多人批评“不优雅”,但它在简单场景里其实是最可靠的选择。我甚至觉得,与其追求各种花哨的懒加载写法,不如先想清楚你的单例到底有多重。
2.2 懒汉式(线程不安全):教科书反面典型
懒汉式的出现是为了解决饿汉式的“提前创建”问题——既然可能用不到,那就等到第一次真正使用的时候再创建。
java复制public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
这段代码的问题太经典了:两个线程同时进入getInstance(),都判断instance为null,然后各自new了一个实例出来,单例被破坏。
它完美地诠释了“懒加载和线程安全是一对需要同时考虑的矛盾”。很多教科书把它列为错误示范,是为了让你意识到:写单例不能只满足于“看起来能工作”,并发环境下必须额外小心。
这个版本最大的价值是教学意义——它引出了后面所有优化的动力。下次看到这段代码,你要想到的不是“这是懒加载”,而是“这在多线程下必挂”。
2.3 方法加锁的懒汉式:安全但性能拉胯
既然上面那个不安全的懒汉式是判断和创建之间出现了竞态条件,那最直接的修复方式就是给方法加锁:
java复制public class SynchronizedLazySingleton {
private static SynchronizedLazySingleton instance;
private SynchronizedLazySingleton() {}
public static synchronized SynchronizedLazySingleton getInstance() {
if (instance == null) {
instance = new SynchronizedLazySingleton();
}
return instance;
}
}
加了synchronized之后,任何时候只有一个线程能进入方法体,线程安全倒是解决了。但问题是,synchronized是方法级别的重锁,即使instance已经创建好了,后续每次调getInstance()依旧要经过锁的竞争和获取。
在单例被频繁调用的场景下,这把锁带来的性能损耗就变得不可忽视了。可能有人会说,现代JVM对synchronized做了很多优化,偏向锁、轻量级锁之类的,性能没那么差。这话没错,但把“一个本可以完全无锁的读操作”硬生生变成“每次都需要经过锁判断”,在代码层面总归是个隐患。
这个版本告诉我们一个重要的思路:锁的范围要尽量小,而且要区分“首次初始化的同步”和“后续访问的同步”。这个思路直接催生了下一个版本。
2.4 双重检查锁(DCL):经典但容易写错
双重检查锁,Double-Checked Locking,是Java面试里高频中的高频。
java复制public class DclSingleton {
private static volatile DclSingleton instance;
private DclSingleton() {}
public static DclSingleton getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (DclSingleton.class) { // 加锁
if (instance == null) { // 第二次检查(有锁)
instance = new DclSingleton();
}
}
}
return instance;
}
}
思路很巧妙:大部分情况下instance已经非空,不需要进入同步块,直接返回即可;只有第一次初始化时才需要加锁保护。这样既保证了线程安全,又避免了每次调用的锁开销。
但这里有个极其容易踩的坑——volatile关键字不能省略。如果不加volatile,在多线程环境下可能出现一个线程拿到“半个对象”的情况。
这就要说到instance = new DclSingleton()这一步了。它不是一个原子操作,在JVM层面大致分三步:
- 分配内存空间
- 初始化对象(执行构造函数)
- 把instance引用指向这块内存
问题在于,JVM的指令重排序可能把第2步和第3步调换顺序。也就是说,如果线程A先执行了第3步(instance指向了一块尚未完成初始化的内存),线程B此时判断instance不为null,直接把这块“半成品”拿去用了,就会出现空指针或者拿到的对象状态不完整。
volatile在这里做的两件事正好对症下药:一是内存屏障,禁止了上面的重排序;二是保证内存可见性,让线程B能立刻看到线程A写入的instance最新值,而不是停留在自己CPU缓存里的旧值。
在C#中,等价的关键字是volatile亦或使用Lazy<T>,实现思路同理。写完双重检查锁之后,最好加注释说明volatile在这里的特殊作用,防止后面的维护者以为它多余然后删掉——我亲眼见过有人这么干过,结果线上出了诡异的偶发空指针。
提示:如果你想让双重检查锁更容易写对,可以坚持一个原则——volatile和synchronized同时出现,一定先想清楚各自承担什么职责:synchronized管“只初始化一次”,volatile管“发布安全的对象引用”。
2.5 静态内部类:目前最优雅的Java实现
虽然双重检查锁很经典,但它在“写法容易出错”这件事上仍然是个挑战。于是还有一种更优雅的方案——利用Java的静态内部类加载机制:
java复制public class StaticInnerSingleton {
private StaticInnerSingleton() {}
private static class Holder {
private static final StaticInnerSingleton INSTANCE = new StaticInnerSingleton();
}
public static StaticInnerSingleton getInstance() {
return Holder.INSTANCE;
}
}
它的原理是:Java类加载规则里,外部类加载时不会加载内部类,只有当你第一次调用getInstance()的时候,JVM才会去加载Holder类,此时才执行内部类的静态初始化,创建实例。
所以它天然实现了两件事:
- 懒加载:Holder类在getInstance()第一次被调用时才加载
- 线程安全:类的加载和静态初始化由JVM保证线程安全
代码写起来比双重检查锁简单多了,既不需要synchronized也不需要volatile,逻辑清晰,性能也很好。在Java世界里,这是我个人最喜欢的写法,也是我在实际项目中用得最多的写法。如果你跟别人推荐单例写法,我建议直接首选静态内部类。
2.6 枚举单例:最反直觉但最“正统”
Joshua Bloch在《Effective Java》里用一整节来推荐枚举单例,原文大意是:“使用枚举实现单例是最佳方式”。理由很硬核,直接解决了一个前面所有写法都解决不了的问题:反射攻击和序列化破坏。
java复制public enum EnumSingleton {
INSTANCE;
public void doSomething() {
// 业务方法
}
}
为什么枚举能抵御这两种攻击?
先看反射。你用反射调用私有构造方法之前,可以先检查一下类是不是枚举:
java复制if (enumSingletonClass.isEnum()) {
throw new IllegalArgumentException("Cannot reflectively create enum objects");
}
Java语言规范里明确规定,枚举类型的实例只能在枚举声明里创建,反射无法绕过这个限制。
再看序列化。对于普通单例类,你把它序列化再反序列化回来,会得到一个全新的实例——单例被破坏。解决办法是写readResolve()方法。而枚举类在序列化时,JVM做了特殊处理:反序列化不会创建新对象,而是直接返回现有的枚举常量。
是不是觉得枚举单例几乎是完美的?是的,但它有一个代价——不够“灵活”。枚举常量一旦定义就不好做懒加载,也不方便像普通类那样在静态代码块里做复杂的自定义初始化逻辑。在绝大多数场景下这不算缺点,但如果你确实需要继承某个父类(枚举默认继承的是java.lang.Enum),那么枚举单例就不合适了。
所以我的建议是:项目里能用枚举就用枚举,这是最不容易被破坏的选择;如果你需要更灵活的初始化逻辑,就退一步用静态内部类,二选一基本能覆盖所有正确需求。
3. JDK和Android源码里的单例实践:参考“大佬”的用法
3.1 从Runtime到Desktop:JDK里的单例身影
理论学习再多,也不如直接在源码里看看官方是怎么用单例的。JDK里最经典的一个例子就是java.lang.Runtime:
java复制public class Runtime {
private static final Runtime currentRuntime = new Runtime();
private static Runtime currentRuntime = new Runtime();
public static Runtime getRuntime() {
return currentRuntime;
}
private Runtime() {}
}
Runtime类封装了应用运行时的环境信息,比如内存状态、处理器数量、执行外部命令等。一个进程里只需要一个Runtime对象去描述这个进程的运行环境,所以JDK毫不客气地用了饿汉式单例——简单、直接、线程安全,一个多余的关键字都没有。
另外还有一个容易被忽略的类是java.awt.Desktop,它用的也是饿汉式单例,getDesktop()返回同一个Desktop实例来执行打开浏览器、打开文件等桌面操作。原因很明显:桌面操作是系统级的,重复创建Desktop对象没有意义,保持单一实例还能避免重复操作带来的资源竞争。
JDK源码里之所以大量使用饿汉式,是因为这些类都太核心了——Runtime、System、Desktop,JVM加载之后几乎必然会被使用,懒加载的意义不大,但饿汉式的简单和线程安全价值很大。这给我们一个启发:选饿汉式还是懒加载,先问自己“这个类会不会在启动后很快被用到”,如果答案是肯定的,没必要搞花里胡哨的懒加载。
3.2 Android源码的InputMethodManager和系统服务注册表
Android的源码也是单例模式的重灾区(褒义)。比如InputMethodManager,它是系统输入法框架的核心管理类,负责处理软键盘的显示和隐藏、输入法切换等。由于输入法服务是系统级别的,整个App只需要一个InputMethodManager实例来跟系统的输入法服务通信,所以它采用了典型的内存级单例。
再比如AccessibilityManager、WindowManager、LayoutInflater这些,全部是单例模式。Android系统自己定义了一套系统服务注册表,App端通过Context.getSystemService()去获取这些服务,本质上是拿到了系统服务的App端代理,而这些代理几乎全是单例实现。这种设计的最大好处是:有限的系统服务资源被统一管理,App端不必重复创建代理对象,也不会有多个代理之间状态不同步的问题。
有意思的是,你去看Android源码里这些单例的实现方式,很多都长得不完全一样——有用双重检查锁的,有用加锁初始化的,也有直接用静态变量初始化的。源码里的单例不是“为了用模式而用模式”,而是真的因为“这个资源应该全进程只有一个,所以我不允许你创建第二个”。想深入理解单例模式的实际价值,Android源码比Java源码更有说服力。
3.3 从源码中得到的重要结论
纵观JDK和Android源码里的单例运用,你会发现一个共同点:这些单例类基本都是重量级的资源管理器,而不是普通的业务对象。
Runtime管理整个进程的运行时状态,InputMethodManager管理系统输入法服务,WindowManager管理窗口系统——它们每一个都对应一个很难被简单重复创建的资源。单例在这里的本质是“抑制重复创建,保护共享资源的独占性”,而不是“为了全局访问方便”。
这也是我给所有读者的第一个建议:先看你的对象有没有“共享资源”属性。如果没有——只是你图省事不想传参——那单例模式大概率是个错误的选择。它迟早会变成测试的噩梦。
4. 面试与考试高频考点:单例的进阶追问与答题策略
单例模式在面试里几乎没有缺席过。面试官问单例,考察的已经不只是实现方式,而是你能否把它背后的问题讲清楚。期末设计模式考试也喜欢在这里出题,比如给一段代码让你说它是不是线程安全、要怎么改。整理一下我问到过的、被问到过的高频题目和对应的分析思路。
4.1 常见追问:这种写法线程安全吗
这是最基础的考法。给你饿汉式、懒汉式、双重检查锁、静态内部类、枚举中的任意一种,让你判断是否线程安全,并说明原因。
答题思路可以固定为两步:
- 找同步机制:代码里有没有synchronized?有没有类的静态初始化?有没有枚举类加载的特殊保证?
- 分析竞态窗口:关键判断是“判断null”和“创建实例”这两个操作之间,有没有可能被另一个线程插队。
比如懒汉式,判断null和new之间可插入,所以不安全;双重检查锁,虽然有两次判断,但第二次判断被包在锁里,创建过程被保护住了,所以安全;静态内部类,Holder的加载过程本身就是JVM级的同步,所以安全。
4.2 深入问题:单例可以被破坏吗
这个问题往下还有分支:除了被破坏,你怎么防御。可以参考第2.6节里面讲的枚举,也可以参考第5.2节的防御编码。
- 反射:调用私有构造方法。防御:构造器里检查已有实例并抛出异常。
- 序列化:反序列化创建新实例。防御:实现readResolve()返回已有实例。
- 克隆:如果是Cloneable,clone()可能创建新实例。防御:重写clone()抛异常。
- 原子性之外,还有类加载器问题:不同类加载器会加载出不同的单例类。防御:指定统一的类加载器。
面试的时候,能把这些点完整讲出来,已经超过绝大多数候选人了。如果还能补一句“所以枚举单例最安全,因为它从语言层面封死了这些洞”,那基本就是满分回答了。
4.3 扩展问题:Spring的Bean和单例模式的关系
很多面试官会顺带问一句:Spring的Bean默认是单例的,那它和单例模式是一回事吗?
我的答案是:不是一回事,但有交集。Spring的单例Bean是容器级别的单例——同一个Spring容器中,同一个Bean定义只创建一个实例。但这个实例的创建不是由类自己控制的,而是由Spring容器控制的。类的构造方法可以是public的,甚至一个普通类,只要Spring容器把它当单例Bean来管理,它在容器范围内就是单例的。
对比一下就很清楚:
- 传统单例模式:类自己管自己的实例,外部无法new
- Spring单例Bean:容器管实例,外部可以通过容器获取,也可以自己new(如果构造方法public)
理解这种区别,能帮你想明白一个问题:在Spring项目里,其实不需要自己手写单例类,直接用@Component默认就是单例的。反而自己去写一大堆单例类,会让代码难以测试、难以替换。
4.4 答题的加分姿势
上面说的都是“会了”才能答出来的内容。但面试不仅是考你会不会,还考你答得有没有条理。我建议按这个顺序组织答案:
- 先一句话说清楚单例模式是什么(保证一个类只有一个实例,并提供全局访问点)
- 再说它解决什么问题(共享资源、全局状态、避免实例重复创建带来的资源浪费和状态不一致)
- 然后给出你用的实现方式,顺带说出为什么选它(比如“我一般用枚举,因为它天然防御反射和序列化”)
- 最后扩展一下优缺点和替代方案(“但在Spring管理容器里,我不会手动写单例,而是交给容器”)
这样答下来,显得既有理论深度,又有实战经验。期末考试的简答题同样可以用这个框架,只是把语言往书本方向靠拢一点就好。
5. 单例模式被黑的最惨的“背锅时刻”:滥用案例分析
单例模式被骂声最大的场景是“滥用”。写到这里,我得说句公道话:模式本身没问题,问题在于太多人把它当成了“方便工具箱”。
5.1 典型滥用一:一个全局可变的配置类
想象这样一个场景:你写了一个AppConfig单例类,里面有几十个字段,每个模块都能通过AppConfig.getInstance()去改字段,简称“全局变量翻身当单例”。
这类单例类的最大问题在于:全局可变状态会把整个项目变成一座无法预测的雷区。你在A模块改了个值,B模块肉眼可见地“灵异”起来;测试的时候,一个测试用例改了配置,另一个测试用例就被污染;多线程环境下,还要处理对所有字段的线程安全问题。
真实的项目里,全局配置应该是集中管理、统一加载的,但它的属性应该是只读的,或者通过受控的方式变更(比如重新加载、观察者通知)。如果你用一个单例来容纳全局可变状态,那麻烦只是个时间问题。
5.2 典型滥用二:代替参数传递的工具人
另一种经典滥用是:开发者不想一层层传参数,干脆造一个单例,谁需要就直接getInstance()去拿。
表面上代码简洁了,实际上对象间的依赖关系全部变成了隐式的。张三模块依赖李四模块,李四模块单例改了点什么,张三就跟着遭殃。你没法从函数签名看出一个方法到底依赖了哪些外部状态,这对代码的可维护性几乎是毁灭性的。
怎么区分该不该用单例?我的经验是看两点:
- 这个对象有没有“进程内唯一”的自然属性?(比如连接池、线程池、缓存管理器)
- 调用方是否真的需要共享同一个实例?
如果两点的答案都是“是”,单例是合理的;如果只是图方便,建议老老实实通过构造方法传依赖,该层层传递就层层传递,代码反而更清晰。
5.3 典型滥用三:单例里塞线程池和定时任务
有人喜欢把线程池、定时任务、消息队列都塞进单例里,图省事。但一旦单例里塞了线程池,它的生命周期管理就变成了一个麻烦:应用退出的时候,谁来shutdown这个线程池?如果没人管,就可能造成线程泄漏,甚至进程无法正常退出。
正确做法是:单例只负责对外提供访问入口,里面的资源需要实现生命周期接口,并且由容器的启动和销毁流程统一管理。如果你手写一个单例线程池,至少要在单例里提供shutdown()方法,并确保在应用退出时调用。
5.4 用单例但不背锅:如何写出可测试的代码
单例被吐槽最狠的是难测试——mock不掉、状态共享、测试用例相互污染。但如果你注意以下几点,这个缺点是可以淡化的:
- 优先考虑枚举或者静态内部类写法,因为它们的创建逻辑好控制。
- 在单例类中要刻意避免保存可变的业务状态,只保存不可变配置或共享资源。
- 如果实在需要依赖外部接口,把单例的“填充依赖”做成包内可见的setter,供测试代码替换,同时在生产代码里不暴露这个方法。
我在公司带项目时定的一个规矩:业务逻辑层禁止直接用单例,单例只允许出现在基础设施层,比如配置管理、缓存访问、数据库连接池。代码跑起来,线上质量稳定了不少,测试也好写了。
6. 从C#到Java,再回到框架:实现单例时的语言差异与最佳实践
6.1 C#里的单例写法
既然网络热词里出现了c#单例模式,这里有必要专门聊一下C#的实现。Java和C#虽然是“近亲”,但在并发模型和语言特性上还是有不少差异。
C#里如果没有特殊需求,我强烈推荐直接用Lazy<T>:
csharp复制public sealed class LazySingleton
{
private static readonly Lazy<LazySingleton> instance =
new Lazy<LazySingleton>(() => new LazySingleton());
private LazySingleton() { }
public static LazySingleton Instance => instance.Value;
}
Lazy<T>默认的线程安全模式是ExecutionAndPublication,意思是只有一个线程会执行初始化方法,其余线程都直接拿到构造好的实例。这一句话就涵盖了线程安全和懒加载两个需求,写起来干净利落。
如果你不想用Lazy<T>,手写双重检查锁在C#里也是可行的,但必须注意一个区别:C#的volatile语义和Java的volatile在内存模型细节上有差异,不过对于单例这个场景,两者都要求同一个点——volatile保证实例引用的写入不会被重排到构造函数执行之前。在C#的.NET Core相关实现中,CLR 2.0之后的内存模型还进一步保证了普通字段写入的引伸性,但为了可读性和可移植性,我依然建议加上volatile。
C#还有一个Java没有的语法点:静态构造函数。C#的静态构造函数由CLR保证只执行一次,因此可以这样实现:
csharp复制public sealed class StaticConstructorSingleton
{
private static readonly StaticConstructorSingleton instance;
static StaticConstructorSingleton()
{
instance = new StaticConstructorSingleton();
}
private StaticConstructorSingleton() { }
public static StaticConstructorSingleton Instance => instance;
}
这段代码的效果和Java的静态内部类很接近:类第一次被访问时,CLR触发静态构造函数,创建实例;CLR层面保证只执行一次,线程安全交由运行时保证。唯一的潜在歧义是cctor的触发时机(beforefieldinit),不过对于单例这种简单场景,影响甚微。
6.2 手写单例 vs 框架容器:现代开发如何抉择
在Java的Spring世界,手写单例的必要性其实已经大幅下降了。Spring容器默认创建的Bean就是singleton作用域,它还替你处理了依赖注入、生命周期销毁、AOP代理这些问题。你再去手写一个单例类,反而会把对象的管理权踢出IoC容器,导致后续想增强、想拦截都没法插手。
Android原生开发里没有Spring容器,手写单例还是很有价值的,但它也是Dagger这类依赖注入框架重点优化的对象。如果你在用Hilt或Dagger,单例同样交给容器去管理,@Singleton注解声明一下即可。
那手写单例还有没有存在意义?有,而且不少:
- 不使用任何框架的小型项目
- 工具类、SDK内部的资源管理类
- 包级私有或库内部的唯一管理器
- 需要在枚举、序列化安全、多类加载器等极端场景下严格控制实例数量的时候
总结起来一句话:能交给容器管理就让容器管,手写单例只出现在容器管不到或不该管的地方。
6.3 我推荐的“最终版单例”代码范式
前面分析了这么多,最后给出一个我认为综合最优的Java实现范式,它兼具懒加载、线程安全、实现接口、可以继承父类、防止反射和序列化破坏——虽然这些特性不会同时出现在一个类里,但下面这套写法是最均衡的:
java复制public class OptimalSingleton {
private static volatile OptimalSingleton instance;
private OptimalSingleton() {
// 防御反射里直接通过反射创建第二个实例的情况
if (instance != null) {
throw new RuntimeException("Cannot create second instance via reflection");
}
}
public static OptimalSingleton getInstance() {
if (instance == null) {
synchronized (OptimalSingleton.class) {
if (instance == null) {
instance = new OptimalSingleton();
}
}
}
return instance;
}
// 防止反序列化时创建新实例
protected Object readResolve() {
return getInstance();
}
@Override
protected Object clone() throws CloneNotSupportedException {
throw new CloneNotSupportedException("Singleton cannot be cloned");
}
}
如果你不想写这么繁琐的防御代码,直接用枚举单例就好了,前面也说了,枚举从语言层面封死了这些洞。两种方式没有绝对优劣,看你的项目风格和约束条件。
6.4 反例:什么时候坚决不能用单例
最后想给你列一个“单例禁用清单”,都是我在实际项目中踩过或见过的:
- 有状态的服务类:比如一个业务Service内部有可变的计数器或有状态缓存——单例会放大并发问题
- 需要被mock的业务依赖:单例的静态入口让mock变得十分麻烦
- 生命周期需要独立管控的对象:比如数据库连接池,随应用启停而不应该被全局共享
- 跨线程的临时数据载体:那应该用ThreadLocal或者显式传递,而不是单例
- 单元测试里的共享状态:测试执行顺序不同会互相污染,最后出现“这套用例单独跑能过,一起跑就挂”的经典现象
能守住这条底线,单例模式在你手里就是一个很好用的工具,而不是一个随时会爆炸的定时炸弹。
7. 总结之外:我对单例模式的真实态度
说了这么多,最后聊几句个人感受。
设计模式这四种字——单例、工厂、观察、策略——在面试和工作里出现的频率最高,但真正理解到位的人很少。很多人把单例当成一种“代码格式”,背了五种写法就觉得掌握了。但实际上,单例更像是一种关于资源所有权和生命周期的设计决策。你问的不是“怎么写单例”,而是“这份资源该归谁管、应该存在几个、全局的访问点设在哪里”。
我最早学单例的时候,也很喜欢折腾各种写法,觉得双重检查锁简直是炫技神器。后来工作久了,反而越来越喜欢简单的写法——能用枚举就用枚举,能交给Spring容器就交给容器。因为代码是给人读的,一个“不花哨但所有人都能秒懂”的方案,比一个“炫酷但需要注释解释半小时”的方案高到不知道哪里去了。
如果你正在准备期末考试或者面试,记住核心结论就是:单例解决的是“全局唯一实例”与“一致性访问”的问题,写法的演进围绕线程安全和懒加载展开,防御反射和序列化最稳妥的选择是枚举,框架容器在大多数场景下可以替代手写单例。
判断该不该用单例,比学会怎么写单例重要得多。先想清楚这两个问题:这个对象在这个进程里是不是天然应该只有一个?访问它的人是不是真的需要共享它?如果都是肯定的,那就放心大胆地用。如果不是,趁早放弃单例,你的代码会感激你。
