☰
Java单例模式全解析:五种写法、线程安全与防破坏实战
2026/10/3 10:43:09 网站建设 项目流程

单例模式大概是整个设计模式里代码量最少、但最能“拷问”功底的一个了。Java 单例类的经典写法网上随便一搜就是一大堆,可真正能把它讲透、用对的人并不算多。我在带项目和招人的时候经常发现:有人加了 synchronized 就觉得万事大吉,有人写了双重检查锁却漏了 volatile,还有人以为枚举单例只是个语法糖,根本说不清它为什么能防反射破坏。这篇文章不打算做教科书式复述,而是把单例从“为什么需要”到“五种写法怎么选”,再到落地验证、常见陷阱和面试深挖点,完整过一遍。不管你是刚接触设计模式的 Java 初学者,还是准备跳槽啃八股的面试党,都应该能从中拿到可以直接用的东西。

1. 为什么需要单例:它到底在解决什么问题

1.1 从“两套配置”事故讲起

先说一个我实际踩过的坑。几年前做支付对账系统时,配置中心模块需要统一加载商户密钥和回调地址。最初设计很随意:每个需要配置的 Service 都自己new ConfManager(),结果线上出了一次事故——运营在后台更新了商户回调地址,其中一个模块还抱着旧配置,导致一批回调验证失败。定位半天,发现原因就是各个模块各自持有一份实例,改动只对新建的实例生效。

这个场景特别典型:当系统里多个地方需要共享同一份数据或者同一个资源时,每new一次,就等于复制了一个“小世界”,各自为政,状态完全不互通。后来改成单例,所有模块通过同一个入口获取同一份配置,问题立刻消失。所以单例的第一个价值,就是保证“全局唯一”,让状态天然一致。

1.2 单例解决的三个核心痛点

从那次事故往后,我再看单例模式,心里就有了一张清晰的图:它其实在同时解决三个层面的问题。

  • 资源层面的浪费。线程池、数据库连接池、HTTP Client、日志 Logger 这类对象,创建成本极高。每 new 一次就要建立一批连接、分配一大块内存,十个模块就是十份开销。单例让这类重量级对象在进程内只存在一份,省下的资源非常可观。
  • 状态层面的一致性。配置信息、计数器、缓存数据这些会变化的状态,一旦被多个实例各自持有,就会互相覆盖、彼此不同步。单例把状态收敛到一个实例上,所有调用方读写同一份数据,从根上避免“两套配置”的闹剧。
  • 访问层面的统一入口。业务代码如果到处手动创建、传递共享对象,逻辑会散落且难维护。单例提供一个全局访问点,调用方一行代码拿到实例,不用知道底层实现细节。

这里可以用一个生活化的类比:一个公司只能有一个 CEO。全世界任何部门要汇报,都只能找这一个人,不能每个部门自己“任命”一个 CEO。CEO 就是那个唯一实例,汇报入口就是getInstance()。

1.3 单例不是“全局变量替身”

有同学看完上面三点,容易走另一个极端:什么东西都塞进单例。我在代码评审里见过把订单实体、用户信息、临时缓存全做成单例的项目,最后并发问题一团糟。这里必须说清楚:单例适合的是“进程生命周期内、本身无私聊状态的共享对象”。如果对象要承载每个线程特有的数据,或者涉及高并发下的频繁写操作,强行单例只会把并发压力集中到一个点,甚至引入严重的线程安全问题。

换句话说,单例保护的是“唯一性”,不等于“随处可用”。设计一个单例类之前,先问自己三句话:这个对象真的需要全局唯一吗?它的生命周期能对齐进程吗?它的状态在并发访问下安全吗?三句里有任何一句犹豫,就该重新考虑方案。

2. 五种主流单例写法:选型思路与代码实现

2.1 饿汉式:类加载时保证唯一实例

饿汉式是最直白的写法,核心代码就一行静态 final 字段:

