3、单例模式(下):静态内部类实现、枚举实现、单例模式在JDK中的应用、单例模式在Spring中的应用

好,咱们接着聊单例模式。上一章我们把饿汉式、懒汉式、DCL(双重检查锁)都过了一遍。说实话,DCL 虽然经典,但代码写起来有点啰嗦,而且一不小心就踩坑。我个人在实际项目中,更倾向于用今天要讲的这两种方式——静态内部类和枚举。

为什么?因为简单、安全、还不容易出错。你想想看,架构设计里最怕什么?最怕的就是「看起来没问题,上线就炸」。这两种实现方式,恰恰是经过实战检验的「稳路子」。

3.1 静态内部类实现

先看代码,再说原理。

public class Singleton {

    private Singleton() {
        // 防止反射攻击
        if (InnerClass.INSTANCE != null) {
            throw new RuntimeException("别想通过反射搞我");
        }
    }

    private static class InnerClass {
        private static final Singleton INSTANCE = new Singleton();
    }

    public static Singleton getInstance() {
        return InnerClass.INSTANCE;
    }
}

这段代码,我用了好多年。它巧妙在哪?

延迟加载。InnerClass 只有在 getInstance() 第一次被调用时,才会被 JVM 加载。加载的时候,才创建实例。这不就是懒加载吗?

线程安全。JVM 在类加载阶段,会隐式地加锁。说白了,类加载器自己就保证了「一个类只会被加载一次」。你不需要写 synchronized,也不需要 volatile。

核心要点:静态内部类实现,本质上是利用了 JVM 的类加载机制来保证线程安全。这是「语言层面」的保障,比我们自己写锁要可靠得多。

我在项目中遇到过一个问题:有人觉得静态内部类写法太「绕」,非要改成 DCL。结果呢?漏写了 volatile,线上出现了「半初始化对象」的 bug。嗯,从那以后,我团队里就定了个规矩——单例优先用静态内部类。

小提示:如果你要防止反射攻击,记得在私有构造器里加一个判断。就像上面代码那样。虽然不能 100% 防住(比如通过 Unsafe 直接分配内存),但至少能挡住 99% 的乱搞行为。

3.2 枚举实现

Joshua Bloch 在《Effective Java》里说过:「单元素枚举是实现单例的最佳方式。」我刚开始看到这句话时,心里也犯嘀咕——枚举?那不是用来定义常量的吗?

后来用了一次,真香。

public enum SingletonEnum {
    INSTANCE;

    public void doSomething() {
        System.out.println("我是枚举单例,稳得很");
    }
}

调用方式也简单:

SingletonEnum.INSTANCE.doSomething();

为什么说枚举实现是最佳方式?三个理由:

  • 线程安全:JVM 保证枚举的创建是线程安全的,跟静态内部类一样。
  • 防反射:反射的 newInstance() 方法对枚举无效。你没法通过反射创建第二个实例。
  • 防序列化:普通单例在反序列化时,会创建一个新对象。你必须实现 readResolve() 方法才能避免。而枚举的序列化是 JVM 自己处理的,天然安全。

注意:枚举单例也有缺点——它不支持懒加载。枚举类被加载时,实例就创建了。如果你的单例对象很重,而且不一定被用到,那静态内部类可能更合适。

我个人习惯是:如果单例对象不重,或者几乎一定会被用到,就用枚举。如果对象很重,而且可能用不上,就用静态内部类。

3.3 单例模式在 JDK 中的应用

JDK 里单例模式用得很多。我挑几个典型的说说。

类名 实现方式 说明
Runtime 饿汉式 每个 Java 应用只有一个 Runtime 实例,用于与 JVM 交互
Desktop 静态内部类 获取桌面相关的 API,如打开浏览器、发送邮件
System.console() 懒汉式(非线程安全) 控制台输入输出,不是所有环境都有控制台

拿 Runtime 来说,你写的每一行 Java 代码,最终都要通过它跟操作系统打交道。它为什么用饿汉式?因为 Runtime 是 JVM 启动时必须存在的,不存在「用不用得上」的问题。

我记得有一次排查内存泄漏,发现有人 new 了好几个线程池,每个线程池都调用了 Runtime.getRuntime().availableProcessors()。其实这个调用是线程安全的,但问题出在他没复用线程池。嗯,单例模式用对了,但业务代码没用好,一样出问题。

3.4 单例模式在 Spring 中的应用

Spring 里的 Bean 默认就是单例的。但注意,这个「单例」跟 GoF 定义的单例不太一样。

GoF 单例:一个 JVM 里,一个类只有一个实例。
Spring 单例:一个 Spring 容器里,一个 Bean 定义只有一个实例。

如果你启动了多个 Spring 容器(比如多个 ApplicationContext),那同一个 Bean 类会有多个实例。所以 Spring 的单例是「容器级别」的,不是「JVM 级别」的。

Spring 是怎么实现单例的?核心是 singletonObjects 这个 ConcurrentHashMap。

// 这是 Spring 源码里的核心数据结构
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

当你要获取一个 Bean 时,Spring 会先查这个 Map。如果存在,直接返回。如果不存在,就创建并放入 Map。这个过程用到了双重检查锁,跟咱们之前讲的 DCL 思路一样。

关键区别:Spring 的单例是「有状态」的。你可以通过 @Scope("prototype") 让一个 Bean 变成多例。而 GoF 单例是「硬编码」的,类本身就不允许创建多个实例。

我在项目中遇到过一个问题:有人把 Service 层的 Bean 设成了 prototype,理由是「每次调用都要新对象」。结果呢?每次请求都创建一个新 Service,里面注入的 DAO 也跟着重新创建,性能直接崩了。后来我告诉他:Service 应该是无状态的,用单例就够了。真要隔离数据,用 ThreadLocal 或者方法参数传。

实战建议:Spring 里 90% 以上的 Bean 用默认的单例就够了。只有当你明确需要「每次调用都持有独立状态」时,才考虑 prototype。而且,prototype Bean 的销毁不由 Spring 管理,你得自己操心。

3.5 本章知识体系

下面这张图,把本章的核心知识点串起来了。你可以看到,静态内部类和枚举是「推荐路线」,JDK 和 Spring 的应用是「实战验证」。学设计模式,不能只背代码,得知道它「用在哪、为什么这么用」。

单例模式(下)知识体系 单例模式实现 静态内部类 延迟加载 + 线程安全 枚举实现 防反射 + 防序列化 JDK 应用 Runtime / Desktop / System Spring 应用 容器级单例 / singletonObjects 推荐路线 轻量/必用 → 枚举 | 重量/可选 → 静态内部类 Spring 中默认单例,无状态优先

好了,单例模式到这里就讲完了。三种经典实现(饿汉、懒汉、DCL)加上两种推荐实现(静态内部类、枚举),再加上 JDK 和 Spring 里的实战应用,够你应付绝大多数场景了。记住一句话:设计模式是工具,不是教条。用对地方,它就是利器;用错地方,它就是累赘。


公众号:蓝海资料掘金营,微信deep3321