创造型模式-单例模式

文章概述

单例模式(Singleton Pattern)是软件设计模式中最基础也最经典的一种创建型模式,其核心定义简洁而明确:确保一个类在整个JVM运行期间有且仅有一个实例,并提供一个全局访问点来获取该实例。尽管概念简单,但在Java并发编程、类加载机制、序列化协议以及分布式架构的复杂环境中,实现一个"真正"的线程安全、防反射攻击、防序列化破坏且具备集群唯一性的单例,却需要开发者对JVM内存模型、锁机制、类加载生命周期乃至分布式协调服务有深刻理解。

单例模式要解决的两大核心问题:一是实例数量的绝对控制 ------无论多少个线程并发访问、无论通过何种方式获取,始终返回同一个对象;二是全局访问入口的统一------为分散在各处的业务代码提供一个一致的状态共享点。这两个目标的达成与否直接影响到系统资源利用率、数据一致性乃至整体架构的健壮性。

本文将从最基础的六种Java单例实现方式入手,结合JVM指令层分析、内存屏障语义与锁升级过程,深入剖析饿汉式、懒汉式、DCL、Holder模式及枚举单例的底层原理。随后通过反射与序列化的PoC攻击代码,揭示单例的脆弱面并展示终极防御手段。进一步,我们将穿梭于JDK、Spring、MyBatis、Logback等经典框架源码,探寻单例模式在工业级产品中的精妙运用。当视角从单机扩展到分布式集群,类加载器隔离与跨进程唯一性的挑战又为单例赋予了新的内涵。最后,通过适用场景梳理、面试难题拆解与可视化流程图,为您呈现一幅完整的单例模式知识图谱。让我们一同深入这场从JVM指令到分布式锁的技术之旅。


一、代码实现:六种单例写法及JVM底层原理全剖析

以下所有实现均提供完整可运行的Demo代码,注释逐行解释设计意图与底层原理。环境基于JDK 8+,建议读者在阅读时同步执行以加深理解。

1. 饿汉式(静态常量与静态代码块)

饿汉式是最简单的单例实现,核心思想是在类加载阶段就完成实例的初始化,利用JVM类加载机制的线程安全性保证单例的唯一性。

java 复制代码
/**
 * 饿汉式 - 静态常量实现
 * 
 * 底层原理剖析:
 * 1. 类加载时机:当类被主动使用时触发初始化(new、反射、访问静态字段/方法等)
 * 2. JVM在类加载的初始化阶段(<clinit>方法)会为静态变量赋值,
 *    该过程是线程安全的,由JVM内部加锁保证
 * 3. 实例在类加载时创建,若从未使用会造成内存浪费,但实现简单
 */
public class EagerSingletonStatic {
    // 1. 私有构造器,防止外部new实例
    private EagerSingletonStatic() {
        System.out.println("EagerSingletonStatic 实例化");
    }

    // 2. 静态常量持有唯一实例,类加载时立即初始化
    //    static final 保证了变量的不可变性(引用不可变)
    private static final EagerSingletonStatic INSTANCE = new EagerSingletonStatic();

    // 3. 全局访问点
    public static EagerSingletonStatic getInstance() {
        return INSTANCE;
    }

    // 测试Main方法
    public static void main(String[] args) {
        EagerSingletonStatic s1 = EagerSingletonStatic.getInstance();
        EagerSingletonStatic s2 = EagerSingletonStatic.getInstance();
        System.out.println(s1 == s2); // true
    }
}
java 复制代码
/**
 * 饿汉式 - 静态代码块实现
 * 
 * 与静态常量方式的区别:初始化逻辑可以更复杂,比如从配置文件读取参数
 * 底层原理:静态代码块同样在类初始化阶段由JVM在<clinit>方法中按顺序执行,
 *           执行过程是线程安全的。
 */
public class EagerSingletonBlock {
    private static final EagerSingletonBlock INSTANCE;

    static {
        // 静态代码块中可以执行复杂的初始化逻辑
        System.out.println("静态代码块执行,类正在初始化...");
        // 此处可以读取配置、处理异常等
        INSTANCE = new EagerSingletonBlock();
    }

    private EagerSingletonBlock() {
        System.out.println("EagerSingletonBlock 实例化");
    }

    public static EagerSingletonBlock getInstance() {
        return INSTANCE;
    }

    public static void main(String[] args) {
        EagerSingletonBlock s1 = EagerSingletonBlock.getInstance();
        EagerSingletonBlock s2 = EagerSingletonBlock.getInstance();
        System.out.println(s1 == s2);
    }
}

饿汉式总结:实现简单,天然线程安全,但缺乏懒加载特性。在单例对象初始化耗时长且不一定会被使用的场景下,会拖慢应用启动速度并浪费内存。适合单例对象较小、一定会被使用的场景。

2. 懒汉式(线程不安全版)

懒汉式将实例化延迟到第一次调用getInstance()时,实现了懒加载,但未考虑并发场景。

java 复制代码
/**
 * 懒汉式 - 线程不安全版
 * 
 * 问题演示:多线程并发调用getInstance()时,可能产生多个实例
 * 底层原理:多个线程同时进入 if (instance == null) 判断时,
 *           都认为实例未创建,从而各自执行new操作
 */
public class LazySingletonUnsafe {
    private static LazySingletonUnsafe instance;

    private LazySingletonUnsafe() {
        // 模拟耗时初始化,放大并发问题出现概率
        try { Thread.sleep(100); } catch (InterruptedException e) {}
        System.out.println(Thread.currentThread().getName() + " 创建了实例");
    }

    public static LazySingletonUnsafe getInstance() {
        if (instance == null) {  // 线程不安全检查点
            instance = new LazySingletonUnsafe();
        }
        return instance;
    }

    // 测试并发问题
    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                LazySingletonUnsafe.getInstance();
            }, "Thread-" + i).start();
        }
        // 输出将显示多个线程创建了实例,证明单例被破坏
    }
}

3. 懒汉式(同步方法)

通过在getInstance()方法上添加synchronized关键字解决并发问题,但带来了性能瓶颈。

java 复制代码
/**
 * 懒汉式 - 同步方法版
 * 
 * 性能分析:每次调用getInstance()都需要获取类锁,即使实例已创建,
 *           高并发下会成为系统瓶颈。锁升级过程:
 *           无锁 -> 偏向锁(仅单线程访问) -> 轻量级锁(少量竞争) -> 重量级锁(竞争激烈)
 */
public class LazySingletonSyncMethod {
    private static LazySingletonSyncMethod instance;

    private LazySingletonSyncMethod() {
        System.out.println(Thread.currentThread().getName() + " 创建实例");
    }

    // synchronized修饰静态方法,锁对象为 LazySingletonSyncMethod.class
    public static synchronized LazySingletonSyncMethod getInstance() {
        if (instance == null) {
            instance = new LazySingletonSyncMethod();
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                LazySingletonSyncMethod.getInstance();
            }).start();
        }
        // 只会有一个线程创建实例
    }
}

性能瓶颈:当实例创建后,后续所有读操作仍需竞争锁。在JDK6之后经过锁优化(偏向锁、轻量级锁),性能有所提升,但仍无法与无锁方案媲美。