public class EagerSingleton { private static final EagerSingleton INSTANCE = new EagerSingleton(); private EagerSingleton() { // 保护构造器,防止外部 new } public static EagerSingleton getInstance() { return INSTANCE; } }

它的线程安全完全靠 JVM 保证:类加载阶段,静态字段初始化只执行一次,而这个阶段由 JVM 的类加载机制做了同步,多线程不可能同时进入。所以不需要额外加锁,性能最好。

但它的缺点也明显:类一被加载,实例就创建了,不管后面用不用。如果这个单例对象的构造逻辑很重,比如连接数据库、加载大文件,那么它会在启动阶段白白消耗时间,还可能导致启动变慢、内存被提前占用。适合饿汉式的场景是:实例创建开销小、类几乎一定会被用到,例如工具类、常量中心。

有些资料管它叫“饿汉”,字面意思就是“饿得等不及,类加载时我就把对象吃掉”——这个命名挺形象,记住就忘不掉。

2.2 懒汉式与双重检查锁:性能和安全如何兼顾

和饿汉相对的是懒汉式:第一次调用getInstance()时才创建实例。很多人最开始写的版本长这样:

public class LazySingleton { private static LazySingleton instance; private LazySingleton() {} public static synchronized LazySingleton getInstance() { if (instance == null) { instance = new LazySingleton(); } return instance; } }

synchronized保证同一时刻只有一个线程能进入方法,线程安全是有了,但代价很大:每次调用getInstance()都要抢锁,哪怕实例早就创建好了。读一个已经存在的对象引用,根本不需要锁。这种写法在低并发下没问题,一旦成为热点方法,锁竞争会拖慢整体性能。

于是就有了双重检查锁(Double-Checked Locking,DCL):

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; } }

外层检查是为了避免每次调用都进同步块,实例已存在时直接返回,性能接近饿汉。内层二次检查是为了防止两个线程同时通过外层判断后,依次进入同步块创建出两个实例。整个设计非常精巧。

这个写法有一个绝对不能被省掉的关键词:volatile。省略它,DCL 就是在埋雷。至于为什么,我放到第四章的“重排序陷阱”里专门讲,这里先记住结论:Java 单例里的 DCL 必须配合volatile才能做到真正的线程安全。

2.3 静态内部类:按需加载与线程安全的自然结合

如果你既想要懒加载,又不想写synchronized,还想让代码看着干净,静态内部类基本是最优解:

public class StaticInnerSingleton { private StaticInnerSingleton() {} private static class Holder { private static final StaticInnerSingleton INSTANCE = new StaticInnerSingleton(); } public static StaticInnerSingleton getInstance() { return Holder.INSTANCE; } }

原理是 JVM 的类加载时机:外部类StaticInnerSingleton被加载时,并不会立刻加载内部类Holder,只有第一次调用getInstance(),代码真正触碰到Holder.INSTANCE,Holder才会被 JVM 加载并初始化静态字段。于是懒加载天然成立。

线程安全也由 JVM 保证:类的初始化阶段是被 JVM 加锁保护的,同一时间只有一个线程能执行<clinit>方法,其他线程会阻塞等待。所以我常说,静态内部类是“用 JVM 的机制代替你手写同步逻辑”,在绝大多数场景下,它比 DCL 更推荐。

这是我个人在实际项目中默认的首选写法。直到今天,我写配置管理器、缓存管理器这类单例,基本都是静态内部类起步。

2.4 枚举单例:站在 JVM 层面防破坏

Joshua Bloch 在《Effective Java》里专门推荐过枚举单例,它可能是代码量最少、最安全的写法:

