Java单例模式全解析:从原理、七种写法到序列化反射坑

发布时间:2026/10/3 10:43:15
Java单例模式全解析:从原理、七种写法到序列化反射坑 做 Java 开发这几年单例模式是少数几个我几乎每天都会撞见的设计模式之一。无论你是准备初级岗位面试还是排查线上状态错乱的问题“单例类”这三个字都会反复出现。你可能已经背过饿汉、懒汉、双重检查但真的碰到过序列化和反射把它“打穿”的场景吗这篇文章我会把单例模式从原理、写法、坑到面试追问一次说透偏实战也会把网上很少讲透的边界问题一起聊完。提示这篇文章适合刚接触设计模式的 Java 新人也适合准备面试、想补全细节的工作党。如果你已经写过几年 Java重点看第 3 章和第 4 章的边界情况。1. 单例模式到底在解决什么问题1.1 从需求场景说起先别急着背代码。单例模式解决的核心问题不是“一个类有且仅有一个对象”这个表象而是资源的重复创建和全局状态的一致性。最典型的场景是配置文件读取。假设你有一个AppConfig类里面封装了数据库地址、Redis 地址、各种业务开关如果每个业务代码都new一次不仅多创建对象更麻烦的是你没法保证“所有代码拿到的都是同一份配置”。一旦某个模块改动了配置对象里的字段其他模块如果没有同步更新立刻就会出现一堆莫名其妙的问题。这个场景在连接池、线程池、日志写入器、硬件抽象对象里同样成立。一个数据库连接池如果被创建了好几个你的连接数会成倍飙升数据库压力直接爆炸日志器如果每个类都有自己独立的实例日志文件锁、缓冲区协调也会变得非常不可控。所以单例模式本质上是在说这类对象在一个进程内只需要一份而且这份实例需要被所有调用方共享。刚学设计模式的时候容易把单例和“全局变量”画等号。区别在于单例把实例的创建过程收拢到了一个类内部使用方不能随便new只能通过固定的方法取实例。这样你可以控制它的初始化时机、初始化方式以及生命周期不会出现“全局变量挂在某个公共类上谁都能改”的失控感。这个设计思路在今天 Java 世界的依赖注入容器、Spring Bean 管理里其实都是延续的。1.2 为什么是“一个类只允许一个实例”这里要回到设计模式的原始定义。单例属于 GoF 23 种设计模式里的创建型模式英文名是 Singleton核心条件有三个构造函数私有化禁止外部直接创建。类内部维护一个自己的静态实例引用。对外提供一个静态方法返回这个全局唯一实例。构造函数私有化这个动作在很多新手看来只是“背下来的语法”实际上它是整个模式的第一道防线。一旦构造方法成了private外部就失去了new Singleton()的能力也就从语法层面保证了实例入口只有getInstance()。通过私有构造函数限制实例化入口是单例模式不可动摇的前提。但仅仅是这样还不够。Java 里有反射、序列化、类加载器这些“后门”它们可以绕过构造权限甚至反序列化时直接生成新对象。这也是后面第 3 章要专门讲的内容。在设计阶段默认认为一个 JVM 进程里只有一个实例这种假设在大部分业务代码里是成立的一旦牵涉到分布式、多类加载器、热部署这个“唯一”就会被打折扣。我见过不少同学在集群环境下排查“为什么单例不生效”其实根源就在于单例的作用域天然是 JVM 进程跨进程时它就不该承载全局唯一状态。2. 七种写法逐个拆解选型与实现2.1 饿汉式最简单但不一定最合适饿汉式可能是你上网搜到的第一份单例代码写法很简单public class EagerSingleton { private static final EagerSingleton INSTANCE new EagerSingleton(); private EagerSingleton() { } public static EagerSingleton getInstance() { return INSTANCE; } }核心逻辑是在类加载阶段JVM 会执行静态变量的初始化此时INSTANCE已经创建好了。由于类加载过程由 JVM 保证线程安全所以getInstance()不需要加任何锁。这种做法最大的优点就是简单、机械、线程安全。但缺点也很明显它是“饿”的只要类被加载实例就立刻被创建。如果你有一百个单例类其中一部分因为启动时的某种链路被触发了类加载即使后续根本没用到这些对象也已经占了内存。假如构造过程中还要读取远程配置、初始化连接池启动时间可能直接被拉长。我之前在一个老系统里排查“启动慢”发现就是十几个单例饿汉式地提前初始化每个都去连接一次数据库而实际上大部分功能根本用不到那些连接。所以我的建议是如果对象的创建成本很低且大概率一定会被用到饿汉式完全可以用如果创建成本高或者想要控制初始化时机请看后面的懒加载方案。2.2 懒汉式从线程不安全到 synchronized懒汉式的出发点很简单第一次调用getInstance()时才创建实例。最基础的写法是这样的public class LazySingleton { private static LazySingleton instance; private LazySingleton() { } public static LazySingleton getInstance() { if (instance null) { instance new LazySingleton(); } return instance; } }这段代码在单线程环境下没有任何问题但在多线程环境下就出事了。假设两个线程同时发现instance null然后都执行了new LazySingleton()返回给调用方的时候可能不是同一个对象。这个问题我用一个多线程循环压测就复现过线程数稍微多一点它就会出现多个实例。最简单粗暴的修复方式是把整个getInstance()方法加上synchronizedpublic static synchronized LazySingleton getInstance() { if (instance null) { instance new LazySingleton(); } return instance; }这样一来线程安全了但代价是每次调用getInstance()都会走一遍方法级同步。在频繁读取单例对象的场景里锁竞争会变成性能瓶颈。有个极端例子某个高并发计数器服务里把每次自增操作都包在了一个synchronized的getInstance()调用上压测时吞吐直接掉了一半。虽然现在的 JVM 会做锁粗化、偏向锁之类的优化但设计上仍然不够优雅。2.3 双重检查锁性能与线程安全的平衡双重检查锁Double-Checked Locking是目前企业代码里最常见的懒加载单例写法。它在getInstance()里去掉了方法级同步只在需要创建实例的那一小段代码里加锁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; } }这里有两个细节必须说清楚。第一为什么要两次if (instance null)第一次判断是为了避免每次调用都进入同步块绝大多数情况下对象已经创建好了直接返回性能最佳第二次判断是防止多线程同时通过第一次判空进入同步块之后重复创建。如果只有外层判断同一时刻两个线程可能分别拿到锁并各自new一个对象最终还是出现多实例。第二为什么要volatile这里踩坑的人最多。instance new DclSingleton()在字节码层面不是一步完成的它大致会经历“分配内存、调用构造器、把引用指向对象”三个阶段。CPU 和编译器可能为了优化而重排指令如果先把引用地址写到了instance字段上然后再调用构造器另一个线程在第一次判空时就会看到一个非空但尚未完整初始化的对象。加volatile后禁止对这个字段的读写进行指令重排序确保引用指向的是构造完成的对象。在 JDK 5 之前Java 内存模型还不太完善就算写了volatile也不一定安全JDK 5 之后这种写法才被公认为正确。所以面试时如果对方问你“为什么双重检查锁的字段要加 volatile”这个回答是标准答案。2.4 静态内部类兼顾懒加载与线程安全既然饿汉式占用了初始化时机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所以INSTANCE不会立刻创建。只有第一次调用getInstance()JVM 才会触发Holder的类初始化过程HotSpot 等 JVM 在类初始化阶段会为静态字段分配并执行clinit这个阶段是线程安全的多个线程同时触发也只会执行一次。换句话说它把“延迟初始化”交给 JVM 的类加载机制比手动加锁更不容易出错。这种写法在《Effective Java》里被强烈推荐也是我个人在工作里最常用的一种单例实现。它比 DCL 少了很多需要解释的边界条件同时语义清楚。你唯一要接受的是“内部类这份写法看着有点绕”但通常加一行注释就够了“用 JVM 类初始化机制实现线程安全懒加载”。2.5 枚举反序列化与反射也无法破坏如果前面的写法都在跟锁、类加载、volatile 打交道那么枚举单例是另一个维度的解法。最简单的形式就是一个枚举常量public enum EnumSingleton { INSTANCE; public void doSomething() { // 业务方法 } }使用方直接EnumSingleton.INSTANCE就能拿到实例。枚举实例的创建由 JVM 保证天然线程安全而且枚举类型没有 public 构造器反射机制在Constructor.newInstance()方法内部对枚举类型做了拦截会直接抛异常反序列化时JVM 也会特地按枚举的name()查找已有枚举对象不会重新创建新实例。也就是说枚举单例同时解决了第 1 章提到的几个“后门”。它为什么在企业代码里反而不常见一方面是因为很多老项目是在枚举语法还不普及、或者团队对枚举认识不一致的年代建立的另一方面是有些人觉得“单例类”总该是个 class用 enum 看起来不太像。但在面试里提到这个写法很容易让面试官觉得你对 Java 语言特性理解得深。如果你的对象状态简单、不需要继承其他类枚举单例完全可以进入你的答案清单。2.6 容器式单例可扩展的“多例注册表”有时项目里的单例不止一个而是一整批服务类都要求全局唯一。比如一个老旧工具库里有UserService、OrderService、LogService你想统一管理它们的创建和获取。这时候一个注册表式的容器会更合适public class SingletonManager { private static final MapString, Object OBJECT_MAP new HashMap(); private static final Object LOCK new Object(); private SingletonManager() { } public static void register(String key, Object instance) { synchronized (LOCK) { if (!OBJECT_MAP.containsKey(key)) { OBJECT_MAP.put(key, instance); } } } public static Object get(String key) { synchronized (LOCK) { return OBJECT_MAP.get(key); } } }这种写法在 Spring 出现之前很常见Spring 的 Bean 容器本质上就是单例注册表的升级版。它的好处是统一生命周期、统一获取入口还方便在测试时替换实现缺点是你在用字符串 key 关联对象编译期检查会变弱一旦 key 写错运行时才发现。实际项目里我会用泛型方法接管一下或者直接交给 Spring 容器管理而不是自己再造轮子。2.7 七种写法选型对比上面总共聊了七种写法包括饿汉式、懒汉式线程不安全、懒汉同步、双重检查锁、静态内部类、枚举、容器式。我习惯用下面这张表做选型写法线程安全懒加载反序列化安全防反射推荐场景饿汉式是否否否创建成本低且必用的对象懒汉式不安全否是否否只用于单线程或学习演示懒汉同步是是否否不追求高频调用时可用双重检查锁是是否否企业代码常见需注意 volatile静态内部类是是否否日常推荐兼顾简单与懒加载枚举是是是是最安全适合状态简单的单例容器式依赖容器实现依赖实现依赖实现依赖实现需要动态管理多个单例注意除了枚举以外其他写法如果要真正扛住序列化都要自己实现readResolve()如果要防反射还需要额外加防护逻辑。这也是我说“枚举单例最安全”的原因。3. 那些“坑”序列化、反射、多类加载器与测试3.1 序列化破坏单例的现场与解法很多人写单例时没考虑过序列化结果把对象存到 Redis、写入本地文件、或者通过 RPC 传输后再反序列化回来发现拿到的对象跟当前 JVM 里的单例不是同一个。严格说反序列化底层是会用ObjectInputStream反射创建一个新实例的即使它的构造器是私有的序列化框架也有自己的绕过路径。复现场景很简单先让单例实现Serializable然后writeObject和readObject走一遍比较两个对象引用你会发现就是 false。解决方案是在单例类里加一个readResolve()方法public class SerializableSingleton implements Serializable { private static final long serialVersionUID 1L; private static final SerializableSingleton INSTANCE new SerializableSingleton(); private SerializableSingleton() { } public static SerializableSingleton getInstance() { return INSTANCE; } protected Object readResolve() { return INSTANCE; } }readResolve()的作用是反序列化时框架先创建临时对象然后调用该方法用返回值替换最终结果。这样外部拿到的就是原有的单例实例临时对象会被丢弃。这个方法签名比较讲究通常是protected Object readResolve() throws ObjectStreamException。如果忘了加或者返回值类型写错序列化仍然会破坏单例。枚举单例没有这个问题所以在《Effective Java》里作者也建议“用枚举实现单例”。3.2 反射攻击与防反射方案比序列化更粗暴的是反射。即使构造器是private反射里的setAccessible(true)也能无视访问权限强制调用构造器创建新对象。比如ConstructorDclSingleton constructor DclSingleton.class.getDeclaredConstructor(); constructor.setAccessible(true); DclSingleton newInstance constructor.newInstance(); System.out.println(newInstance DclSingleton.getInstance()); // false在普通业务代码里没人会故意通过反射创建单例对象但在某些框架、测试工具、攻击脚本里这种事情确实可能发生。如果真的要防常见做法是在构造器里加一枚“是否已经创建”的判断private static boolean created false; private DclSingleton() { synchronized (DclSingleton.class) { if (created) { throw new RuntimeException(单例对象禁止重复创建); } created true; } }注意这个created标志本身也要面对多线程问题所以放在同步块里比较稳妥。更彻底的做法是直接用枚举单例让反射机制从语言层面就拒绝创建。不过这种防御在实际代码中其实很少写因为大多数项目里单例的生命周期控制粒度不会细到反反射。我个人的做法是知道有这种攻击面但不依赖“一个纯 Java 单例无法被反射创建”这个假设去设计安全边界。3.3 多 ClassLoader、集群、分布式下的“单例”早年在 Tomcat 上做热部署的时候我发现一个单例类在重启后出现了两个实例。这不是代码写错了而是同一个类被两个ClassLoader各自加载了一次。JVM 里“类”是由ClassLoader和类名共同决定的不同ClassLoader加载的同名类在运行时属于不同类型所以各自的静态字段也是独立的。Tomcat 的 Web 应用类加载器、OSGi 的模块加载器都会导致这种隔离。要让这种环境下的“全局唯一”成立通常得把实例放进真正共享的容器里或者接受“每个类加载器一个实例”的现实靠外部机制去协调。更重要的是跨进程、跨节点的分布式环境。单例的语义边界是 JVM 进程你在机器 A 上启动一个服务机器 B 上又启动一个两个进程里的单例互不相干。如果业务需要全局唯一锁、全局唯一 ID绝不能指望单例模式去解决那是分布式锁、数据库唯一约束、注册中心之类的活。这个认知在面试里非常重要因为很多人会把“单例”和“全局唯一”混为一谈。3.4 单元测试中如何安全处理单例状态单例模式最被诟病的一点就是测试困难。因为它是全局状态测试用例执行完后单例对象还活着里面的字段、计数器、临时缓存会留在下一个测试用例里。比如你测试一个单例计数器先跑了一段逻辑让 count 5另一个测试类再跑时起始 count 可能已经变了。我有两个实际经验。第一个是尽量让单例对象无状态。所有的请求数据通过方法参数传递不要在单例字段里保存调用方相关的临时数据如果实在需要保存上下文优先用ThreadLocal或其他作用域更明确的容器。第二个是在测试里提供重置入口或使用依赖注入容器。我不太建议直接给单例加一个 public 的 reset 方法那等于主动放开了一个后门更推荐的是让单例的实现依赖可以被替换或者用 Mockito 这类工具去 mock 掉静态方法。Spring 的Bean单例之所以好测就是因为它把实例的生命周期交给了容器测试时可以随时替换 bean 定义。3.5 高并发场景下单例状态的一致性单例负责全局唯一的实例但这个“唯一”不代表里面的状态不会发生并发问题。很多人写了一个单例对象里面放了一个int count然后在高并发下做count结果发现值一直不对。原因很简单单例只保证对象的个数不保证字段的原子性和可见性。volatile只能保证可见性不能保证复合操作的原子性AtomicInteger或锁才是你需要的东西。我在一个统计系统里见过这样的案例一个单例维护着每秒请求数请求打进来就count上线后数值偏差很大。后来换成AtomicLong又配合无锁读取数据才对得上。这类问题面试里经常换个马甲出现比如“单例的实例变量为什么可能有并发问题”本质上考察的是你对线程安全边界的理解。单例是线程安全的一个必要条件但不是充分条件。4. 面试官高频追问与规模化实践4.1 常见面试题速查表单例模式在 Java 面试里的出现频率极高整理几个我遇见过的典型问题按“考察点”列出来问题考察点单例模式的构造方法为什么是 private是否理解实例入口收拢懒汉式和饿汉式有什么区别初始化时机、线程安全权衡DCL 为什么加 volatile是否理解 JMM、指令重排静态内部类单例为什么线程安全是否理解类初始化机制你能写出线程安全的单例吗综合实现能力与细节意识单例会破坏序列化吗怎么解决readResolve 与 enum 的差异反射能创建单例吗防御意识与语言特性掌握Spring 的单例 Bean 和 GoF 单例一致吗容器管理与类管理差异回答这些题的时候不要只背代码最好能主动说出场景和取舍。比如面试官问饿汉式你可以补充一句“如果类加载很早就被触发而对象本身创建成本又高饿汉式可能会拖慢启动”这比单纯背代码有说服力。同样问双检锁时把volatile的指令重排讲清楚很容易让面试官觉得你是有实战经验的。4.2 Spring 框架里的单例与纯 Java 单例的差异使用 Spring 的同学对Service、Component都很熟默认情况下它们是单例 Bean。但这里容易产生一个误解Spring 的单例 Bean 并不是“类的静态字段只有一个实例”而是实例由 Spring 容器创建并缓存在同一个容器内每个 bean 定义只对应一个对象。你完全可以写出两个不同的 Bean 定义指向同一个类的不同配置然后 getBean 拿到两个不同的对象。所以 Spring 单例的“唯一”是容器层面的不是 Java 语法层面的。Spring 选择单例而不是每次 new动机和 GoF 单例很像减少对象创建开销、统一管理生命周期、方便注入依赖。但 Spring 通过依赖注入化解了测试困难你不需要手动调用getInstance()容器负责把依赖塞给你测试时也可以换成 mock 对象。这就比直接写一个XxxSingleton.getInstance()要灵活得多。如果你在实际项目里还在到处写getInstance()而不是依赖注入那么单例模式就会被动成一个隐藏的全局状态容器。4.3 单例模式在实际项目中的滥用和替代方案我踩过的最大的一个坑是把本该是“请求级”或“线程级”的状态放进单例里。比如做一个多租户系统想把当前租户信息放在一个TenantContext单例里结果并发请求一进来A 租户的数据被 B 租户读到线上直接出乱子。这个教训让我养成了一个习惯**写单例之前先问自己这个对象的“唯一”是不是在正确的作用域里**如果作用域是请求应该用RequestScope如果作用是线程应该用ThreadLocal如果作用是进程才考虑单例。替代方案上现代 Java 项目已经很少需要手写单例了。你完全可以用 IoC 容器管理生命周期Spring 里把类声明为Bean或Service默认就是单例。对于纯工具类静态方法加私有构造器可能比单例更简单。只有当你确实需要“一个全局访问点 懒加载 强约束不能被 new”的时候单例模式才值得亲手写。4.4 一个实战重构案例替换全局状态的单例去年处理过一个老模块里面有一个OrderCounter单例负责记录订单号流水public class OrderCounter { private static OrderCounter instance; private int currentNumber 0; private OrderCounter() { } public static synchronized OrderCounter getInstance() { if (instance null) { instance new OrderCounter(); } return instance; } public synchronized int next() { return currentNumber; } }看起来是单例有锁也有线程安全但它有一个致命问题订单号是整个应用共享的可它的作用域被锁死在了进程里。一旦服务部署到多节点每个节点的订单号都会从 1 开始最后数据库就会出现主键冲突。这种问题不是加不加锁能解决的也不是单例模式本身的错而是你把“进程内唯一”误当成了“全局唯一”。重构时我把订单号的生成逻辑从单例里拆出来改成由数据库发号器或 Redis 自增发号OrderCounter也可以删掉了。这个案例给我的启发是**单例模式不会把作用域变大它只负责在当前作用域里保证唯一。**做架构设计时先画清楚对象的生命周期边界再决定要不要用单例顺序不能反。我个人经过不少线上问题的洗礼最终的选型习惯是普通业务对象优先交给 Spring 容器默认单例独立工具类直接静态方法需要懒加载又不想引入容器的场景用静态内部类追求绝对安全的场景用枚举。每次写单例前我都会再多问一句“这个实例的边界真的应该是整个 JVM 吗”想清楚了代码自然就少很多坑。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询