4. DCL双重检查锁定(重点分析volatile与JMM)

DCL(Double-Checked Locking)是懒加载单例的经典高性能实现,其正确性高度依赖于volatile关键字。

java 复制代码
/**
 * DCL双重检查锁定单例
 * 
 * 核心知识点:
 * 1. volatile的可见性与禁止指令重排语义
 * 2. JMM(Java内存模型)中对象创建的三个步骤
 * 3. 为什么必须用volatile修饰instance
 */
public class DCLSingleton {
    /**
     * volatile 关键作用:
     * 1. 保证多线程间的可见性:一个线程修改了instance引用,其他线程立即可见
     * 2. 禁止指令重排序:防止 instance = new DCLSingleton() 这个操作内部的
     *    "分配内存空间"、"初始化对象"、"将引用指向内存地址"三步发生重排序
     */
    private static volatile DCLSingleton instance;

    private DCLSingleton() {
        System.out.println(Thread.currentThread().getName() + " DCL实例创建");
    }

    public static DCLSingleton getInstance() {
        // 第一次检查:避免已创建实例后不必要的同步开销
        if (instance == null) {
            // 同步代码块,锁对象为 DCLSingleton.class
            synchronized (DCLSingleton.class) {
                // 第二次检查:防止多个线程同时通过第一次检查后,
                // 在竞争锁的过程中实例已被其他线程创建
                if (instance == null) {
                    /*
                     * 对象创建三步曲(无volatile时可能被重排序):
                     * 1. memory = allocate()      // 分配内存空间
                     * 2. ctorInstance(memory)     // 初始化对象
                     * 3. instance = memory        // 将instance引用指向内存地址
                     *
                     * JIT编译器可能将步骤2和步骤3重排序为:
                     * 1 -> 3 -> 2,此时instance已经非null但对象尚未初始化完成。
                     * 另一个线程进入第一次if (instance == null)时发现非null,
                     * 直接返回一个未初始化完成的对象,导致不可预知的错误。
                     *
                     * volatile通过内存屏障(Memory Barrier)禁止这种重排序。
                     * - 在volatile写操作前插入StoreStore屏障,禁止前面的普通写与volatile写重排
                     * - 在volatile写操作后插入StoreLoad屏障,禁止后面的volatile读/写与当前volatile写重排
                     */
                    instance = new DCLSingleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 0; i < 20; i++) {
            new Thread(() -> DCLSingleton.getInstance()).start();
        }
    }
}

JMM规范深度解释 :在Java内存模型中,volatile字段的写操作happens-before于后续对该字段的读操作。这保证了当线程A完成instance的初始化后,线程B读取instance时能立即看到完整构造的对象。如果缺少volatile,线程B可能看到一个未完全构造的对象(部分字段为默认值),导致严重bug。该问题在JDK5之前确实存在,JDK5增强了JMM后,DCL配合volatile才成为可靠的方案。

5. 静态内部类Holder模式(分析类加载的懒加载与线程安全机制)

这是一种结合了饿汉式线程安全与懒汉式延迟加载的优雅方案,利用了JVM类加载的懒加载特性。

java 复制代码
/**
 * 静态内部类(Holder)单例模式
 * 
 * 底层原理:
 * 1. 类加载的懒加载时机:JVM规定,类在使用时才进行加载和初始化。
 *    SingletonHolder类只有在调用getInstance()方法访问其静态字段INSTANCE时
 *    才会被加载和初始化,从而实现了延迟实例化。
 * 2. 线程安全性保证:JVM在类的初始化阶段(执行<clinit>方法)会获取一个
 *    Class对象锁,保证多线程环境下类的静态初始化过程是线程安全的。
 * 3. 无需synchronized,性能极佳,是目前最推荐的非枚举单例实现方式。
 */
public class HolderSingleton {

    private HolderSingleton() {
        System.out.println("HolderSingleton 实例化");
    }

    // 静态内部类,持有外部类单例
    private static class SingletonHolder {
        // 静态字段,在内部类被加载时初始化
        private static final HolderSingleton INSTANCE = new HolderSingleton();
    }

    public static HolderSingleton getInstance() {
        // 首次调用时触发 SingletonHolder 类的加载和初始化
        return SingletonHolder.INSTANCE;
    }

    // 类加载触发时机测试方法
    public static void otherStaticMethod() {
        System.out.println("调用外部类的静态方法不会触发内部类加载");
    }

    public static void main(String[] args) {
        System.out.println("--- 开始测试 ---");
        HolderSingleton.otherStaticMethod();  // 不会触发内部类加载
        System.out.println("--- 首次获取单例 ---");
        HolderSingleton s1 = HolderSingleton.getInstance(); // 触发内部类加载
        HolderSingleton s2 = HolderSingleton.getInstance();
        System.out.println(s1 == s2);
    }
}

类加载触发时机的深入解析 :JVM规范明确规定了类主动使用的七种场景,其中包括"读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)"。SingletonHolder.INSTANCE正是这样一种主动使用,因此会触发内部类的初始化。而外部类的静态方法otherStaticMethod()不会引用内部类,故不会导致其加载。这种懒加载机制实现了按需初始化,避免了饿汉式的资源浪费。

6. 枚举单例(《Effective Java》推荐的最佳实践)

枚举单例是Josh Bloch大力推崇的方式,它不仅能防止多线程并发问题,还对反射攻击和序列化攻击具有天然免疫力。

java 复制代码
/**
 * 枚举单例模式 - 最安全的单例实现
 * 
 * 优势分析:
 * 1. 线程安全:枚举实例的创建由JVM在类加载时完成,与饿汉式类似,天然线程安全
 * 2. 防反射攻击:Constructor.newInstance()源码中会判断如果是枚举类型则抛出异常
 * 3. 防序列化攻击:枚举的序列化机制只存储枚举名称,反序列化时通过valueOf方法获取
 *    已有的枚举常量,不会创建新实例。
 * 4. 代码简洁:无需私有构造器、getInstance方法等样板代码
 */
public enum EnumSingleton {
    INSTANCE;  // 唯一的枚举常量

    // 可以添加属性和方法
    private String configValue = "default";

    public void doSomething() {
        System.out.println("执行单例业务逻辑,实例hashCode: " + this.hashCode());
    }

    public String getConfigValue() {
        return configValue;
    }

    public void setConfigValue(String configValue) {
        this.configValue = configValue;
    }

    // 测试Main方法
    public static void main(String[] args) {
        EnumSingleton s1 = EnumSingleton.INSTANCE;
        EnumSingleton s2 = EnumSingleton.INSTANCE;
        s1.setConfigValue("updated");
        System.out.println(s1 == s2); // true
        System.out.println(s1.getConfigValue()); // updated
        System.out.println(s2.getConfigValue()); // updated
        s1.doSomething();
    }
}

枚举单例的底层保障 :枚举类型在Java中本质上是一个继承自java.lang.Enum的final类,其所有常量均为public static final字段,类加载时初始化。JVM层面保证了枚举常量的唯一性。后续攻防章节将进一步追溯源码证明其防御机制。


二、攻防分析:反射与序列化破坏单例及终极防御

单例模式的"唯一实例"承诺在普通使用场景下是可靠的,但面对Java的反射机制与序列化机制,普通实现(饿汉、懒汉、DCL、Holder)均存在被破坏的风险。本章节通过PoC攻击代码演示破坏过程,并深入JDK源码解释防御原理。

1. 反射攻击:setAccessible破坏单例与防御

攻击演示:通过反射调用私有构造器,创建多个实例。

java 复制代码
import java.lang.reflect.Constructor;

/**
 * 反射攻击PoC:破坏除枚举外的所有单例实现
 */
public class ReflectionAttack {
    public static void main(String[] args) throws Exception {
        // 目标:攻击 DCLSingleton (同样适用于HolderSingleton、饿汉式等)
        DCLSingleton instance1 = DCLSingleton.getInstance();
        DCLSingleton instance2 = null;

        // 1. 获取单例类的Class对象
        Class<DCLSingleton> clazz = DCLSingleton.class;
        // 2. 获取私有构造器
        Constructor<DCLSingleton> constructor = clazz.getDeclaredConstructor();
        // 3. 突破访问权限检查
        constructor.setAccessible(true);
        // 4. 通过反射创建新实例
        instance2 = constructor.newInstance();

        System.out.println("instance1 hashCode: " + instance1.hashCode());
        System.out.println("instance2 hashCode: " + instance2.hashCode());
        System.out.println("单例被破坏: " + (instance1 != instance2)); // true
    }
}

防御方案:在私有构造器中添加标志位检测

java 复制代码
/**
 * 增强型DCL单例(防反射)
 * 通过在构造器中检查实例是否已存在来防御反射攻击
 */
public class DCLSingletonReflectionProof {
    private static volatile DCLSingletonReflectionProof instance;
    // 标志位,记录实例是否已创建
    private static volatile boolean isCreated = false;

    private DCLSingletonReflectionProof() {
        synchronized (DCLSingletonReflectionProof.class) {
            if (isCreated) {
                throw new RuntimeException("单例已被创建,禁止通过反射重复实例化");
            }
            isCreated = true;
            System.out.println("DCLSingletonReflectionProof 实例化");
        }
    }

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

枚举对反射攻击的天然免疫------追溯Constructor.newInstance源码

攻击枚举单例时会发现constructor.newInstance()抛出IllegalArgumentException: Cannot reflectively create enum objects

我们追溯到JDK源码java.lang.reflect.ConstructornewInstance方法:

java 复制代码
// JDK 8 源码片段
public T newInstance(Object ... initargs) throws InstantiationException, IllegalAccessException,
               IllegalArgumentException, InvocationTargetException {
    if (!override) {
        if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
            Class<?> caller = Reflection.getCallerClass();
            checkAccess(caller, clazz, null, modifiers);
        }
    }
    // 关键判断:如果是枚举类型,直接抛出异常
    if ((clazz.getModifiers() & Modifier.ENUM) != 0)
        throw new IllegalArgumentException("Cannot reflectively create enum objects");
    ConstructorAccessor ca = constructorAccessor;   // read volatile
    if (ca == null) {
        ca = acquireConstructorAccessor();
    }
    @SuppressWarnings("unchecked")
    T inst = (T) ca.newInstance(initargs);
    return inst;
}

从源码可见,JDK在反射创建实例时特意检查了Modifier.ENUM标志位,如果是枚举类则直接抛出异常,从而在语言层面彻底封堵了反射攻击的路径。

2. 序列化攻击:ObjectInputStream破坏单例与防御

攻击演示:通过序列化与反序列化创建新的实例。

java 复制代码
import java.io.*;

/**
 * 序列化攻击PoC:针对普通单例(非枚举)
 */
public class SerializationAttack {
    public static void main(String[] args) throws Exception {
        // 以 HolderSingleton 为例
        HolderSingleton instance1 = HolderSingleton.getInstance();
        HolderSingleton instance2 = null;

        // 序列化到内存字节流
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        ObjectOutputStream oos = new ObjectOutputStream(baos);
        oos.writeObject(instance1);
        oos.close();

        // 反序列化
        ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
        ObjectInputStream ois = new ObjectInputStream(bais);
        instance2 = (HolderSingleton) ois.readObject();
        ois.close();

        System.out.println("instance1 hashCode: " + instance1.hashCode());
        System.out.println("instance2 hashCode: " + instance2.hashCode());
        System.out.println("单例被破坏: " + (instance1 != instance2)); // true
    }
}

防御原理:readResolve方法

Java序列化规范提供了一种回调机制:如果可序列化类定义了readResolve()方法,反序列化过程中会调用该方法,并将其返回的对象作为readObject()的最终结果。利用此特性,我们可以返回已存在的单例实例。

java 复制代码
import java.io.Serializable;

/**
 * 可序列化且防序列化攻击的单例(Holder模式版)
 */
public class HolderSingletonSerializable implements Serializable {
    private static final long serialVersionUID = 1L;

    private HolderSingletonSerializable() {
        System.out.println("HolderSingletonSerializable 构造器调用");
    }

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

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

    /**
     * 反序列化时JVM会自动调用此方法,返回的实例会替代反序列化生成的新对象
     * 调用时机:ObjectInputStream在读取对象数据后,通过反射调用此方法
     */
    private Object readResolve() {
        System.out.println("readResolve 被调用,返回已有单例");
        return SingletonHolder.INSTANCE;
    }
}

枚举序列化的特殊处理机制------追溯ObjectInputStream.readEnum源码

枚举的序列化由JVM特殊处理,并不遵循普通对象的序列化规则。我们查看ObjectInputStream.readEnum()方法的核心逻辑(JDK 8源码):

java 复制代码
// ObjectInputStream 中处理枚举反序列化的方法
private Enum<?> readEnum(boolean unshared) throws IOException {
    // ... 读取枚举类型描述符 ...
    String name = readString(false);  // 读取枚举常量名称
    Enum<?> result = null;
    Class<?> cl = enumType;
    if (cl != null) {
        try {
            @SuppressWarnings("unchecked")
            // 通过 Enum.valueOf(Class, String) 获取已存在的枚举常量
            Enum<?> en = Enum.valueOf((Class)cl, name);
            result = en;
        } catch (IllegalArgumentException ex) {
            // ... 异常处理 ...
        }
        // ...
    }
    return result;
}

可见,枚举反序列化并非重新构造一个新对象,而是通过Enum.valueOf()方法根据名称查找当前JVM中已存在的枚举常量。由于枚举常量在类加载时已全部创建完毕,反序列化得到的始终是同一个实例。因此枚举单例对序列化攻击也是天然免疫的。


三、源码级应用分析:JDK与主流框架中的单例实践

1. JDK经典案例

(1) Runtime类 ------ 饿汉式单例的典范

java 复制代码
// java.lang.Runtime 源码片段
public class Runtime {
    private static final Runtime currentRuntime = new Runtime();

    public static Runtime getRuntime() {
        return currentRuntime;
    }

    private Runtime() {}
    // ... 其他方法 ...
}

Runtime类代表Java应用程序的运行时环境,每个Java应用仅需一个Runtime实例来与JVM交互(如执行GC、获取内存信息等)。JDK采用标准的饿汉式实现,简单可靠。

(2) Collections.EMPTY_LIST ------ 不可变单例容器

java 复制代码
// java.util.Collections 源码片段
public class Collections {
    @SuppressWarnings("rawtypes")
    public static final List EMPTY_LIST = new EmptyList<>();

    // 返回类型安全的空列表单例
    @SuppressWarnings("unchecked")
    public static final <T> List<T> emptyList() {
        return (List<T>) EMPTY_LIST;
    }

    // 内部静态类,不可变空列表实现
    private static class EmptyList<E> extends AbstractList<E> implements RandomAccess, Serializable {
        // ... 所有修改操作均抛出UnsupportedOperationException ...
    }
}

此模式属于享元模式与单例模式的结合。由于空列表是无状态的不可变对象,全局共享一个实例既能节省内存,又无线程安全问题。

(3) Console类 ------ 懒汉式变体(双重检查锁风格)

java 复制代码
// java.io.Console 源码片段
public final class Console implements Flushable {
    private static volatile Console cons;

    private Console() {}

    public static Console console() {
        if (cons == null) {
            synchronized (System.class) {       // 注意锁对象是System.class
                if (cons == null) {
                    cons = SharedSecrets.getJavaIOAccess().console();
                }
            }
        }
        return cons;
    }
}

Console类使用了DCL变体,锁对象选择了System.class,因为控制台与系统输入输出紧密相关。这体现了单例与系统资源绑定的设计思路。

2. Spring源码:DefaultSingletonBeanRegistry三级缓存机制

Spring IoC容器中,默认作用域的Bean均为单例。其单例管理的核心位于DefaultSingletonBeanRegistry类,通过三级缓存巧妙解决了循环依赖问题。

核心缓存定义:

java 复制代码
// org.springframework.beans.factory.support.DefaultSingletonBeanRegistry
public class DefaultSingletonBeanRegistry extends SimpleAliasRegistry implements SingletonBeanRegistry {

    /** 一级缓存:存放完全初始化好的单例Bean,即成品 */
    private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);

    /** 二级缓存:存放早期暴露的单例Bean(未完成属性填充和初始化),用于解决循环依赖 */
    private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);