public enum EnumSingleton { INSTANCE; public void doSomething() { // 业务逻辑 } }

调用方直接用EnumSingleton.INSTANCE,不需要getInstance()方法。它的安全体现在两个层面。反射层面:Constructor.newInstance()的源码里明确禁止对枚举类型创建新实例,会直接抛IllegalArgumentException,所以传统反射攻击对枚举无效。序列化层面:Java 对枚举的序列化做了特殊处理,反序列化时不是通过反射创建新对象,而是根据名称返回已有的枚举常量,因此枚举类单例在序列化场景下天然不会产生第二个实例。

缺点也有:枚举不能继承别的类(但可以实现接口),延迟加载不好控制,代码风格和普通类差异比较大。所以它在业务代码里不算多见,更多用在框架底层、工具库或者“绝对不能被反射破坏”的敏感场景。不过面试时如果能主动提出来,印象分会明显不一样。

2.5 容器式(注册式)单例:Spring 里在用的思路

实际做 Java 后端的人,几乎天天在用 Spring,而 Spring 管理的 Bean 默认就是单例。它的底层不是上面任何一种静态写法,而是一个容器注册表:

public class SingletonRegistry { private static final Map<String, Object> INSTANCES = new ConcurrentHashMap<>(); private SingletonRegistry() {} public static Object getInstance(String className) throws Exception { if (!INSTANCES.containsKey(className)) { synchronized (SingletonRegistry.class) { if (!INSTANCES.containsKey(className)) { Class<?> clazz = Class.forName(className); INSTANCES.put(className, clazz.getDeclaredConstructor().newInstance()); } } } return INSTANCES.get(className); } }

思路是:用一个 Map 缓存实例,key 是类名或 bean 名,value 是对应的对象实例,需要时先从 Map 取,取不到再创建并放进去。这和 Spring 的singletonObjects注册表在思想上完全一致。

容器式单例的好处是把“唯一性”从某个类的静态变量,提升到了容器层面。实例不是由类自己“写死”的,而是由容器统一管理,这样很容易加上代理、AOP、生命周期回调等扩展能力。这也是为什么 Spring 的单例 Bean 可以做事务增强、可以做依赖注入,而普通静态单例做不到。理解这一点,你就能回答那个高频面试题:Spring 的单例和单例模式有什么区别?答案是,Spring 的单例是容器级单例,单例模式是类加载器级单例。

为了让理解更直观,我把五种写法放在一张表里做对比:

写法创建时机线程安全延迟加载防反射/序列化适用场景
饿汉式类加载时是(JVM保证)否需额外处理启动就要用、创建开销小
懒汉式(同步方法)首次调用是(性能差)是需额外处理理论教学、低并发
DCL首次调用是(需volatile)是需额外处理高并发下的经典选型
静态内部类首次调用 Holder是(JVM保证)是需额外处理绝大多数业务默认推荐
枚举JVM 加载枚举类是(JVM保证)否天然防御绝对安全优先、框架底层
容器式首次注册是(ConcurrentHashMap)是与容器实现有关Spring 等 IOC 容器

3. 从“能用”到“稳用”:单例落地实操与验证

3.1 实战:配置中心单例的完整实现

写一个非常贴近业务的例子:配置中心。需求是应用启动后加载app.properties,所有模块共享同一份配置,并且支持随时读取。

public class ConfigManager { private final Properties properties = new Properties(); private ConfigManager() { try (InputStream is = ConfigManager.class.getClassLoader() .getResourceAsStream("app.properties")) { if (is == null) { throw new IllegalStateException("app.properties not found in classpath"); } properties.load(is); } catch (IOException e) { throw new ExceptionInInitializerError(e); } } private static class Holder { private static final ConfigManager INSTANCE = new ConfigManager(); } public static ConfigManager getInstance() { return Holder.INSTANCE; } public String get(String key) { return properties.getProperty(key); } public String get(String key, String defaultValue) { return properties.getProperty(key, defaultValue); } }

这里我故意选用了静态内部类写法,因为配置对象的创建涉及 IO 读取,属于“慢启动”资源,最好是按需加载而不是应用一启动就加载。构造器里我加了文件不存在的校验,并把 IO 异常包装成ExceptionInInitializerError,这样一旦配置缺失,类初始化会直接失败,问题能在启动阶段暴露而不是等到运行时才报空指针。

调用方式也简单:

String callbackUrl = ConfigManager.getInstance().get("callback.url", "");

所有模块拿到的都是同一个ConfigManager实例,改配置时只需要保证资源文件更新和重新加载机制,不会出现每个模块各拿一份旧配置的情况。

3.2 并发环境怎么验证“真的只有一个实例”

很多新手写完全单例,心里没底:到底是不是真的只有一个实例?最直接的验证办法是用并发压测,在多个线程同时第一次调用getInstance(),打印每个线程拿到的对象内存地址哈希值。这里的关键是让线程尽量同时起跑,否则验证没有说服力。

public class SingletonConcurrentTest { public static void main(String[] args) throws InterruptedException { int threadCount = 16; CountDownLatch ready = new CountDownLatch(threadCount); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(threadCount); ExecutorService pool = Executors.newFixedThreadPool(threadCount); for (int i = 0; i < threadCount; i++) { pool.submit(() -> { ready.countDown(); try { start.await(); ConfigManager instance = ConfigManager.getInstance(); System.out.println("hash = " + System.identityHashCode(instance)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { done.countDown(); } }); } ready.await(); long begin = System.nanoTime(); start.countDown(); done.await(); long cost = System.nanoTime() - begin; System.out.println("并发获取耗时(ns):" + cost); pool.shutdown(); } }

正常输出里,16 个线程打印的System.identityHashCode应该全部一致。有人会问,为什么用System.identityHashCode而不直接打印object.toString()?因为如果单例类没有重写toString(),两者等价;但很多实际类会重写,toString()的结果就不代表对象身份了。用identityHashCode是最稳的。

如果拿到的哈希值不一致,基本可以断定代码存在线程安全问题,优先检查同步块是否写对、volatile是否漏掉。

3.3 一个容易被忽视的扩展点:带泛型的单例注册表

容器式单例在日常代码中也很实用。比如一个小系统里,有多种策略类需要全局唯一,但每种策略类单独写一套静态单例又太啰嗦,可以用一个泛型注册表统一管理:

public final class SingletonRegistry { private static final Map<String, Object> INSTANCES = new ConcurrentHashMap<>(); private SingletonRegistry() {} @SuppressWarnings("unchecked") public static <T> T getInstance(String key, Supplier<T> creator) { return (T) INSTANCES.computeIfAbsent(key, k -> creator.get()); } }

使用方式:

PayStrategy strategy = SingletonRegistry.getInstance("alipay", AlipayStrategy::new);

computeIfAbsent本身就是原子的,能避免并发创建。不过要注意两个问题:泛型信息会被擦除,取出来时如果类型不匹配,强转可能报ClassCastException;另外注册表是全局共享的 Map,key 的命名规则要提前定好,否则容易撞车。这种写法适合“少量策略类需要全局共享”的中小型项目,如果规模大了,还是交给 Spring 容器管理更省心。

4. 单例的陷阱清单与面试深挖点

4.1 反射与序列化:单例模式的破防点

静态单例最容易被攻击的入口就是反射。看下面这段代码:

Class<?> clazz = StaticInnerSingleton.class; Constructor<?> constructor = clazz.getDeclaredConstructor(); constructor.setAccessible(true); Object instance1 = constructor.newInstance(); Object instance2 = constructor.newInstance(); System.out.println(instance1 == instance2); // false

setAccessible(true)可以强行调用私有构造器,在普通静态单例面前直接“破了防”。要防御的话,可以在构造器里加一道校验:

private StaticInnerSingleton() { if (Holder.INSTANCE != null) { throw new IllegalStateException("Already initialized"); } }

这样反射第二次调用构造器时,因为实例已存在,直接抛异常。但这道防线依赖代码顺序,第一层反射依然能成功创建,后面的调用才被拦下。如果要求绝对安全,直接选枚举,反射框架对枚举无能为力。

序列化同样会破坏单例。如果一个单例类实现了Serializable,反序列化时会通过反射创建新对象,即使构造器是私有的。解决办法是实现readResolve()方法:

protected Object readResolve() { return Holder.INSTANCE; }

反序列化时 JVM 会调用readResolve(),用返回值代替新创建的对象,从而保证唯一性。枚举则完全不需要这些处理,这也是它在安全层面更省心的原因。

4.2 重排序陷阱:为什么 volatile 不能省略

这一节是 DCL 的核心考点。很多人只知道 DCL 要加volatile,但说不出为什么。new DclSingleton()这行代码,在 JVM 底层大致拆成三步:

