单例模式的唯一性契约:五种写法、三类破坏路径与误用信号 去年排查过一个挺典型的故障某个服务的数据库连接数在流量平稳的情况下持续攀升最后把连接池打满。线程栈翻了三遍才发现那个负责读取配置的客户端在同一个进程里被初始化了两遍两套连接池、两套定时刷新任务同时跑。代码里明明写着getInstance()问题出在另一处初始化路径绕开了它。那次之后我对单例模式的理解彻底变了——它不是少 new 几次的语法糖而是一份关于进程内唯一资源的契约写错一个关键字契约就作废。这篇文章想讲的就是这份契约怎么落地。从为什么需要单例、五种主流写法各自在赌什么到反射、序列化、类加载器这三条真实的破坏路径再到什么时候你压根不该用单例。适合已经能写单例但被坑过、或者正准备在项目里引入单例的人看。不敢说覆盖全部但至少把我这些年踩过的、review 时拦下来的坑都摊开讲一遍。1. 从一次重复初始化说起单例到底在防什么1.1 一个对象被创建两次的真实代价很多教程把单例讲成保证一个类只有一个实例这句话对但不够用。真正要问的是为什么这个类只能有一个实例答案永远落在资源上。配置管理器重复创建意味着配置文件被读两遍、监听器被注册两遍、变更通知发两次。连接池重复创建意味着实际的连接数是你配置值的两倍监控面板上看着正常数据库那边已经被打穿。日志器重复创建缓冲区各写各的日志顺序错乱排查问题时看到的时序是假的。缓存组件重复创建更隐蔽两个实例各持一份数据A 写进去 B 读不到业务上表现为偶发查不到数据最难查。这些问题的共同点是症状出现在离病因很远的地方。你看到的是连接数超限病因在初始化代码的一行静态方法调用。所以单例的价值不只是省内存而是把这个资源在进程内有且只有一份这件事变成代码结构上的强制约束让后来的人不容易绕过。反过来说如果一个类没有独占资源、没有共享状态、创建成本可以忽略给它套单例就是纯粹的负担。我见过给纯计算工具类加单例的除了让单元测试变难没有任何收益。1.2 单例的三条硬约束与它的真实作用域一个合格的单例实现同时满足三件事控制实例化构造函数不可外部调用、保证唯一性同一作用域内只存在一份、提供访问点有稳定的获取方式。前两条是本质第三条是形式。很多实现只做到了第三条——构造函数是 public 的或者干脆是个静态工具类被误叫成单例。比这三条更容易被忽略的是作用域。单例的唯一从来不是绝对的它一定绑定在某个边界上作用域层级唯一性由谁保证典型失效场景类加载器ClassLoader 的加载机制插件化、热部署、多加载器共存JVM 进程类的静态初始化机制多进程部署、fork 子进程容器依赖注入容器的 Bean 管理多容器实例、手动 new集群外部协调组件单机内存计数器当限流器绝大多数人写的单例作用域是单个类加载器。这意味着同一个 JVM 里如果存在两个 ClassLoader 都加载了这个类你会得到两个单例。这个坑在普通业务里很少遇到一旦上了插件架构或者热加载就是灾难。更常见的是把进程内单例当成集群唯一。某次做接口限流有人用单例里的AtomicInteger计数单机测试完美上了四台机器后实际阈值变成四倍直接把下游打挂。这不是单例的错是作用域认知的错。单例只能管住一个进程跨进程的事得靠外部存储或协调服务。所以我在设计阶段会先问一句这个唯一要管到哪一层答案决定了实现方式也决定了它能不能用单例。2. 五种写法分层拆解从能跑对到跑不坏2.1 饿汉式简单背后是对类加载时序的赌注饿汉式是最短的写法也是最多人用的public class AppConfig { private static final AppConfig INSTANCE new AppConfig(); private AppConfig() { // 读配置、建连接池 } public static AppConfig getInstance() { return INSTANCE; } }它能保证线程安全原因不在代码本身而在语言规范类的静态初始化由 JVM 加锁执行一个类在同一个类加载器下只会初始化一次多线程同时触发初始化时只有一个线程执行clinit其余线程阻塞等待。这是语言层面的保证比自己写synchronized可靠得多。代价在于初始化时机不可控。触发类初始化的条件包括创建实例、访问静态字段、调用静态方法、反射加载、初始化子类。也就是说只要有任何一处碰到了这个类实例就会被创建哪怕业务上此刻还不需要。如果构造函数里做了重活——读远程配置、建连接池、加载大文件——这个开销会前移到启动阶段或者某个意外的第一次访问点启动时间和首次请求延迟都会受影响。还有个更隐蔽的坑构造函数抛异常。静态初始化期间抛出的异常会被包装成ExceptionInInitializerError而且这个类会被标记为初始化失败后续任何访问都会抛NoClassDefFoundError错误信息还丢掉了原始堆栈。线上见过一次因为配置文件临时不可读导致整个服务起不来报的还是个看起来毫不相关的NoClassDefFoundError排查花了不少时间。我的判断标准很直接构造函数里没有 IO、没有可能失败的远程调用、创建成本可以忽略才用饿汉式。否则宁可多写几行换成延迟加载。2.2 懒汉与双重检查volatile 到底拦住了什么最朴素的懒汉式长这样public static synchronized Singleton getInstance() { if (instance null) { instance new Singleton(); } return instance; }正确但把整个方法锁住了。实例一旦创建完成后续每次调用仍然要抢同一把锁而 99.9% 的调用只是读一个已经确定的引用。在 QPS 高的路径上这会变成一个全局串行点。虽然现代 JVM 对无竞争的锁做了不少优化但 contention 一起来代价就真实了。于是有了双重检查锁DCLpublic 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(); } } } } }这段代码被讲了无数遍但volatile为什么必须有很多人只能背答案。拆开看new Singleton()在字节码层面大致是三步——分配内存并清零、调用构造函数初始化、把引用赋给instance。这三步里第 2 步和第 3 步之间没有数据依赖编译器和处理器都可能把赋值提前。单线程下无所谓因为没人会在中途观察多线程下就有问题了线程 A 执行到第 3 步完成、第 2 步还没结束时instance已经是非 null 了。线程 B 走到外层if看到非 null直接返回这个对象。此时对象的内存已经分配但字段还是零值——一个半成品被拿去用了字段读出来是 null 或者 0报错位置离真正的原因十万八千里。volatile在这里的作用是禁止这种重排序同时保证写操作对其他线程立即可见。两个语义里DCL 主要依赖的是前者。需要注意的是这个保证是在 JSR-133 之后才明确下来的很早的语言版本里 DCL 本身就被认为是错误用法这也是为什么有些老资料会说别用 DCL。还有一个实操层面的细节instance字段必须是static且不能被final修饰final加volatile编译不过构造函数私有且要防止内部再调用getInstance()——在构造函数里调自己的getInstance()会造成死循环或拿到未初始化对象这种写法我 review 时见过不止一次。2.3 静态内部类与枚举把线程安全交给语言静态内部类的思路很巧把实例的创建推迟到另一个类的初始化上。public class Singleton { private Singleton() {} private static class Holder { private static final Singleton INSTANCE new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } }它同时拿到了饿汉式的线程安全同样是clinit的锁和懒汉式的延迟Holder只在getInstance()被调用时才初始化。外部类加载不会触发内部类初始化这是关键。相比 DCL它不需要volatile不涉及任何显式同步代码也短可读性更好。我个人的默认选择就是它。它的边界也得说清楚如果外部类还有别的静态字段或方法引用了Holder延迟就失效了另外它依然挡不住反射和序列化这两条下面单独讲。枚举是语言层面给出的最强保证public enum Singleton { INSTANCE; private final MapString, String cache new ConcurrentHashMap(); public String get(String key) { return cache.get(key); } }枚举常量的实例化由 JVM 在类初始化时完成且规范明确规定每个枚举常量只会被创建一次。更关键的是它天然免疫两种破坏反射调用Constructor.newInstance()创建枚举时会被直接拒绝并抛IllegalArgumentException序列化时枚举不走普通的对象序列化路径反序列化通过名字查找返回同一个常量对象。代价有几条枚举不能继承其他类已经继承了Enum所以如果单例需要继承某个基类这条路走不通枚举常量在类初始化时就全部创建不能延迟如果单例有复杂的构造参数比如要从配置文件读值枚举写起来很别扭另外某些 JSON 序列化框架默认把枚举序列化成字符串名字对外接口返回枚举类型时需要额外处理。把这几种写法放一起对照选择会清晰很多写法延迟加载线程安全防反射防序列化可继承适用场景饿汉式否是否否是无 IO、创建轻量懒汉同步方法是是否否是调用频率低双重检查锁是是否否是高频调用、需要延迟静态内部类是是否否是通用默认选择枚举否是是是否无继承需求、强安全表格里防反射防序列化两列全是否不是这些写法写得不好而是它们解决的是不同层次的问题——前者管实例创建后者管实例被复制。这两件事必须分开处理混在一起谈就容易得出某某写法不安全的错误结论。3. 三条真实的破坏路径与修补办法3.1 反射穿透私有构造函数私有构造函数只是编译期和运行期的访问检查setAccessible(true)可以直接绕过ConstructorSingleton c Singleton.class.getDeclaredConstructor(); c.setAccessible(true); Singleton second c.newInstance(); System.out.println(second Singleton.getInstance()); // false要挡这一下得在构造函数里加一道判断public class Singleton { private static final AtomicBoolean CREATED new AtomicBoolean(false); private Singleton() { if (!CREATED.compareAndSet(false, true)) { throw new IllegalStateException(单例已被创建禁止再次实例化); } } }用AtomicBoolean而不是普通布尔字段是因为反射调用可能发生在多线程环境下普通字段的检查-设置不是原子操作两个线程同时反射仍可能都通过。用 CAS 至少能保证只有一个成功。但必须说清楚这不是绝对防御。反射不仅能调构造函数还能改私有静态字段把CREATED重置回 false 再来一次。所以这段代码的真实价值是提高门槛、防止误用而不是抵御恶意攻击。如果场景里真的存在不可信的反射调用方比如加载第三方插件正确做法是隔离类加载器让单例类根本不在对方的可见范围内。语言层面的绝对防御只有枚举那一条路。3.2 序列化与反序列化产生的第二个实例这条比反射更容易被忽略因为它藏在一个看起来完全正常的操作里。单例实现Serializable之后反序列化不走构造函数——JVM 会直接分配对象并填充字段得到一个全新的实例。Singleton a Singleton.getInstance(); ByteArrayOutputStream bos new ByteArrayOutputStream(); new ObjectOutputStream(bos).writeObject(a); ObjectInputStream ois new ObjectInputStream(new ByteArrayInputStream(bos.toByteArray())); Singleton b (Singleton) ois.readObject(); System.out.println(a b); // false很多人写单例时顺手 implements 了Serializable尤其是要放进分布式缓存或者会话里的时候却没意识到这一行破坏了唯一性。修补方法是加readResolveprivate Object readResolve() { return getInstance(); }反序列化流程在读完对象之后会检查类里有没有readResolve有的话就用它的返回值替换掉刚创建的对象。方法必须是private或者至少是能被反序列化机制识别到的可见性返回类型用Object或具体类型都可以。另外还有个writeReplace作用是在序列化时就把对象替换掉适合单例内部持有大量不需要序列化的字段时使用。两者选一个即可readResolve更常见。实测下来我的建议是如果单例确实需要序列化优先考虑改成枚举如果因为要继承或延迟加载不能用枚举readResolve是必写项而且要写单元测试覆盖——这条逻辑太容易被后来的人重构掉测试是唯一的保险。3.3 类加载器与进程边界单例在哪里失效前面提过单例的唯一性建立在同一个类加载器加载同一个类这个前提上。一旦这个前提破了静态字段就不共享了。ClassLoader loaderA new URLClassLoader(urls, parent); ClassLoader loaderB new URLClassLoader(urls, parent); Class? clazzA loaderA.loadClass(com.example.Singleton); Class? clazzB loaderB.loadClass(com.example.Singleton); // clazzA ! clazzB各自的 INSTANCE 也是两份这种结构在插件化、热部署、多版本共存等场景里很常见。表现是重启才生效或者配置改了但读不到因为新的加载器创建了新的实例老的还在跑。排查手段很简单在需要的地方打印一句Singleton.class.getClassLoader()看看是不是每次都一样。如果不一样说明存在多加载器。解决方案只有两条要么把单例类放到父加载器通常是启动类加载器或平台类加载器能加载的路径下让所有子加载器共享同一份要么放弃单例改由一个统一的管理者持有实例通过参数传递。后者更啰嗦但在插件架构里更可靠。再往外一层是进程边界。单例管不到别的进程这在容器化部署下尤其要清醒。任何依赖内存里只有一份的逻辑——计数器、限流器、本地缓存的一致性、任务调度去重——在多实例部署下都会失效。这类需求要么借助外部存储做原子操作要么接入专门的协调组件。单例在这里帮不上忙用它反而会制造看着对、实际错的假象。4. 误用现场单例退化成全局变量的几个信号4.1 可测性崩塌与隐式依赖单例最被诟病的不是并发问题是可测性。因为实例是全局可达的任何一段代码都可以随手XxxManager.getInstance()于是依赖关系从构造函数的参数列表里消失了。后