Java 单例模式实战:DCL、静态内部类与枚举的安全写法

单例模式是 Java 中最常见的创建型设计模式,核心是保证一个类在 JVM 里只有一个实例并提供全局访问点。但真正写出线程安全、防反射、防序列化的单例并不简单:懒加载与线程安全天然冲突,双重检查锁(DCL)少一个 volatile 就会出隐蔽 bug。本文从饿汉式、懒汉式讲起,拆解 DCL 为什么必须加 volatile,对比静态内部类与枚举两种推荐写法,并给出反序列化与反射攻击的防御方案。

一、为什么需要单例模式

很多对象天生就只该有一个:线程池、连接池、配置中心、注册表、日志器。如果每处都 new 一个,不仅浪费内存,还会让状态彼此割裂——两个线程从两个不同的连接池拿到连接,事务和限流就全乱了。单例把”唯一实例”这件事收口到一个地方,对外只暴露一个稳定的获取入口,调用方无需关心它何时创建。

但单例也是最容易被滥用的模式。能做成无状态工具类(纯静态方法)的就别用单例;能用依赖注入容器托管的也未必需要手写。先问一句”它真的必须唯一吗”,再决定是否落地单例。

二、饿汉式与懒汉式:最朴素的两种写法

饿汉式在类加载时就创建实例,天生线程安全,但缺点是一上来就占资源,哪怕永远用不到。懒汉式把创建推迟到第一次调用,但最简单的写法在并发下会创建出多个实例。

// 饿汉式:类加载即创建,线程安全但可能提前占用资源
public class EagerSingleton {
    private static final EagerSingleton INSTANCE = new EagerSingleton();
    private EagerSingleton() {}
    public static EagerSingleton getInstance() {
        return INSTANCE;
    }
}

// 懒汉式(线程不安全版):并发下可能 new 出多个实例
public class LazySingleton {
    private static LazySingleton instance;
    private LazySingleton() {}
    public static LazySingleton getInstance() {
        if (instance == null) {           // 线程 A、B 同时进入,各自 new 一个
            instance = new LazySingleton();
        }
        return instance;
    }
}

给 getInstance() 加 synchronized 能立刻解决并发问题,但每次获取都要抢锁,高并发下反而成了性能瓶颈。于是有了更精细的双重检查锁。

三、双重检查锁 DCL:为什么必须加 volatile

DCL 的思路是:只有实例还没创建时才加锁进入临界区,创建好之后直接返回,不再走锁。看起来两全其美,但经典实现里有一个致命坑——instance 必须声明为 volatile。

public class DclSingleton {
    // 关键点:volatile 禁止指令重排,保证可见性
    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;
    }
}

3.1 不加 volatile 的隐患:指令重排

instance = new DclSingleton() 在 JVM 里并不是原子的,它大致分成三步:① 分配内存;② 在内存上执行构造函数初始化对象;③ 把 instance 引用指向这块内存。编译器和 CPU 可能做指令重排,让 ③ 先于 ② 完成。此时另一个线程在第一次检查里看到 instance 不为 null,就直接返回了一个还没初始化完成的对象,用到成员变量时就会拿到默认值甚至抛空指针。

volatile 在这里干两件事:一是通过内存屏障禁止 ②③ 重排,二是保证一个线程写完、其他线程立刻可见。想深入 volatile 的 happen-before 语义,可以看这篇 Java 内存模型 JMM 与 volatile。

四、静态内部类:懒加载与线程安全的最佳折中

如果你不想纠结 volatile 和锁,静态内部类(Initialization-on-demand holder)是更省心的写法。JVM 保证类初始化是线程安全的,而内部类只有在被引用时才加载,天然实现了懒加载。

public class HolderSingleton {
    private HolderSingleton() {}

    private static class Holder {
        private static final HolderSingleton INSTANCE = new HolderSingleton();
    }

    public static HolderSingleton getInstance() {
        return Holder.INSTANCE;   // 第一次调用才触发 Holder 类加载
    }
}

这个写法的优点非常明显:无锁、延迟加载、线程安全全由类加载机制兜底,代码也最干净。它几乎是 DCL 之前最被推荐的方案。但它挡不住反射和序列化攻击——下面会讲。

五、枚举单例:Effective Java 的终极推荐

Joshua Bloch 在《Effective Java》里给出过一个至今最稳妥的结论:实现单例的最佳方式是写一个包含单个元素的枚举。它写法极简,而且由 JVM 保证实例唯一。

public enum EnumSingleton {
    INSTANCE;

    public void doSomething() {
        // 业务方法直接挂在枚举实例上
    }
}