    /** 三级缓存:存放可以生成Bean的工厂对象(ObjectFactory),用于延迟生成代理对象 */
    private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap<>(16);

    // 正在创建中的Bean名称集合
    private final Set<String> singletonsCurrentlyInCreation = Collections.newSetFromMap(new ConcurrentHashMap<>(16));
}

getSingleton方法源码分析(解决循环依赖流程):

java 复制代码
protected Object getSingleton(String beanName, boolean allowEarlyReference) {
    // 步骤1:从一级缓存获取
    Object singletonObject = this.singletonObjects.get(beanName);
    // 步骤2:如果一级缓存没有,且该Bean正在创建中
    if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
        // 步骤3:从二级缓存获取早期引用
        singletonObject = this.earlySingletonObjects.get(beanName);
        if (singletonObject == null && allowEarlyReference) {
            synchronized (this.singletonObjects) {
                // Double Check
                singletonObject = this.singletonObjects.get(beanName);
                if (singletonObject == null) {
                    singletonObject = this.earlySingletonObjects.get(beanName);
                    if (singletonObject == null) {
                        // 步骤4:从三级缓存获取工厂,创建早期Bean引用
                        ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
                        if (singletonFactory != null) {
                            singletonObject = singletonFactory.getObject();
                            // 步骤5:升级到二级缓存,并从三级缓存移除
                            this.earlySingletonObjects.put(beanName, singletonObject);
                            this.singletonFactories.remove(beanName);
                        }
                    }
                }
            }
        }
    }
    return singletonObject;
}

