Skip to content
Go back

单例模式的5种写法——从饿汉到枚举的演进

单例模式 5 种写法的演进

一句话结论(30s)

单例模式的本质是保证一个类全局只有一个实例。关键设计是借助 JVM 的类加载机制天然保证线程安全——饿汉式和静态内部类靠 <clinit> 只执行一次、DCL 靠 volatile 禁止指令重排、枚举靠 JVM 禁止反射与反序列化重建。权衡:日常开发首选静态内部类,因为懒加载、无锁、零同步开销;需要防反射/序列化攻击时用枚举,两者都比经典 DCL 更简洁。

核心原理(2min)

五种写法的演进本质是在「懒加载 vs 线程安全 vs 防攻击」三个维度上权衡。饿汉式类加载时即创建(<clinit> 保证线程安全但不懒加载);synchronized 懒汉式懒加载但热路径有锁竞争;DCL 用「外层无锁 if + 内层持锁 if」消除 99.9% 的锁竞争,再用 volatile 的 StoreStore 屏障禁止 new Singleton() 中「引用赋值」与「构造器初始化」的重排,防止线程拿到半成品对象;静态内部类把实例放进 Holder,只在首次调用 getInstance() 时触发类加载,零同步开销;枚举由 JVM 保证全局唯一,且 Constructor.newInstance() 对枚举抛异常、反序列化走 Enum.valueOf,天然防反射防序列化。源码落地:JDK 的 Runtime.getRuntime()Collections 的 EMPTY 系列常量,以及 Spring 默认的单例 Bean,都是这一模式的实例。

底层深入(5-10min)

1. 饿汉式:最简单也最安全

public class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    private Singleton() {}
    public static Singleton getInstance() { return INSTANCE; }
}

类加载时 <clinit>() 执行 new Singleton(),JVM 保证 <clinit> 只执行一次且线程安全。

缺点:不能懒加载——就算这个单例永远没用到,类加载时也创建了。

💭 思考穿插:既然饿汉式这么安全,为什么不一直用它?——因为「安全」和「合适」是两码事。如果单例初始化很重(占内存、建连接、依赖外部资源)却又几乎用不到,饿汉式就白白付出了启动代价;反过来,如果初始化便宜、又必然会被用到,饿汉式的简单反而是优点。这就引出了下一个问题:能不能既懒加载又线程安全?

2. 懒汉式(synchronized):能用但慢

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

每次 getInstance() 都要争锁,即使对象早就创建好了。99.9% 的调用中 instance 已经不为 null,但每个线程还要 synchronized → 热路径有锁竞争。

💭 思考穿插:能不能「只在第一次创建时加锁,之后就不锁了」?——这正是下一节 DCL 的动机。同步的代价不在「创建那一次」,而在「创建完成后每次调用都还要争锁」。所以优化的方向不是去掉锁,而是让锁只在真正需要创建的那极少数时刻才生效。

3. DCL(volatile + 双重检查):快且安全

public class Singleton {
    private static volatile Singleton instance;    // ← volatile 不能省
    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {                    // ① 外层检查(无锁)
            synchronized (Singleton.class) {
                if (instance == null) {            // ② 内层检查(持锁)
                    instance = new Singleton();    // ③ 创建
                }
            }
        }
        return instance;
    }
}

外层 if:大部分调用 instance 已存在,直接返回,不进入 synchronized → 热路径零竞争。

内层 if:两个线程同时通过外层 if(都看到 instance == null),一个获取锁创建,另一个等锁。第二个获取锁后发现 instance != null,不再创建。

volatile 为什么不能省? instance = new Singleton() 不是原子的,JIT 可能将其重排为:

1. 分配内存
2. 将 instance 指向该内存  ← 引用赋值在前
3. 调用构造器初始化         ← 初始化在后

线程 A 执行了步骤 2 但还没执行 3(对象未完全初始化),线程 B 的外层 if 看到 instance != null → 返回半成品对象 → NullPointerException 或其他未定义行为。

volatile 的 StoreStore 屏障禁止步骤 2 在步骤 3 之前执行,确保 instance 指向的对象已经完全初始化。

