单例模式可能是23种设计模式里最简单、也最容易被面试官抛出来的一个。但根据这些年我帮团队做代码评审的经验越是简单的模式在实际项目里翻车的概率反而越高——有人写出线程不安全的懒汉式还浑然不觉有人用双重检查锁却忘了加volatile还有人把配置类全都做成单例结果多环境跑不通。这篇文章我不打算只照着教科书念一遍定义和优缺点而是把这些年踩过的坑、和同事争论过的设计问题一起聊透。不管你是临近设计模式考试的在校生还是准备面试的初级开发或者已经在生产环境里被单例坑过一轮的工程人这篇内容应该都能给你一些不一样的东西。1. 为什么单例被认为是最被高估也最被低估的设计模式1.1 单例模式到底在解决什么问题先说清楚一个容易被误解的地方单例模式不是为了让代码能访问到某个对象而存在的。如果你只是因为不想一路传参数、想在任何地方直接拿到一个对象那你真正需要的是全局变量不是单例。单例模式解决的是实例唯一性和访问一致性这两个问题。所谓实例唯一性就是整个进程生命周期内这个类只能有一个实例存在访问一致性是指所有人都得从这个唯一实例上读取和修改状态不能各拿各的副本。打个比方一个家庭里只能有一个总电闸。你不能在客厅、卧室、厨房各装一个总电闸那样关一个屋子的时候整栋房子的电全乱了。总电闸这个角色天然就应该是一个单例——它本身有状态开/关而且这个状态必须全局一致。配置管理器、日志对象、线程池、数据库连接池都是类似的性质多个实例没有意义多个实例只会带来状态分裂甚至把数据库连接资源耗光。1.2 什么场景应该用单例什么场景不该用我梳理了一下真正适合用单例的场景基本都逃不出下面这几类配置管理全系统读取同一份配置任何一处修改必须全局生效。连接池/线程池资源本身是有限且昂贵的多个实例等于重复占用资源。硬件/设备管理比如打印机队列、支付串口、相机硬件物理设备只有一个软件层就不能造出两个控制器去抢。全局计数器或无状态服务入口比如访问量统计器必须保证计数一致。但现实项目里很多单例是被强行设计出来的。最常见的误用是代码里需要共享某个对象就直接把它做成单例至于这个对象是不是真的要求全进程唯一根本没想过。比如多数据源项目里你把每个数据源配置都做成单例那第二套数据源就永远初始化不起来多租户系统里把当前租户信息做成单例租户一多就是事故现场。还有一个典型误用是把有状态但不要求全局唯一的组件做成单例——比如某个用户会话对象一旦做成单例用户A的操作就会串到用户B那里。1.3 单例与全局变量的本质区别单例经常被吐槽是优雅一点的全局变量这个说法不算冤枉。两者都能让你在代码任意位置拿到同一个对象但单例比全局变量多做了几件正事受控的创建时机全局变量在类加载时就初始化单例可以延迟到真正被调用时才创建。受控的访问入口全局变量是直接暴露字段任何人都能随便覆盖单例通过getInstance()提供访问构造器私有外部没有再new一个的可能。线程安全的保障全局变量不加额外手段就是裸的共享状态单例实现通常自带线程安全策略。换句话说全局变量是把对象扔到公共广场上谁都可以动单例是给唯一实例配一个管理员所有人只从管理员手里取。不过一旦你把单例内部的状态设计成完全公开可变的它也就退化回了全局变量这一点后面聊滥用时还会展开。2. 线程安全视角下的四种经典写法与踩坑复盘单例的写法五花八门网上随便一搜能列出七八种。但站在工程角度真正有讨论价值的就四种饿汉式、懒汉式、双重检查锁、静态内部类。外加一个Java特有的枚举方案我放到后面单独说。2.1 饿汉式简单可靠的默认选择饿汉式是所有写法里最稳的也是一个我认为没什么好犹豫、适合大多数场景的默认选择public class EagerSingleton { private static final EagerSingleton INSTANCE new EagerSingleton(); private EagerSingleton() {} public static EagerSingleton getInstance() { return INSTANCE; } }它靠JVM的类加载机制保证线程安全——static final字段在类加载阶段完成初始化JVM保证一个类的加载过程在同一个线程里执行其他线程只能等加载结束所以不会有并发问题。那为什么很多人鄙视饿汉式核心槽点是启动时就要创建实例。如果单例的构造函数里有重活比如建立网络连接、加载大数据文件、初始化连接池那饿汉式就会拖慢应用启动速度而且哪怕这个单例根本没被用到它也会被白白创建。我见过一个项目启动耗时2分钟排查了半天发现是一个日志配置类在构造函数里做了一个全量缓存预热而这缓存根本没用上。但说句公道话如果你的单例构造函数很轻或者你根本不在乎那点启动时间饿汉式基本就是最好的选择——代码最少、线程安全、不存在反射之外的破坏途径。2.2 懒汉式的线程安全演进从synchronized到DCL懒汉式的初衷是延迟到第一次调用时才创建实例。最基础的写法长这样public class LazySingleton { private static LazySingleton instance; private LazySingleton() {} public static LazySingleton getInstance() { if (instance null) { instance new LazySingleton(); } return instance; } }这个写法在单线程下完全没问题但并发环境下两个线程可能同时通过if判断各自new一个实例单例就破功了。初学者最容易在这里踩坑因为本地测试永远跑不出并发问题上线之后某个深夜流量高峰Bug就冒出来了。最简单的修复是给方法加public static synchronized LazySingleton getInstance()。加锁确实解决了线程安全但代价是每次调用都要抢占锁。拿配置查询这种高频操作来说每秒几十万次调用性能损耗会很直观。于是就有了双重检查锁Double-Checked LockingDCLpublic 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; } }第一次判空是为了避免无谓的加锁已初始化就直接返回第二次判空是在真正确认没有其他线程抢先创建。这个写法兼顾了懒加载、线程安全和性能是在懒加载前提下最主流的方案。2.3 静态内部类的延迟加载机制DCL虽然好用但代码量多而且volatile这个关键字本身就劝退了一批人。如果既想懒加载又想要最简单的线程安全写法静态内部类是更优雅的路子public class HolderSingleton { private HolderSingleton() {} private static class Holder { private static final HolderSingleton INSTANCE new HolderSingleton(); } public static HolderSingleton getInstance() { return Holder.INSTANCE; } }它的原理基于JVM对类初始化阶段的处理外部类HolderSingleton加载时并不会加载内部静态类Holder只有第一次调用getInstance()、真正访问Holder.INSTANCE时JVM才会触发Holder的类加载和初始化。因为Holder内部的INSTANCE依然是static final字段所以类加载机制再次保证了线程安全同时又天然实现了延迟加载。我在实际项目里比较推荐这种写法。它的性能跟饿汉式几乎没差别也没有DCL那种需要跟同事解释为什么双重检查的心智负担。2.4 双重检查锁为什么必须加volatile这个问题面试常考但很多人只是背答案。它牵扯到JMMJava内存模型里的指令重排序问题。instance new DCLSingleton()这一行代码在字节码层面不是一条指令而是三步在堆内存中为对象分配空间调用构造器完成对象初始化把引用赋值给instance问题在于CPU和编译器在确保单线程结果一致的前提下允许对指令进行重排序。在第2步和第3步之间是有可能先执行第3步、再执行第2步的。想象一下线程A执行到把引用赋给instance时切换出去此时instance不为空但对象还没初始化完成线程B进来看到instance ! null直接返回这个半成品随后调用上面的方法就可能在运行时抛出空指针或拿到一堆默认值。这就像你看到快递包裹已经放到门口引用已赋值但里面东西其实还没装好对象未初始化完成。volatile关键字在这里起的作用是禁止对instance赋值的前后指令进行重排序同时保证可见性——线程A写入instance后线程B能立刻看到最新值而不是自己线程栈里的旧值。不加volatileDCL就是一个隐形的坑而且这个坑很难复现跟运气强相关。如果把四种写法放一起对比大概长这样写法线程安全懒加载性能开销代码复杂度饿汉式安全否无低懒汉式基本版不安全是无低懒汉式synchronized安全是高低双重检查锁 DCL安全是低中静态内部类安全是无低3. 序列化与反射摧毁单例的两个隐形杀手很多人以为把构造器写成private单例就固若金汤了。真实情况是Java的反射机制和序列化机制都有办法绕过这个private约束让单例诈尸出第二个实例。3.1 反射破坏单例的原理与防御手段反射可以调用setAccessible(true)来强制访问私有构造器。代码如下Class? clazz DCLSingleton.class; Constructor? constructor clazz.getDeclaredConstructor(); constructor.setAccessible(true); DCLSingleton second (DCLSingleton) constructor.newInstance();只要这么一搞不管你的getInstance()写得多么天衣无缝新实例照样能创建出来。对此比较常见的防御手段是在私有构造器里加一个标志位检查public class SafeSingleton { private static boolean created false; private SafeSingleton() { synchronized (SafeSingleton.class) { if (created) { throw new RuntimeException(单例构造器被非法调用); } created true; } } }这样第一次反射调用也会走构造器此时created已经是true就会抛出异常。但坦率讲这只是增加了攻击成本并不是绝对防御——攻击者完全可以通过反射先把created字段改成false再调用构造器。所以如果你的类要暴露给不受信任的代码使用单靠代码层面防反射其实防不住只能靠安全管理器这类JVM级机制来限制反射权限。对于绝大多数业务系统来说这个威胁模型没那么严重大家通常加的也就是个标志位。3.2 序列化破坏单例的原理与readResolve修复序列化是另一个更隐蔽的破坏途径。一个单例类如果实现了Serializable那么通过反序列化得到的对象和原来那个单例对象是两个完全不同的实例。这是因为反序列化过程中readObject()并不走构造器而是直接在堆上创建新对象private构造器拦不住它。修复方式是在类里加一个readResolve()方法public class SerSingleton implements Serializable { private static final SerSingleton INSTANCE new SerSingleton(); private SerSingleton() {} public static SerSingleton getInstance() { return INSTANCE; } protected Object readResolve() { return INSTANCE; } }ObjectInputStream在反序列化结束后会检查有没有readResolve()方法如果有就用它返回的对象替换掉新创建的对象。这样一来反序列化拿到的依然是原来的单例实例唯一性保住了。这里有个细节readResolve()的可见性没有强制要求但习惯上写成protected而不是private原因是为了配合CGLIB或代理机制生成的子类能正确覆盖它。3.3 枚举为什么是天生防破坏的单例Joshua Bloch在《Effective Java》里力荐用枚举实现单例核心原因就是JVM在语言层面规定枚举实例只能有一个反射和序列化都绕不过去。public enum EnumSingleton { INSTANCE; public void doSomething() { // 业务逻辑 } }反射方面newInstance()对枚举类型会直接抛出异常没法通过反射创建第二个枚举实例。序列化方面枚举的序列化机制和普通类完全不同——它序列化的是枚举实例的名称反序列化时用Enum.valueOf()按名称查找已有实例而不是新建对象。在很多Java技术群里有一个争论枚举单例不支持懒加载。其实准确地说枚举类在第一次被主动使用比如调用INSTANCE或枚举里的静态方法时才加载天然也是延迟性的不过这种延迟的粒度是整个枚举类的加载不算完全意义上第一次调用实例时才初始化但实际项目中这点差别基本可以忽略不计。枚举单例真正的限制是你不能让它继承某个抽象父类因为隐式继承了java.lang.Enum。如果确实需要继承那就只能回到静态内部类方案了。4. Spring容器里的单例和设计模式单例是什么关系在Spring项目里讨论单例很容易出现概念混淆。很多开发者把Spring的单例Bean和设计模式的单例画等号然后会发现一套完全对不上号的规矩这里值得单独掰扯一下。4.1 Spring的单例Bean不等于单例模式Spring容器的默认scope就是singleton但此单例非彼单例。Spring单例的含义是在同一个Spring容器中同一个Bean Name只会对应一个实例容器负责创建、缓存、管理这个实例。它是容器级别的复用从类本身的角度看构造器依然可以通过反射被任意调用类也没有强制自己只能有一个实例。而设计模式单例的约束力来自类自身私有构造器、静态方法、唯一实例标志属于类级别自管制。Spring则是靠着容器缓存配方完成了多个Bean之间共享同一个实例的目标类本身并不需要做任何配合。换句话说Spring工程里的单例Bean绝大多数都不是设计模式意义上的单例类。4.2 容器管理单例的生命周期与可测试性Spring这么设计的根本原因是要把实例管理这件事收编到容器手里让开发者只专注于业务代码。容器可以做到的事情包括单例初始化之前先处理依赖注入、在Bean上叠加AOP代理、在销毁时回调PreDestroy方法、按需多实例切换scope为prototype等等。这对可测试性是很关键的加分项。设计模式单例因为持有私有构造器和静态方法要想在单元测试里替换依赖基本要动用Mockito这类工具去mock静态方法。而Spring单例Bean通过接口注入测试时你new一个普通的实现类塞进去就行不需要动任何静态状态。举个真实场景。我在一个支付项目里把支付网关客户端做成了经典的私有构造器单例。后来测试环境要模拟网关超时我需要给这个单例注入一个假的超时时间结果发现完全没法下手——单例自己管自己测试代码根本换不掉它的内部依赖。后来重构时把这个类改成Spring单例Bean把网关地址、超时时间全从配置里注入测试代码只需要组装一个配置不同的实例就搞定了。4.3 有状态组件用单例Scope为什么是定时炸弹Spring容器默认给所有Bean都是singleton scope所以很多开发者写代码时压根不去想这个scope是不是合适。对于无状态的Service、工具类、数据访问类单例scope完全没问题但凡是内部持有了可变状态的Bean用singleton scope都可能翻车。最典型的例子是用于记录当前请求用户的组件。比如一个UserContext类内部用一个静态或实例字段保存当前登录用户如果配成singleton那所有请求线程共享这一个UserContext实例用户A的请求还没处理完用户B就把它覆盖了。等到A后面再读拿到的是B的信息。为了避免这种问题Spring提供了RequestScope和SessionScope分别用于一次请求内复用和一个会话内复用。这里面的核心认知是单例模式的本质是唯一性而不是共享性。你要想清楚的是——这个组件在整个进程里到底允许多少个实例共存。对于每个请求都有自己的状态这类需求哪怕数据看起来是共享的也不该用单例Scope来硬扛。5. 实战中的单例滥用什么时候应该放弃单例聊到最后我想说点更贴近职场实战的内容。设计模式本身是中性的工具问题出在使用它们的人。单例模式因为上手门槛低被滥用的频次远超其他模式。5.1 单例对代码可测试性的伤害单例对可测试性的最大伤害来自隐式依赖和可变全局状态。当一个业务类直接调用SomeManager.getInstance()时它和SomeManager之间的依赖关系是隐藏在方法内部的——你阅读代码时看不到构造器参数里有这个依赖测试时也没法轻易替换它。更扎心的是状态污染。单例对象在进程级别只有一个它内部保存的状态天然就是全局的。测试A往配置单例里写了一个测试配置测试B如果再读这个配置拿到的就是测试A写进去的残留数据两个测试互相污染谁也没法稳定复现问题。这就像全班同学合用一个答题卡第一个人涂了答案第二个人上来只能涂改最后分数算谁的都说不清。有经验的做法是尽量让单例保持无状态化或者把状态依赖全部通过方法参数传入而不是存在单例字段里。如果一个单例无法做到这两点你就需要认真考虑它是不是真的适合做单例了。5.2 依赖注入如何替代裸单例如果你用Spring这类依赖注入框架其实有比手写单例更好的选择把类注册成容器中的单例Bean由容器来保证从一个Bean名拿到的永远是同一个实例然后通过构造器或Setter把这个依赖注入到使用方。相较于裸单例这种方案有三个务实的好处测试友好测试时可以直接注入一个自定义实现不需要动静态状态。可见性提升使用者通过构造器参数看到依赖关系代码意图更清晰。生命周期可控容器接管实例销毁资源释放不用在静态方法里硬编码。在非Spring环境比如纯JDK的组件库我一般建议优先使用静态内部类单例而不是DCL或枚举。不是因为DCL不好而是很多时候项目成员水平参差静态内部类是最不需要脑子、也不容易写错的那种。团队协作里代码的可维护性和可理解性往往比技术上的极致优化更值钱。5.3 一句话判断该不该用单例我总结了一个特别朴素的判断标准分享过好几次反馈都还不错如果你说不清楚这个类多创建几个实例会造成什么具体的、可复现的问题那它大概率不需要做成单例。配置多一份会读错值那是配置模型设计有问题不是单纯单例能解决的。连接多一份会耗尽连接数那是真需要单例。日志写两份其实Logger本身就该是共享的这个初衷是性能而不是唯一性。反过来凡是你能明确说出多个实例会导致状态不一致或者创建成本极高必须复用的类单例就是合理选择。用这个标尺去卡一遍项目里的单例代码至少能筛掉一半纯粹出于顺手而写的单例。我个人的习惯是能不用就不用必须用的用静态内部类或枚举避免DCL在多人协作时被改坏。等哪天你真的遇到多实例会炸的场景再引入单例你会更清楚自己在保护什么东西。这样写出来的代码模式才是长在逻辑里的而不是贴在类名上的。