三级缓存解决循环依赖的经典流程:

  1. A创建中 :实例化A(通过构造器),将A的ObjectFactory放入三级缓存。
  2. A填充属性B:发现依赖B,开始创建B。
  3. B创建中 :实例化B,将B的ObjectFactory放入三级缓存。
  4. B填充属性A :从缓存中获取A。通过getSingleton从三级缓存获取A的工厂,产生A的早期引用(可能为代理),放入二级缓存,并注入给B。
  5. B完成初始化:B成为完整Bean,放入一级缓存。
  6. A完成初始化:A拿到完整的B,继续属性填充及初始化,最终放入一级缓存。

注:三级缓存时序图见第七章节Mermaid图表。

3. MyBatis源码:ErrorContext基于ThreadLocal的线程级单例

MyBatis的ErrorContext类用于记录当前线程在执行SQL时的上下文错误信息,每个线程应有自己独立的ErrorContext实例,避免多线程间信息串扰,这属于线程级单例模式。

java 复制代码
// org.apache.ibatis.executor.ErrorContext 源码简化
public class ErrorContext {
    // ThreadLocal确保每个线程拥有独立的ErrorContext实例
    private static final ThreadLocal<ErrorContext> LOCAL = ThreadLocal.withInitial(ErrorContext::new);

    private ErrorContext() {}

    public static ErrorContext instance() {
        return LOCAL.get();
    }

    // 存储当前线程的错误信息
    private String resource;
    private String activity;
    private String object;
    // ... getter/setter ...
}

这种模式在"单例"的范围定义上做了延伸:单例并非一定是整个JVM唯一,也可以限定在线程范围内唯一 。类似的设计在Spring的RequestContextHolderTransactionSynchronizationManager中也有体现。

4. Logback源码:LoggerFactory的ILoggerFactory绑定与单例管理

Logback是SLF4J的原生实现,其核心LoggerContext作为ILoggerFactory的实现,在整个应用中应当只有一个实例。

java 复制代码
// ch.qos.logback.classic.LoggerContext 绑定过程简化
public final class LoggerFactory {
    static final String STATIC_LOGGER_BINDER_PATH = "org/slf4j/impl/StaticLoggerBinder.class";

    public static ILoggerFactory getILoggerFactory() {
        // ...
        // 通过StaticLoggerBinder获取单例的LoggerContext
        StaticLoggerBinder binder = StaticLoggerBinder.getSingleton();
        return binder.getLoggerFactory();
    }
}

// ch.qos.logback.classic.spi.LogbackServiceProvider (或 StaticLoggerBinder)
public class StaticLoggerBinder {
    private static StaticLoggerBinder SINGLETON = new StaticLoggerBinder();

    public static StaticLoggerBinder getSingleton() {
        return SINGLETON;
    }

    private LoggerContext defaultLoggerContext = new LoggerContext();

    public ILoggerFactory getLoggerFactory() {
        return defaultLoggerContext;
    }
}

LoggerContext内部维护了所有Logger实例的缓存(通过ConcurrentHashMap),其本身作为应用日志系统的根上下文,必须是单例的,以保证配置的统一和资源的集中管理。


四、分布式环境下的单例:从JVM唯一到集群唯一

单例模式的传统定义局限于单个JVM进程。当应用部署在多台服务器构成集群时,如何保证某个逻辑对象在整个集群中只有一个实例?这需要借助分布式协调服务。

1. 类加载器隔离导致Tomcat多WebApp场景下静态单例失效问题