// 使用
EnumSingleton.INSTANCE.doSomething();

5.1 枚举如何天然防反射与序列化

普通类的私有构造器能被反射强行调用,setAccessible(true) 一关就破了防线;而枚举的实例由 JVM 在类加载时创建,且《Java 语言规范》明确禁止通过反射创建枚举对象,newInstance 遇到枚举直接抛异常。序列化方面,枚举的序列化机制不走普通 readObject,而是按名字解析,天然杜绝反序列化产生新实例。也就是说,枚举单例在”防反射、防序列化”这两条上不需要你写任何额外代码。

六、破坏性攻击:反射与序列化如何击穿单例

下面这张表把四种主流写法在四个维度上摆开对比,一眼就能看出为什么枚举胜出:

写法线程安全懒加载防反射防序列化
饿汉式是否否否
懒汉式(synchronized)是是否否
DCL + volatile是是否否
静态内部类是是否否
枚举单例是是是是

下面这段代码演示反射如何击穿一个普通单例——即便构造器是 private,反射也能强行调用:

Constructor<DclSingleton> c = DclSingleton.class.getDeclaredConstructor();
c.setAccessible(true);                 // 关闭访问检查
DclSingleton fake = c.newInstance();   // 直接 new 出一个新实例,单例被破
System.out.println(fake == DclSingleton.getInstance()); // false

如果你坚持用 DCL 或静态内部类,又想挡住反射,只能在构造器里加一道”第二次调用就抛异常”的守卫(用静态标记位),但这会让代码变得脆弱。序列化场景则要额外实现 readResolve() 返回同一个实例。相比之下,枚举把这些麻烦全部免掉了。

七、Spring 容器里的单例 ≠ Java 单例

很多人会混淆两个概念。Spring 的 @Scope("singleton") 指的是在 IoC 容器(一个 ApplicationContext)里只有一个 Bean,但如果你用 new 创建同一个类,或者用多个容器加载同一配置,照样会有多个实例。它靠的是容器缓存,不是类加载机制。手写单例解决的是”一个类在 JVM 里只有一份”,而 Spring 单例解决的是”被容器管理的 Bean 只有一份”,二者边界要分清。关于设计模式在业务代码里的更多落地,可参考 Java 设计模式实战:工厂与策略模式。

八、选型清单:什么时候该用单例

落到工程实践,给出一份落地清单:

  • 对象确实必须全局唯一(连接池、配置、注册表、调度器)——才考虑单例;
  • 能做成无状态工具类或交给 DI 容器的,不要用手写单例;
  • 需要懒加载又嫌 DCL 啰嗦,用静态内部类;
  • 要求绝对防反射、防序列化,无脑上枚举单例;
  • 并发场景的共享状态本身就有坑,单例只是起点,真正的难点在线程池与异步编排的协作上。

单例模式看着简单,真正写对却要懂类加载、JMM 和序列化三套机制。日常开发里,枚举单例是性价比最高的默认选择;当你需要兼顾可读性与极致控制时,静态内部类与 DCL 依然是值得掌握的经典写法。

上一篇 大模型输出护栏:用 Guardrails 做结构化校验与自愈
下一篇 PgBouncer 实战:PostgreSQL 连接池调优
最新评论
Docker 网络模式详解:bridge/host 生产选型 - FSData
Docker 网络模式详解:bridge/host 生产选型 - FSData
8月20日
[…] 注意最后那条:把端口绑定到 127.0.0.1,再由宿主机上的 Nginx 反代进来,比直接 -p 80:80 暴露安全得多,也方便统一做
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
Vue3 + TypeScript 工程化:类型安全的前端架构 - FSData
8月20日
[…] Pinia 的工程化用法,可以参考我们之前的实战文章 《Vue3 组合式 API + Pinia:状态管理从入门到实战》,那里覆盖了 Sto
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
2026 年最值得装的 10 个 VS Code 插件(AI/前端/后端/效率全覆盖) - FSData
8月19日
[…] 状态,点一下就能起停容器,配合我们讲过的 Docker Compose 编排 […]
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
Redis 缓存设计:穿透、击穿、雪崩与最佳实践 - FSData
8月19日
[…] 缓存三大坑的本质都是”缓存失效导致请求穿透到数据库”。记住口诀:穿透用布隆、击穿用锁、雪崩用错峰。配合合理的过期策略
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
GitHub Actions 实战:从零搭建 CI/CD 流水线 - FSData
8月19日
[…] Docker 镜像部署。结合我们之前写的 Docker Compose 一键编排实战,可以在 CI […]