💭 思考穿插:为什么必须「两层 if」、一层不够?——如果去掉外层 if,每个线程都要先进 synchronized,就退化成了懒汉式;如果去掉内层 if,两个线程先后通过外层 if、都会各自创建一次,单例就被破坏了。外层 if 负责「快速返回」,内层 if 负责「防止重复创建」,两者各司其职。

Spring 里的真实落地:DefaultSingletonBeanRegistry 的双重检查

Spring 默认 Bean 作用域就是单例,DefaultSingletonBeanRegistry 的核心是「缓存单例的 ConcurrentHashMap + 一把创建锁」:

	/** Common lock for singleton creation. */
	final Lock singletonLock = new ReentrantLock();

	/** Cache of singleton objects: bean name to bean instance. */
	private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

getSingleton(String beanName, ObjectFactory<?> singletonFactory) 的创建流程正是 DCL 的工程化版本(源码节选自 Spring 6.2,分三段贴出):

① 无锁快查(第一次检查)——绝大多数请求在这里命中直接返回:

			Object singletonObject = this.singletonObjects.get(beanName);
			if (singletonObject == null) {

② 进锁后复查(第二次检查)——抢到锁后先再查一次,防止重复创建:

				this.singletonLock.lock();
				locked = true;
				// Singleton object might have possibly appeared in the meantime.
				singletonObject = this.singletonObjects.get(beanName);
				if (singletonObject != null) {
					return singletonObject;
				}

③ 仍为空才真正创建——持锁状态下确认 null,才调用工厂创建并标记为新建:

					// Leniently created singleton object could have appeared in the meantime.
					singletonObject = this.singletonObjects.get(beanName);
					if (singletonObject == null) {
						this.currentCreationThreads.put(beanName, currentThread);
						try {
							singletonObject = singletonFactory.getObject();
						}
						finally {
							this.currentCreationThreads.remove(beanName);
						}
						newSingleton = true;
					}

这三段连起来就是「外层 if + 内层 if」:第一段无锁快查、命中即返回,第二、三段在持锁状态下反复确认 null 才真正 getObject(),保证同一个 beanName 全局只创建一次。与经典 DCL 的区别在于:Spring 用 ReentrantLock(含 tryLock 非阻塞尝试)替代 synchronized,而 singletonObjects 本身是 ConcurrentHashMap,让无锁读也具备可见性——这就是「Spring 单例 Bean」在并发下仍全局唯一的底层机制。

4. 静态内部类(Holder):最优雅

public class Singleton {
    private Singleton() {}

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

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

Holder 类只在 getInstance() 首次被调时才被类加载器加载 → <clinit> 执行 → INSTANCE 被赋值。JVM 用 _init_lock 保证 <clinit> 不会被多线程并发执行。

不需要 synchronized,不需要 volatile。 JVM 规范天然保证其线程安全。懒加载 + 无锁 + 零同步开销,静态内部类是单例模式的”瑞士军刀”。

💭 思考穿插:静态内部类和 DCL 都能懒加载又线程安全,为什么更推荐前者?——因为 DCL 的线程安全靠「你手动写对 volatile + 两层 if」,写错一步就踩坑;而 Holder 的线程安全靠「JVM 类加载规范天然保证」,你什么都不用写。把正确性交给语言规范,比交给程序员的记忆更可靠。

5. 枚举:最”懒”最安全

public enum Singleton {
    INSTANCE;

    public void doSomething() { ... }
}

枚举的实例由 JVM 保证全局唯一。枚举类不能被反射实例化(Constructor.newInstance() 对枚举抛异常)、反序列化也不创建新实例(ObjectInputStream 对枚举走 Enum.valueOf)。

JDK 在反射与克隆两条路上都加了「物理」闸门。反射层面,java.lang.reflect.Constructor 在生成 ConstructorAccessor 前先判断声明类是否为枚举:

            // Ensure the declaring class is not an Enum class.
            if ((clazz.getModifiers() & Modifier.ENUM) != 0)
                throw new IllegalArgumentException("Cannot reflectively create enum objects");

克隆层面,java.lang.Enumclone() 直接抛异常,杜绝「克隆出一个新实例」破坏单例:

    protected final Object clone() throws CloneNotSupportedException {
        throw new CloneNotSupportedException();
    }

反序列化层面,Enum.valueOf 通过 enumConstantDirectory() 按名字从 JVM 维护的常量表里取回同一实例,而不是新建对象:

    public static <T extends Enum<T>> T valueOf(Class<T> enumClass,
                                                String name) {
        T result = enumClass.enumConstantDirectory().get(name);
        if (result != null)
            return result;
        if (name == null)
            throw new NullPointerException("Name is null");
        throw new IllegalArgumentException(
            "No enum constant " + enumClass.getCanonicalName() + "." + name);
    }

这三处就是「防攻击」的真正来源:反射被 Constructor 显式拦截、克隆被 clone() 显式拦截、反序列化走 valueOf 取常量而非 new,所以枚举实例由 JVM 兜底保证全局唯一。

不需要写构造器 private、不需要序列化相关的 readResolve() 方法、不需要防反射攻击。 枚举是《Effective Java》作者 Josh Bloch 推荐的”最省心单例”。

💭 思考穿插:单例为什么用枚举最安全?——回顾前面四种写法,反射和反序列化都能绕过 private 构造器再建一个新实例,破坏单例;只有枚举被 JVM 在反射、克隆、反序列化三条路上都加了「物理闸门」。所以「最安全」不是写得多复杂,而是把防线交给了 JVM 本身,攻击者绕不过语言层。

总结

方式懒加载线程安全防反射防序列化复杂度
饿汉最低
synchronized
DCL
Holder
枚举✗(类加载时创建)最低

日常开发用静态内部类,需要防反射/序列化攻击的场合用枚举。DCL 虽然经典但用到的场景有限——静态内部类和枚举提供了更简洁的替代。

章末提问

Q1:单例为什么用枚举最安全?

结论先行:因为 JVM 在反射、克隆、反序列化三条路上都物理禁止重建枚举实例,攻击者无法在语言层绕过。 因为:Constructor.newInstance() 检测到声明类是枚举直接抛异常、Enum.clone() 直接抛异常、反序列化走 Enum.valueOf 从常量表取回同一实例而非 new。而前四种写法只靠 private 构造器,反射和反序列化都能绕过它再建出一个新实例。

Q2:DCL 里 volatile 为什么不能省?

结论先行:因为 volatile 禁止了「引用赋值」与「构造器初始化」之间的指令重排。 因为:instance = new Singleton() 不是原子的,JIT 可能先让引用指向内存、后执行构造器初始化;此时另一线程的外层 if 看到非 null 就返回半成品对象,触发 NPE。volatile 的 StoreStore 屏障保证 instance 指向的是已完全初始化的对象。

Q3:为什么 DCL 要两层 if?

结论先行:外层 if 消除热路径的锁竞争,内层 if 防止并发下重复创建。 因为:去掉外层 if 就退化成 synchronized 懒汉式,每次调用都争锁;去掉内层 if 会导致两个线程先后通过外层 if 后各创建一次,单例被破坏。两层 if 各司其职。

Q4:静态内部类和 DCL 怎么选?

结论先行:优先选静态内部类。 因为:两者都懒加载且线程安全,但 Holder 靠 JVM 类加载机制的 <clinit> 天然保证(不用 synchronized、不用 volatile),DCL 靠程序员手动写对 volatile + 双层 if,写错一步就踩坑。把正确性交给语言规范更可靠。

Q5:饿汉式和枚举都是类加载时创建,区别在哪?

结论先行:区别在是否防反射、防序列化。 因为:饿汉式仍可被反射调用私有构造器、被反序列化重建出新实例破坏单例;枚举则被 JVM 在反射、克隆、反序列化三处物理拦截,实例全局唯一。


Share this post on:

Previous Post
原型模式——对象克隆的深浅之道
Next Post
工厂模式——Spring FactoryBean如何封装复杂Bean的创建