问题场景 :Tomcat容器中部署了多个Web应用(WebAppA和WebAppB),两个应用都依赖了同一个包含单例模式的工具Jar包(如一个全局ID生成器)。由于Tomcat为每个WebApp创建独立的WebappClassLoader,同一个类会被不同的类加载器各加载一次,从而产生两个独立的Class对象和两个静态变量副本,导致单例失效。

解决方案

  • 将Jar包置于Tomcat的lib目录:由Common类加载器加载,所有WebApp共享同一个类定义。
  • 使用容器管理的单例:如将单例注册为JNDI资源。
  • 采用分布式单例方案(见下文),将唯一性保证交给外部协调服务,避免依赖类加载器层级。

2. 基于Redis分布式锁实现集群单例

利用Redis的SETNX(SET if Not eXists)命令实现互斥,确保在某一时刻只有一个服务节点能获取到"执行权"。

java 复制代码
import redis.clients.jedis.Jedis;
import redis.clients.jedis.params.SetParams;

/**
 * 基于Redis的分布式单例协调器
 * 模拟场景:集群中只有一个节点可以执行定时任务
 */
public class RedisDistributedSingleton {
    private static final String LOCK_KEY = "cluster:singleton:task";
    private static final String LOCK_VALUE = "instance-001"; // 建议使用UUID或主机标识
    private static final int LOCK_EXPIRE = 30; // 锁过期时间,防止死锁

    private Jedis jedis;

    public RedisDistributedSingleton() {
        // 初始化Redis连接
        jedis = new Jedis("localhost", 6379);
    }

    /**
     * 尝试获取"单例执行权"
     */
    public boolean tryAcquireLeadership() {
        SetParams params = new SetParams().nx().ex(LOCK_EXPIRE);
        String result = jedis.set(LOCK_KEY, LOCK_VALUE, params);
        return "OK".equals(result);
    }

    /**
     * 续期锁,适用于长任务
     */
    public boolean renewLeadership() {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "return redis.call('expire', KEYS[1], ARGV[2]) else return 0 end";
        Object result = jedis.eval(script, 1, LOCK_KEY, LOCK_VALUE, String.valueOf(LOCK_EXPIRE));
        return "1".equals(result.toString());
    }

    /**
     * 释放执行权(仅在持有时释放)
     */
    public void releaseLeadership() {
        String script = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
                        "return redis.call('del', KEYS[1]) else return 0 end";
        jedis.eval(script, 1, LOCK_KEY, LOCK_VALUE);
    }

    public static void main(String[] args) throws InterruptedException {
        RedisDistributedSingleton singleton = new RedisDistributedSingleton();
        // 模拟多个节点竞争
        if (singleton.tryAcquireLeadership()) {
            try {
                System.out.println("获取集群单例执行权,开始执行任务...");
                // 模拟长任务
                for (int i = 0; i < 60; i++) {
                    Thread.sleep(1000);
                    // 每10秒续期一次
                    if (i % 10 == 0) {
                        singleton.renewLeadership();
                    }
                }
            } finally {
                singleton.releaseLeadership();
                System.out.println("任务执行完毕,释放执行权");
            }
        } else {
            System.out.println("未获取到执行权,本节点作为备用");
        }
    }
}

设计思路 :此模式并非严格的"单例对象",而是通过分布式锁实现了单例行为的保障------保证某个任务在同一时间只有一个节点执行。适用于定时任务调度、资源清理等场景。

3. 基于ZooKeeper的Curator LeaderLatch实现分布式单例

Apache Curator提供了高级API LeaderLatch,封装了基于ZK临时顺序节点实现的Leader选举机制。

java 复制代码
import org.apache.curator.framework.CuratorFramework;
import org.apache.curator.framework.CuratorFrameworkFactory;
import org.apache.curator.framework.recipes.leader.LeaderLatch;
import org.apache.curator.framework.recipes.leader.LeaderLatchListener;
import org.apache.curator.retry.ExponentialBackoffRetry;

import java.util.concurrent.TimeUnit;

/**
 * 基于Curator LeaderLatch的分布式单例
 * 只有被选为Leader的节点才能执行业务逻辑
 */
public class ZkDistributedSingleton {
    private static final String ZK_CONNECT_STRING = "localhost:2181";
    private static final String LEADER_PATH = "/cluster/singleton/leader";

    private CuratorFramework client;
    private LeaderLatch leaderLatch;

    public ZkDistributedSingleton(String instanceId) {
        // 创建Curator客户端
        client = CuratorFrameworkFactory.builder()
                .connectString(ZK_CONNECT_STRING)
                .retryPolicy(new ExponentialBackoffRetry(1000, 3))
                .build();
        client.start();

        // 创建LeaderLatch
        leaderLatch = new LeaderLatch(client, LEADER_PATH, instanceId);
        leaderLatch.addListener(new LeaderLatchListener() {
            @Override
            public void isLeader() {
                System.out.println(instanceId + " 成为Leader,开始执行单例业务");
                executeSingletonTask();
            }

            @Override
            public void notLeader() {
                System.out.println(instanceId + " 失去Leader身份,停止单例业务");
            }
        });
    }

    public void start() throws Exception {
        leaderLatch.start();
    }

    public void close() throws Exception {
        leaderLatch.close();
        client.close();
    }