  1. 分配内存空间
  2. 初始化对象,执行构造器
  3. 把对象引用赋值给instance

问题在于,编译器、CPU 为了提高性能可能对指令重排序,把第 2 步和第 3 步调换顺序执行。在单线程下没问题,但在多线程下就会出现这个场景:线程 A 执行了“分配内存”和“引用赋值”,但还没有执行“初始化对象”。此时线程 B 进入getInstance(),发现instance != null,直接返回一个“半初始化”的对象。线程 B 拿着这个构造还没执行完的对象去调方法,轻则拿到默认值,重则直接抛空指针。

volatile在这里做两件事:禁止该字段的读写操作被编译器、处理器重排序;建立happens-before关系,保证写instance前对对象的所有修改,对读instance后的线程可见。加上volatile之后,instance = new DclSingleton()的“引用赋值”必然发生在“初始化对象”之后,半初始化对象就不会被其他线程看见了。

静态内部类和枚举单例都不存在这个问题,是因为它们的实例创建发生在类初始化阶段,JVM 在类初始化完成前不会将引用发布给其他线程,所以天然安全。

4.3 类加载器与 Spring 容器:单例的边界在哪

还有一个容易被忽略的坑:类加载器。单例的唯一性其实不是“整个 JVM 唯一”,而是“同一个类加载器范围内唯一”。如果同一个类被两个不同的类加载器各加载一次,内存里就有两份 Class 元数据,每份都有自己的静态变量,单例就会出现多实例。这在 Tomcat 这种带多 Web 应用隔离的环境里尤其要留意。

Spring 单例 Bean 和这个边界又不一样。Spring 的singletonObjects是一个Map<String, Object>,即使同一个类被加载两次,只要 bean 的唯一 name 对应一个实例,容器从 Map 里拿到的还是同一个对象。它管理的是“容器范围内的实例”,而不是“类加载器范围内的静态变量”,并且还附带生命周期和代理能力。所以面试官问“Spring 单例和 GoF 单例区别”时,回答思路应该落在:实例缓存的位置不同、管理主体不同、扩展能力不同。

4.4 面试追问实录:这些问题能检验你是否真懂

单例相关的面试题,我见得最多的几个,基本可以串成一条追问链路:

问题核心回答思路
单例模式和静态方法 / 静态变量有什么区别?静态方法没有状态,单例可以持有并共享状态;静态变量虽然是全局的,但缺少访问控制、没有规范和扩展点。
饿汉式和懒汉式怎么选?看创建开销和使用时机:确定会用、开销小选饿汉;希望按需创建选懒汉。
DCL 里 volatile 的作用是什么?禁止指令重排序,防止其他线程拿到半初始化对象;保证可见性。
枚举为什么能防止反射和序列化破坏?JVM 规范层面禁止反射创建枚举实例;序列化机制对枚举按名称返回常量,不创建新对象。
静态内部类为什么线程安全?类初始化阶段由 JVM 加锁保证,只有第一个线程执行初始化,后续线程等待。
你实际项目中推荐用哪种?普通业务默认静态内部类,安全敏感场景用枚举,容器环境交给 Spring 管理。

把这些问题想清楚,单例的“纸面知识”才算真正落地。其实面试官要的不是一个标准答案,而是看你有没有理解每个选择背后的 JVM 机制。

最后聊一点我个人在项目里的体会。单例这个模式看起来简单,但它把“全局唯一”“并发安全”“生命周期管理”三个核心命题全部卷进来了。我现在的默认选型很简单:业务代码里的配置、连接池这类共享对象,一律用静态内部类;涉及反序列化、反射对抗或者对安全极度敏感的底层组件,直接上枚举;Spring 环境里,能用 IoC 容器管理就不要手写单例。踩过“两套配置”的坑之后,我养成了一个习惯——每次设计一个会被多处共享的类,都会先问一句:它需要唯一吗?如果需要,那谁来保证这个唯一性?想清楚这一步,代码的质量就已经赢了大半。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询