5、单例模式(创建型):饿汉式、懒汉式、双重检查锁、静态内部类、枚举实现

单例模式,说白了就是保证一个类在整个系统里只有一个实例。你想想看,有些对象我们真的不需要创建多个——比如配置管理器、线程池、数据库连接池。如果到处 new 出来一堆,内存浪费不说,还容易出各种诡异的问题。

我个人习惯把单例模式分成两大类:饿汉式懒汉式。名字挺形象的——饿汉就是不管三七二十一,先把对象创建好;懒汉则是等到有人真正要用的时候,才去创建。

下面我把五种实现方式挨个讲一遍,每种我都会说说我在项目中踩过的坑。

5.1 饿汉式(线程安全,但可能浪费资源)

饿汉式是最简单的写法。类加载的时候就把实例创建好,JVM 保证了类加载过程的线程安全,所以天然就是线程安全的。

public class SingletonEager {
    private static final SingletonEager INSTANCE = new SingletonEager();

    private SingletonEager() {}

    public static SingletonEager getInstance() {
        return INSTANCE;
    }
}

嗯,这里要注意:private 构造方法一定要写,不然别人就能 new 出来了。我见过有人忘了写,结果单例变成了「多例」。

优点:实现简单,线程安全,没有锁的性能开销。
缺点:类加载时就创建对象,如果这个类一直没被使用,就白白占着内存。

我曾经在一个微服务项目里用过饿汉式,结果那个单例类里持有一个 50MB 的缓存。服务启动后虽然没人调用它,但内存已经被占用了。后来我换成了懒汉式,才把启动内存降下来。

5.2 懒汉式(延迟加载,但线程不安全)

懒汉式就是等到 getInstance() 被调用时才创建对象。但问题来了——多线程环境下,两个线程可能同时进入 if (instance == null) 的判断,然后各自 new 出一个对象。

public class SingletonLazy {
    private static SingletonLazy instance;

    private SingletonLazy() {}

    public static SingletonLazy getInstance() {
        if (instance == null) {
            instance = new SingletonLazy();
        }
        return instance;
    }
}
警告:这种写法在多线程环境下是不安全的。不要在生产代码里直接用。

那怎么解决?加 synchronized 呗。但直接加在方法上,每次调用都要走锁,性能太差了。于是就有了双重检查锁。

5.3 双重检查锁(兼顾性能和线程安全)

双重检查锁的思路是:先判断一次 instance == null,如果为 null 才进入同步块,进入同步块后再判断一次。这样只有第一次创建时需要加锁,后续调用直接返回实例,没有锁开销。

public class SingletonDCL {
    private static volatile SingletonDCL instance;

    private SingletonDCL() {}

    public static SingletonDCL getInstance() {
        if (instance == null) {
            synchronized (SingletonDCL.class) {
                if (instance == null) {
                    instance = new SingletonDCL();
                }
            }
        }
        return instance;
    }
}
关键点:volatile 关键字不能少。它禁止了指令重排序,确保 instance 在被赋值时已经完成了构造函数的执行。

我记得有一次排查线上问题,发现某个单例对象的字段偶尔是 null。查了半天,就是因为忘了加 volatile。JVM 在 new SingletonDCL() 时,可能会先分配内存、再赋值给 instance、最后才调用构造函数。另一个线程拿到的是「半初始化」的对象,字段自然就是 null 了。

5.4 静态内部类(推荐方式之一)

静态内部类的方式既实现了延迟加载,又保证了线程安全,而且代码非常简洁。它的原理是:外部类加载时,内部类并不会被加载。只有调用 getInstance() 时,内部类才会被加载,此时 JVM 会保证类加载过程的线程安全。

public class SingletonHolder {
    private SingletonHolder() {}

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

    public static SingletonHolder getInstance() {
        return Holder.INSTANCE;
    }
}

我个人比较喜欢这种写法。它没有锁,没有 volatile,代码干净利落。而且 JVM 层面的线程安全比我们自己加锁要可靠得多。

适用场景:大部分需要延迟加载的单例场景。如果你不确定选哪种,静态内部类通常是个好选择。

5.5 枚举实现(最安全的方式)

枚举实现是《Effective Java》里强烈推荐的方式。它天然防止反射攻击和序列化破坏。你想想看,反射可以调用 setAccessible(true) 来调用私有构造方法,但枚举的构造方法反射是拿不到的。序列化也是,枚举的序列化由 JVM 特殊处理,不会产生新实例。

public enum SingletonEnum {
    INSTANCE;

    public void doSomething() {
        // 业务方法
    }
}

调用方式也很简单:SingletonEnum.INSTANCE.doSomething()

注意:枚举不能继承其他类(但可以实现接口)。如果你的单例需要继承某个父类,枚举就不适用了。

我曾经在一个金融项目里用过枚举单例。那个项目对安全性要求极高,不允许任何人通过反射或序列化创建第二个实例。用枚举实现,心里踏实多了。

5.6 五种实现对比

实现方式 延迟加载 线程安全 防反射攻击 防序列化破坏 推荐指数
饿汉式 ★★★
懒汉式(不加锁)
双重检查锁 ★★★★
静态内部类 ★★★★
枚举 ★★★★★

从表格能看出来,枚举在安全性上是最全面的。但实际项目中,静态内部类和双重检查锁用得也很多,因为大部分场景不需要防反射和防序列化。

5.7 知识体系图

下面这张图把五种实现的核心逻辑串起来了,方便你对比记忆:

单例模式五种实现对比 饿汉式 类加载即创建 懒汉式 延迟加载,线程不安全 双重检查锁 volatile + 两次判断 静态内部类 JVM类加载保证安全 枚举 最安全 延迟加载? 延迟加载? 延迟加载? 延迟加载? 延迟加载? 线程安全 ✓ 类加载机制 线程不安全 ✗ 需加synchronized 线程安全 ✓ synchronized+volatile 线程安全 ✓ JVM类加载 线程安全 ✓ JVM保证 防反射: ✗ 可被反射破坏 防反射: ✗ 可被反射破坏 防反射: ✗ 可被反射破坏 防反射: ✗ 可被反射破坏 防反射: ✓ JVM禁止 推荐指数: ★★★ 推荐指数: ★ 推荐指数: ★★★★ 推荐指数: ★★★★ 推荐指数: ★★★★★

5.8 避坑指南

  • 反射攻击:除了枚举,其他四种方式都可以通过反射调用私有构造方法创建新实例。如果你要防反射,可以在构造方法里加一个标志位,第二次调用时抛出异常。
  • 序列化破坏:如果单例类实现了 Serializable,反序列化时会创建新对象。解决方案是添加 readResolve() 方法,返回已有的实例。
  • 类加载器问题:如果系统中有多个类加载器,每个类加载器都会加载自己的单例。这种情况比较少见,但如果你在用 Tomcat 等容器,要注意。
  • 双重检查锁的 volatile这个我前面说过了,忘了加就是 bug。而且这个 bug 不是每次都能复现,非常隐蔽。

好了,单例模式就讲到这里。五种实现各有优劣,没有银弹。我个人建议:不需要延迟加载就用饿汉式,需要延迟加载优先考虑静态内部类,对安全性有极致要求就用枚举


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