    private void executeSingletonTask() {
        // 执行只有Leader才能做的操作,例如:清理过期数据、推送全局配置等
        new Thread(() -> {
            while (leaderLatch.hasLeadership()) {
                try {
                    System.out.println("Leader节点执行业务...");
                    TimeUnit.SECONDS.sleep(5);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        }).start();
    }

    public static void main(String[] args) throws Exception {
        // 模拟集群中三个节点
        ZkDistributedSingleton node1 = new ZkDistributedSingleton("node-1");
        ZkDistributedSingleton node2 = new ZkDistributedSingleton("node-2");
        ZkDistributedSingleton node3 = new ZkDistributedSingleton("node-3");

        node1.start();
        node2.start();
        node3.start();

        // 让程序运行一段时间观察Leader切换
        TimeUnit.MINUTES.sleep(2);

        node1.close();
        node2.close();
        node3.close();
    }
}

LeaderLatch原理 :每个参与者都在ZK的指定路径下创建一个临时顺序节点,序号最小的节点成为Leader。当Leader节点会话断开或主动释放,临时节点被删除,剩余节点中序号最小的自动当选新Leader。这种机制保证了集群中始终有且仅有一个Leader,从而实现分布式单例语义。


五、适用场景分析:何处用单例,何处需谨慎

1. 资源池化场景:数据库连接池、线程池为何需要单例

技术理由

  • 资源昂贵且可共享:数据库连接创建开销大(TCP握手、认证),池化复用可显著提升性能。单例连接池确保全局唯一入口,避免多处独立创建池导致连接数失控。
  • 统一配置与管理:连接池参数(最大连接数、超时时间)需在应用级别保持一致,多实例易造成配置冲突和资源浪费。
  • 生命周期与JVM绑定:连接池通常随应用启动创建,随应用关闭销毁,与JVM进程生命周期一致,适合单例管理。

2. 全局配置管理:应用配置对象、系统参数对象的单例设计

技术理由

  • 数据一致性:配置信息(如数据库URL、第三方API密钥)在运行期间通常是只读的,单例可保证所有模块获取到一致的配置视图。
  • 减少重复加载开销:从文件、环境变量或配置中心加载配置可能涉及IO或网络请求,单例模式结合懒加载可将开销降至最低。
  • 支持动态刷新 :单例配置对象可配合监听器机制,实现配置热更新而不影响客户端引用(例如@ConfigurationProperties在Spring Cloud Config中的运用)。

3. 无状态工具类:如日期格式化、加密解密工具类的单例化考量

技术理由

  • 无状态所以线程安全:工具类方法不依赖成员变量,天然线程安全。单例化可减少对象创建开销。
  • 反面警示------SimpleDateFormat并非无状态SimpleDateFormat内部持有Calendar对象,多线程共享会导致日期解析错乱。此类有状态工具类严禁使用静态单例 ,应使用ThreadLocal或每次新建实例。

4. 硬件接口访问:打印机、串口等独占资源场景

技术理由

  • 物理独占性 :打印机、串口等硬件设备同一时刻只能被一个任务操作。单例对象作为硬件访问的逻辑代理,可内置同步锁或队列机制来串行化访问请求,避免并发操作导致设备状态混乱或数据损坏。
  • 生命周期统一管理:在单例对象中集中打开/关闭硬件连接,简化资源管理逻辑。

5. Spring Bean作用域:singleton作用域的适用边界与注意事项

适用边界

  • 无状态Service、Controller、Repository:默认单例是正确且高效的选择。
  • 共享的只读配置Bean
  • 线程安全的工具组件

注意事项

  • 避免在单例Bean中定义可变实例变量@Controller中持有请求相关的成员变量,会导致线程安全问题(Spring本身不保证单例Bean的线程安全,需要开发者自己保证)。
  • 注意作用域依赖 :单例Bean中注入prototype作用域的Bean,后者只会被注入一次,无法实现每次获取都得到新实例。需使用@LookupApplicationContext.getBean()

6. 不适用场景警示:哪些场景应避免使用单例

  • 携带可变状态的对象 :如上述SimpleDateFormat,以及含有用户会话数据的对象。此类对象应使用prototyperequest/session作用域。
  • 高并发下的瓶颈点 :若单例方法内部有重量级synchronized锁,会成为系统并发度的天花板。例如早期Hashtable的单例运用(现已由ConcurrentHashMap替代)。
  • 需频繁创建销毁的轻量级对象:如DTO、VO,单例化反而增加状态管理的复杂度。
  • 与特定上下文绑定的对象 :如HttpServletRequestHttpServletResponse,其生命周期由容器管理,强行单例化无意义。

六、面试题精选与专家级解答

以下精选10道单例模式相关的高频Java专家面试题,并提供有技术深度的参考答案。

1. 为什么DCL需要volatile?从JMM角度解释

参考答案 : DCL中instance = new Singleton()这一行代码在JVM层面并非原子操作,可分解为三个步骤:

  1. 分配内存空间。
  2. 执行构造方法初始化对象。
  3. instance引用指向分配的内存地址。

JMM允许编译器和处理器对指令进行重排序 以提高执行效率。上述步骤可能被重排为1→3→2 的执行顺序。当线程A执行完1和3后,instance已经不为null,但对象尚未初始化完成(字段均为默认值)。此时若线程B执行第一次if (instance == null)检查,发现非null,直接返回未初始化完成的对象,后续调用其方法或访问字段将引发不可预知的错误(如NPE或数据不一致)。

volatile关键字通过以下机制禁止这种重排序:

  • 内存屏障 :在volatile写操作(如对instance的赋值)前后插入StoreStore屏障和StoreLoad屏障,防止前面的写操作与后面的volatile写操作重排,也防止后面的读/写操作与volatile写重排。
  • happens-before规则 :对volatile变量的写操作happens-before 后续对该变量的读操作,保证线程B读取instance时,能看到线程A写入instance之前的所有操作结果,即看到的是一个完整构造的对象。

因此,缺少volatile的DCL在JDK5之前(及之后未使用volatile时)存在严重的安全隐患。

2. 枚举单例是如何防止反射和序列化攻击的?

参考答案防反射攻击 :JDK在java.lang.reflect.Constructor.newInstance()方法中有显式判断:

java 复制代码
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
    throw new IllegalArgumentException("Cannot reflectively create enum objects");

当通过反射尝试创建枚举实例时,JVM直接抛出异常,从根本上禁止了枚举的反射实例化。

防序列化攻击 :枚举的序列化与反序列化有特殊处理。序列化时,仅将枚举常量的名称 写入流中,不保存任何字段状态。反序列化时,ObjectInputStream.readEnum()方法根据读取到的名称,调用Enum.valueOf(Class<T> enumType, String name)获取对应的枚举常量。由于枚举常量在类加载时已全部实例化完成,valueOf返回的是JVM中已存在的同一实例,因此不会产生新对象。这种机制保证了枚举在序列化反序列化过程中的单例性。

3. 单例模式在集群环境下如何保证唯一性?

参考答案 : 集群环境下,JVM进程相互隔离,内存级单例无法跨进程生效。保证集群唯一性需借助分布式协调中间件

  • 基于数据库唯一约束:通过插入一条记录到带有唯一索引的表来抢占"单例执行权",任务结束后删除记录。缺点是不够实时,需处理锁超时。
  • 基于Redis分布式锁 :利用SETNX命令实现互斥,可设置过期时间防止死锁。适合轻量级的任务互斥。
  • 基于ZooKeeper :利用ZK的临时顺序节点和Watcher机制实现Leader选举。Curator框架的LeaderLatch封装了此模式,当Leader宕机时自动触发重新选举,保证集群中始终有且仅有一个Leader节点。
  • 基于配置中心下发指令:如使用Nacos、Apollo下发一个开关变量,只有特定IP的节点执行任务。

4. Spring的单例Bean是线程安全的吗?为什么?

参考答案Spring容器本身并不保证单例Bean的线程安全性。Spring只是管理Bean的创建和装配,Bean的线程安全取决于其自身的状态管理方式。

  • 如果单例Bean是无状态 的(例如典型的@Service@Repository@Controller,没有可变的成员变量),那么它自然是线程安全的。
  • 如果单例Bean持有可变状态 (如成员变量),并且有多个线程并发修改该状态,则会出现线程安全问题。此时需要开发者通过sychronizedLock或使用ThreadLocal将状态线程私有化来保证安全。
  • 对于有状态的Bean,Spring提供了prototyperequestsession等作用域来为每次访问创建新实例,从根本上避免共享状态问题。

5. 单例模式与静态工具类在设计上的本质区别是什么?

参考答案

  • 面向对象设计 :单例模式是对象,可以实现接口、继承类,具备多态特性。静态工具类仅是一组静态方法的集合,无法实现接口或继承,是面向过程的编码风格。
  • 延迟加载与资源管理:单例模式可控制实例化时机(懒加载),可以在构造器中执行资源初始化(如建立连接),并能在销毁时释放资源。静态类在类加载时初始化,生命周期完全由JVM类加载器控制,缺乏灵活的初始化和销毁机制。
  • 状态保持:单例可以持有状态(尽管通常不推荐,但可以),静态类虽然也有静态变量,但缺乏封装性。
  • 可测试性:单例由于是对象,可以通过依赖注入、Mock等方式替换或模拟,方便单元测试。静态工具类的方法调用是硬编码的,难以Mock。

6. 如何优雅地实现可销毁的单例对象?

参考答案: 标准的单例模式缺少显式的销毁接口。要实现可销毁的单例,可以考虑以下方案:

  • 提供destroy()方法并清空引用 :在单例类中添加public static synchronized void destroy()方法,内部将instance置为null,并执行资源清理。需要注意并发安全问题,在destroy()getInstance()之间需同步协调。
  • 使用JVM Shutdown Hook :通过Runtime.getRuntime().addShutdownHook(Thread)注册钩子,在JVM关闭前执行清理逻辑。
  • 实现AutoCloseable接口 :让单例类实现AutoCloseable,在try-with-resources块中使用,但需注意单例获取方式需适配。
  • 借助容器管理 :如在Spring中,单例Bean会随容器关闭而销毁,可声明@PreDestroy方法进行资源释放。

7. 单例模式可能带来哪些代码坏味道?如何重构?

参考答案代码坏味道

  • 隐式全局状态:单例本质是全局变量,导致模块间产生隐式耦合,代码难以理解和测试。
  • 违反单一职责原则:单例类往往不仅管理自身实例,还承担业务逻辑,职责不清。
  • 阻碍单元测试:单例的全局状态会在多个测试用例间串扰,必须手动重置状态。
  • 强依赖导致难以扩展 :直接调用Singleton.getInstance()形成了硬编码依赖,替换实现困难。

重构策略

  • 依赖注入(DI):通过构造器或Setter将单例作为依赖注入,由容器(如Spring)管理其生命周期。客户端不感知单例,只依赖接口。
  • 引入工厂模式:将单例获取逻辑封装在工厂中,降低直接耦合。
  • 使用枚举替代:枚举单例更简洁、安全,且易于测试(可通过扩展性受限于语言特性)。

8. 类加载器如何破坏单例?如何防御?

参考答案破坏机制 :JVM中,类的唯一性由类加载器类全限定名 共同决定。如果同一个单例类被不同的类加载器加载(例如Tomcat的多个WebApp),JVM中会存在多个Class对象,它们的静态变量是隔离的,从而产生多个"单例"实例。

防御方案

  • 统一类加载器 :将单例所在的Jar包置于更高层级的类加载器路径下(如Tomcat的lib目录、Java的ext目录),确保所有WebApp使用同一个类定义。
  • 使用容器JNDI资源:通过JNDI绑定单例对象,由容器管理唯一性。
  • 显式指定类加载器:在获取单例时,强制使用某个特定类加载器加载类(需谨慎,可能引发更多类加载问题)。
  • 设计层面规避:不依赖静态单例,改用分布式单例或Spring容器单例Bean。

9. 饿汉式与懒汉式在性能、故障排查角度的取舍考量

参考答案

维度 饿汉式 懒汉式(线程安全版如Holder)
启动性能 类加载时立即初始化,若单例构造耗时,会延长应用启动时间 延迟到首次调用,启动快
运行时性能 无任何同步开销,获取速度快 首次调用有初始化开销,后续无同步开销(Holder)或需锁(方法同步)
故障排查 启动时抛异常可立即发现,Fail-Fast 运行时首次调用时才暴露初始化问题,排查相对被动
内存占用 可能提前占用内存,若从未使用则浪费 按需占用,更节约内存
实现复杂度 极简 较复杂(需考虑并发与指令重排)

专家建议 :对于初始化快速、确定会被使用的单例,优先饿汉式 ;对于初始化成本高、可能不会被使用的单例,优先Holder模式 ;需要传递上下文或依赖外部资源的单例,使用枚举单例

10. 除了枚举,还有哪些手段可以彻底防御反射攻击?

参考答案

  • 构造器内标志位检查(如前文所述) :在私有构造器中通过静态布尔标志位判断,若已创建则抛出异常。但此方法仍能被反射绕过 (先通过反射修改标志位为false再创建)。
  • 利用SecurityManager :配置Java安全策略,禁止setAccessible(true)操作。但这会限制整个应用的反射能力,较为粗暴。
  • 使用Unsafe类破坏反射能力 :某些极端手段可通过Unsafe修改Constructormodifiersoverride字段状态,但不可移植,且JDK升级可能失效。
  • 终极手段仍是枚举 :因为枚举的防御逻辑在JDK的Constructor.newInstance()源码中,是语言层面的硬性规定,无法被任何Java代码绕过(除非修改JDK源码或使用非常规的字节码增强绕过反射检查)。

已为您在第七章节各Mermaid图表下方补充详细文字说明,便于读者深入理解流程图与时序图的每个步骤。以下是更新后的图表部分内容,您可直接替换原文相应章节。


七、图表要求:Mermaid流程图与时序图(附详细文字说明)

1. DCL双重检查锁定的执行流程图

flowchart TD A["调用 getInstance()"] --> B{"instance == null ?"} B -- "否" --> Z["返回 instance"] B -- "是" --> C["进入 synchronized 代码块"] C --> D{"再次检查 instance == null ?"} D -- "否" --> Z D -- "是" --> E["分配内存空间"] E --> F["初始化对象"] F --> G["将 instance 引用指向内存地址
(volatile 写屏障禁止重排序)"] G --> Z classDef default fill:#f9f9f9,stroke:#333,stroke-width:1px; classDef condition fill:#fff4e6,stroke:#ffa500,stroke-width:1.5px; classDef important fill:#ffebee,stroke:#f44336,stroke-width:2px; class B,D condition; class G important;

流程文字说明

  1. 首次检查(instance == null?)

    线程进入getInstance()后先执行无锁的null检查。若实例已存在,则直接返回,这是性能优化的关键------避免已初始化后的每次调用都进入同步块争抢锁,将高并发下的锁竞争降至最低。

  2. 获取类锁(synchronized块)

    仅当首次检查发现实例为null时,线程才尝试获取Singleton.class的监视器锁。多个线程可能同时通过首次检查,但在锁竞争时只有一个能进入同步块,其余阻塞等待。

  3. 二次检查(再次检查 instance == null?)

    进入同步块的线程必须再次检查 实例是否仍为null。原因在于:当前线程在等待锁期间,可能已有其他线程完成了实例创建并释放了锁。若不做二次检查,将导致重复创建实例,破坏单例唯一性。

  4. 对象创建三步曲与volatile的屏障作用

    • 分配内存:JVM为对象在堆上分配空间。
    • 初始化对象:执行构造方法,为字段赋初始值。
    • 引用指向 :将instance变量指向分配的内存地址。
      若无volatile修饰,步骤②和③可能被编译器或CPU指令重排 为①→③→②。此时若另一线程进入首次检查,将看到instancenull而直接返回一个未初始化完成的对象 (字段为默认值),引发NPE或逻辑错误。
      volatile在步骤③前后插入StoreStore屏障StoreLoad屏障 ,强制禁止这种重排序,保证任何线程读取instance时,对象已完全构造完毕。

2. Spring三级缓存解决循环依赖的时序图

sequenceDiagram participant Client participant Spring as Spring容器 participant Cache1 as 一级缓存(singletonObjects) participant Cache2 as 二级缓存(earlySingletonObjects) participant Cache3 as 三级缓存(singletonFactories) Note over Client,Spring: 场景:A依赖B,B依赖A Client->>Spring: getBean("A") Spring->>Spring: 实例化A(构造器) Spring->>Cache3: 放入A的ObjectFactory(可生成早期引用) Spring->>Spring: 填充A属性,发现依赖B Spring->>Spring: getBean("B") Spring->>Spring: 实例化B(构造器) Spring->>Cache3: 放入B的ObjectFactory Spring->>Spring: 填充B属性,发现依赖A Spring->>Spring: getBean("A") [递归调用] Spring->>Cache1: 查询A (未找到) Spring->>Cache2: 查询A (未找到) Spring->>Cache3: 获取A的ObjectFactory Cache3-->>Spring: 返回A的早期引用(可能为代理) Spring->>Cache2: 将A早期引用放入二级缓存 Spring->>Cache3: 移除A的ObjectFactory Spring-->>Spring: 返回A早期引用给B Spring->>Spring: B完成属性填充和初始化 Spring->>Cache1: 将完整B放入一级缓存 Spring-->>Spring: 返回B给A Spring->>Spring: A拿到完整的B,继续属性填充及初始化 Spring->>Cache1: 将完整A放入一级缓存 Spring-->>Client: 返回A

时序图文字说明

  1. 触发A的创建

    客户端请求getBean("A"),Spring容器发现一级缓存中没有A,开始创建流程。

  2. A的实例化与早期工厂暴露

    Spring先调用A的构造器完成实例化 (此时属性尚未填充)。随后,将A的ObjectFactory(一个能生成A早期引用的lambda表达式)放入三级缓存 。这一步是解决循环依赖的前置条件

  3. 填充A的属性,触发B的创建

    容器开始为A填充属性,发现A依赖B。于是递归调用getBean("B")。由于B也不存在于任何缓存,容器同样实例化B并将B的ObjectFactory放入三级缓存。

  4. B依赖A,从缓存获取A的早期引用

    填充B时发现B依赖A,再次调用getBean("A")。这次查找顺序为:一级缓存(无)→ 二级缓存(无)→ 三级缓存 。三级缓存中找到了A的ObjectFactory,调用其getObject()方法获得A的早期引用(若A需要AOP代理,此处会生成代理对象)。

  5. 缓存升级

    Spring将获取到的A早期引用放入二级缓存,并从三级缓存中移除A的工厂。此举防止重复创建代理对象,保证后续获取的都是同一个早期引用。

  6. B完成初始化

    B拿到A的早期引用后,完成自身的属性填充与初始化,成为完整Bean ,并被放入一级缓存(同时移除二、三级缓存中的B)。

  7. A完成初始化

    递归返回后,A获得完整的B实例,继续完成自身的属性填充与初始化。最终A也成为完整Bean,放入一级缓存。

  8. 关键机制总结

    • 一级缓存存"成品",供外部获取。
    • 二级缓存存"半成品早期引用",解决循环依赖。
    • 三级缓存存"工厂",延迟生成代理对象,确保AOP在循环依赖场景下仍正确工作。

3. 序列化反序列化中readResolve调用时序图

sequenceDiagram participant OIS as ObjectInputStream participant JVM participant Obj as 反序列化生成的对象 participant Singleton as 单例类 OIS->>JVM: 读取字节流,构造新对象实例 JVM-->>OIS: 返回新对象 tempObj OIS->>Obj: 填充对象字段数据 OIS->>OIS: 检查类是否定义了 readResolve 方法 alt 定义了 readResolve OIS->>Obj: 通过反射调用 tempObj.readResolve() Obj->>Singleton: 返回已存在的单例 INSTANCE Singleton-->>OIS: 返回 INSTANCE Note over OIS,Obj: 丢弃tempObj,最终返回INSTANCE else 未定义 readResolve OIS-->>OIS: 直接返回 tempObj end

时序图文字说明

  1. 构造新对象(tempObj)
    ObjectInputStream从字节流中读取类描述信息,绕过构造器 直接在堆上重建一个新对象tempObj。这是序列化破坏单例的根本原因------即使构造器私有,反序列化也能创建新实例。

  2. 填充字段数据
    OIS根据流中的持久化数据,通过反射将字段值恢复到tempObj中。此时tempObj在内存层面已是单例类的另一个"完整"实例。

  3. 检查readResolve方法
    OIS通过反射检查该类是否定义了private Object readResolve()方法。这是Java序列化规范提供的回调钩子

  4. 分支一:定义了readResolve

    • OIS调用tempObj.readResolve()
    • 在该方法中,开发者编写的逻辑返回了真正的单例实例 (如SingletonHolder.INSTANCE)。
    • OIS收到返回值后,丢弃掉刚刚构建的tempObj ,将readResolve的返回值作为readObject()的最终结果返回给调用方。
    • 由于tempObj不再被引用,稍后会被GC回收,从而保证了单例的唯一性。
  5. 分支二:未定义readResolve

    • OIS直接将tempObj作为反序列化结果返回。此时客户端获得的是一个全新的伪造实例,单例模式被彻底破坏。
  6. 枚举的特殊性

    对于枚举类型,ObjectInputStream不会执行上述流程,而是走readEnum()分支,通过名称查找已存在的枚举常量并返回,从根本上杜绝了新实例的产生。


八、结语

单例模式作为设计模式中的"Hello World",其简洁的外表下蕴藏着Java语言底层机制的众多精华------从类加载器的双亲委派模型到JMM的happens-before原则,从synchronized锁升级过程到序列化协议的回调钩子。本文通过六种实现方式的代码级剖析、反射与序列化的攻防实战、主流框架的源码探索以及分布式场景的扩展思考,全方位展现了单例模式在单机与集群环境中的最佳实践与潜在陷阱。

理解单例模式不仅是为了应对面试中的刁钻提问,更是为了在系统设计时能精准权衡资源利用、并发性能与代码可维护性。当您下次面对一个"只需一个实例"的需求时,希望本文能帮助您做出更具专业深度的技术决策。

相关推荐
ximu_polaris2 小时前
设计模式(C++)-结构型模式-组合模式
c++·设计模式·组合模式
geovindu3 小时前
go: Singleton Pattern
单例模式·设计模式·golang
ximu_polaris3 小时前
设计模式(C++)-结构型模式-外观模式
c++·设计模式·外观模式
不知名的老吴3 小时前
思考:设计模式对前端有用吗?
设计模式·状态模式
ximu_polaris3 小时前
设计模式(C++)-创造型模式-建造者模式
c++·设计模式·建造者模式
likerhood4 小时前
设计模式之建造者模式(Builder Pattern)java版本
java·设计模式·建造者模式
周末也要写八哥4 小时前
前端三大类设计模式学习
学习·设计模式
jump_jump13 小时前
GetX — Flutter 的瑞士军刀,还是过度封装的陷阱?
flutter·设计模式·前端框架
wuyikeer19 小时前
Spring Boot 经典九设计模式全览
java·spring boot·设计模式