单例模式详细讲解(java)

单例模式

文章目录

  • 单例模式
    • 前言
    • 一、单例模式介绍
    • [二、 六种实现方式](#二、 六种实现方式)
      • [2.1 饿汉式](#2.1 饿汉式)
        • [2.1.1 模板代码](#2.1.1 模板代码)
        • [2.1.2 详细测试代码](#2.1.2 详细测试代码)
      • [2.2 懒汉式 - 线程不安全版](#2.2 懒汉式 - 线程不安全版)
        • [2.2.1 模板代码](#2.2.1 模板代码)
        • [2.2.2 详细测试代码](#2.2.2 详细测试代码)
      • [2.3 懒汉式 - 同步锁版(线程安全但低效)](#2.3 懒汉式 - 同步锁版(线程安全但低效))
        • [2.3.1 模板代码](#2.3.1 模板代码)
        • [2.3.2 详细测试代码](#2.3.2 详细测试代码)
      • [2.4 双重检查锁(DCL, Double-Checked Locking)- 面试/工程高频](#2.4 双重检查锁(DCL, Double-Checked Locking)- 面试/工程高频)
        • [2.4.1 模板代码](#2.4.1 模板代码)
        • [2.4.2 详细测试代码](#2.4.2 详细测试代码)
      • [2.5 静态内部类(Static Inner Class)- 优雅且安全](#2.5 静态内部类(Static Inner Class)- 优雅且安全)
        • [2.5.1 模板代码](#2.5.1 模板代码)
        • [2.5.2 详细测试代码](#2.5.2 详细测试代码)
      • [2.6 枚举(Enum)- 单例的终极防御](#2.6 枚举(Enum)- 单例的终极防御)
        • [2.6.1 模板代码](#2.6.1 模板代码)
        • [2.6.2 详细测试代码](#2.6.2 详细测试代码)
      • 总结

前言

单例模式的目的极其明确:确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。

这看似简单,但在实际的工程应用(尤其是高并发后端系统)中,要做到"绝对安全"却暗藏玄机。

在系统开发中,有些对象如果出现多个实例,会导致资源浪费、状态不一致或逻辑混乱。

  1. 全局状态与历史管理器 :例如在医疗辅助诊断系统中,针对某次会话的诊断历史记录管理器(Diagnostic History Manager)。如果在不同模块(如影像解析模块和问诊模块)中存在多个历史管理器实例,会导致历史数据无法同步,出现丢记录的 Bug。
  2. 硬件/系统资源访问:数据库连接池(Connection Pool)、线程池。建立连接极其消耗资源,整个系统应当共享同一个连接池来统一调度。
  3. 系统配置信息类 :读取全局的配置文件(如 .properties.yml),这些配置在内存中只需存一份即可。

本文项目源码链接:https://github.com/likerhood/CodeDesignWork/tree/main/codedesign4.0-0


一、单例模式介绍

单例模式是设计模式中最简单、也最符合开发者直觉的模式。

它在实际开发中的应用极高,其核心设计理念可以高度浓缩为两点:

  • 绝对唯一:在多线程等复杂环境下,保证一个类仅有一个实例,并提供一个全局访问点。
  • 性能优化:阻断全局类的频繁创建与销毁,降低资源开销,从根本上提升系统整体性能。

二、 六种实现方式

单例的核心构造原则是:构造方法私有化(Private Constructor)

但在多线程环境下,如何安全地向外暴露这个实例,就演化出了不同的写法。

2.1 饿汉式

最简单,但可能浪费资源。 类加载时就立即初始化,利用类加载机制保证了线程安全。

  1. 私有化构造函数;
  2. 通过static静态变量保证类加载时就会创建该类的实例对象
  3. 提供全部访问这个单例对象的公开方法(静态方法)。

在 Java 中,当一个类被 JVM 加载到内存并进行初始化时,JVM 会自动执行类中的静态代码块(static {})以及静态变量的赋值操作

因为我们写了 private static final EagerSingleton INSTANCE = new EagerSingleton();,所以当 EagerSingleton 这个类一被 JVM 识别并加载,这行代码就会立刻执行,从而调用了私有构造器,把对象创建出来了。而且,JVM 底层的类加载机制天生是线程安全的,这就保证了在多线程环境下,这个对象也只会被创建一次。

缺点:如果这个类包含了庞大的数据字典或需要加载大量本地资源,且系统启动后很久都没用到它,就会白白占用内存。

2.1.1 模板代码
java 复制代码
public class EagerSingleton {
    // 1. 私有化构造器
    private EagerSingleton() {}
    // 2. 类加载时立刻实例化,天生线程安全
    private static final EagerSingleton INSTANCE = new EagerSingleton();
    // 3. 提供全局访问点
    public static EagerSingleton getInstance() {
        return INSTANCE;
    }
}
2.1.2 详细测试代码

模拟如下场景,在单例模式的类中初始化需要做这些工作:

  • 读取了一个 50MB 的本地配置文件。
  • 建立了一个数据库连接池。
  • 初始化了一个庞大的数据字典。

如果你的系统在启动时触发了这个类的加载(比如你不小心调用了这个类的其他静态方法,或者用反射扫到了它 ),即使你当前根本不需要用到这个单例对象,这 50MB 的内存和数据库连接也就被死死占用了。这就叫"白白浪费了系统资源"。

Java 复制代码
package com.likerhood.design;

/**
 * 饿汉式,单例模式
 */
public class Singleton_00 {

    // 假设这是一个其他的静态变量
    public static final String SOME_OTHER_STATIC_FIELD = new String("饿汉式单例模式");

    // 1. 静态变量,在类加载时初始化
    private static final Singleton_00 INSTANCE = new Singleton_00();

    // 2. 私有化构造器,里面模拟"耗时且占用资源"的操作
    private Singleton_00() {
        System.out.println(">>> 构造器被调用!Singleton_00实例正在被创建...");
        System.out.println(">>> 正在加载 50MB 配置文件...");
        System.out.println(">>> 正在建立数据库连接...\n");
    }

    // 3. 提供全局访问点
    public static Singleton_00 getInstance() {
        System.out.println("--- getInstance() 方法被调用 ---");
        return INSTANCE;
    }

    public void doSomething() {
        System.out.println("执行饿汉式单例对象的核心业务逻辑。");
    }
}


public class ApiTest {

    public static void test_singleton_00(){

        System.out.println("========== 系统启动 ==========\n");

        // 模拟系统运行了一段时间,我们仅仅是访问了这个类的另一个静态变量
        System.out.println("业务代码:我尝试访问 HeavyEagerSingleton 的另一个静态变量...");
        String temp = Singleton_00.SOME_OTHER_STATIC_FIELD;
        System.out.println("业务代码获取到的值: " + temp + "\n");
        // 注意:我们并没有调用 HeavyEagerSingleton.getInstance() !!!
        System.out.println("此时没有在该方法中接受饿汉式单例模式的实例对象,但是该实例对象已经被创建\n");

        // 直到此刻,我们才真正需要用到这个单例对象
        System.out.println("业务代码:我现在真正需要饿汉式单例模式的单例对象了!");
        Singleton_00 instance1 = Singleton_00.getInstance();
        Singleton_00 instance2 = Singleton_00.getInstance();

        System.out.println("\ninstance1 和 instance2 是同一个对象吗? " + (instance1 == instance2));

        instance1.doSomething();

        System.out.println("饿汉式单例模式执行完成");
    }


    public static void main(String[] args) {

        test_singleton_00();
    }

}

上述测试结果如下:

该代码案例中的内存图可视化如下:

2.2 懒汉式 - 线程不安全版

懒汉式单例模式为了解决饿汉式的资源浪费问题:将对象实例的初始化改为延迟加载。

2.2.1 模板代码
Java 复制代码
public class LazyUnsafeSingleton {
    private static LazyUnsafeSingleton instance;
    private LazyUnsafeSingleton() {}

    public static LazyUnsafeSingleton getInstance() {
        if (instance == null) { // 线程A跑到这里,时间片耗尽
            instance = new LazyUnsafeSingleton(); // 线程B跑到这里并创建了实例
        }
        return instance; // 最终线程A恢复,又创建了一个实例!单例被打破。
    }
}

系统刚启动,或者很长一段时间内 :没有任何业务调用 getInstance(),那么 instance 就一直是 null。什么都没发生,内存极其干净。

第一次有人调用 getInstance()

  1. 程序走到 if (instance == null),发现确实是 null(条件成立)。
  2. 于是进入 if 代码块内部,执行 new LazyUnsafeSingleton(),在这个时刻,才真正去申请内存、消耗 CPU 资源来创建对象。
  3. 创建完成后,把这个对象的地址赋给 instance 变量,最后返回。

回忆一下在前文对"饿汉式"的测试与总结:饿汉式最大的缺点是"只要触发类加载,不管你用不用,对象都会被立刻创建,白白浪费资源"。

而懒汉式的这个 if,就是专门为了解决这个资源浪费而生的。

场景对比(以耗时 50MB 内存的诊断影像解析引擎为例):

  • 使用"饿汉式"的灾难: 系统刚启动,仅仅是因为某个模块读取了该类的一个普通静态配置,JVM 触发了类加载。不管当前有没有医生在使用影像解析功能,引擎立刻被初始化,瞬间吃掉 50MB 内存,甚至霸占了几个数据库连接。如果今天一整天都没有人拍片子,这 50MB 内存就白白浪费了一整天。
  • 使用"懒汉式"的优雅(有了这个 if): 系统启动,类被加载,但此时 instance 只是一个空荡荡的 null完全不占用那 50MB 的核心内存

但在多线程下会发生"意外"。

意外情况 :在高并发下,多个线程同时通过了 if (instance == null) 的判断,最终会产生多个实例,导致内存泄漏或状态覆盖。

  1. 线程 A 发现它是 null,刚准备进去 new,还没来得及 new,操作系统的 CPU 把线程 A 暂停了。
  2. 此时线程 B 来了,它看到 instance 依然是 null (因为 A 还没造出来),于是 B 进去了,并且顺利 new 了一个对象。
  3. 接着 CPU 切换回线程 A,A 从刚才暂停的地方继续往下走,它不会再去判断一次 if ,而是直接又执行了一次 new

这就导致了之前压测中出现的情况:原本为了节省资源的 if,在并发下由于防线被集体突破,反而造出了无数个多余的实例。

这就是为什么我们在后面的演进中,必须在这个 if 上面加锁(synchronized),甚至演化出双重检查锁(DCL) ------本质上都是在给这个脆弱但又极其重要的 if 穿上防弹衣。

2.2.2 详细测试代码

模拟以下场景:

维纳斯系统(一个医学图像辅助诊断系统)(Venus System)刚刚部署上线或重启,系统还处于冷启动状态,内存中尚未初始化"全局诊断影像解析引擎"。

突然,早上 8 点整,全院 100 位门诊医生在同一瞬间 点击了患者历史病历中的"查看影像诊断"按钮。这 100 个请求犹如潮水一般涌入后端服务器,几乎在同一微秒向系统索要解析引擎的实例(即疯狂调用 getInstance())。

java 复制代码
public class Singleton_01 {

    private static Singleton_01 instance;

    private Singleton_01() {
        System.out.println(Thread.currentThread().getName() + " >>> 正在分配内存,初始化诊断影像解析引擎...");
        try {
            // 【关键点】:模拟引擎初始化的耗时操作,也是为了故意放大并发漏洞
            Thread.sleep(50);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static Singleton_01 getInstance() {
        // 步骤 1:判断实例是否为空
        if (instance == null) {

            // 【极其危险的区域】:在高并发下,如果有多个线程同时通过了上面的 null 判断,
            // 它们会在这里排队执行 new 操作。
            // 为了让测试更容易暴露出问题,我们让线程在这里稍微停顿一下(模拟时间片耗尽)
            try {
                Thread.sleep(10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 步骤 2:创建实例
            instance = new Singleton_01();
        }
        return instance;
    }
}


public class ApiTest {

     public static void test_singleton_01() throws InterruptedException{

        System.out.println("========== 懒汉式单例模式线程不安全版本场景模拟:\n 维纳斯系统启动:高并发获取影像解析引擎 ==========\n");

        int threadCount = 100; // 模拟 100 个并发请求

        // 发令枪:用于让所有线程在同一起跑线等待
        CountDownLatch startLatch = new CountDownLatch(1);
        // 终点线:用于等待所有线程执行完毕
        CountDownLatch endLatch = new CountDownLatch(threadCount);

        // 线程安全的 Set,用来收集各个线程获取到的实例
        // 如果是真正的单例,最后 Set 的 size 应该是 1
        Set<Singleton_01> instanceSet = ConcurrentHashMap.newKeySet();

        // 创建线程池
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    // 1. 所有线程在此阻塞,等待发令枪倒计时归零
                    startLatch.await();

                    // 2. 发令枪响,大家同时去获取单例!
                    Singleton_01 parser = Singleton_01.getInstance();

                    // 3. 将获取到的对象放入 Set 中(Set 会利用对象的内存地址自动去重)
                    instanceSet.add(parser);

                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    // 4. 线程执行完毕,终点线计数减 1
                    endLatch.countDown();
                }
            });
        }

        // 100个线程瞬间并发执行
        startLatch.countDown();

        // 主线程等待所有子线程跑到终点
        endLatch.await();
        executor.shutdown();

        System.out.println("\n========== 测试结果统计 ==========");
        System.out.println("并发请求数量: " + threadCount);
        System.out.println("实际创建的影像解析引擎实例数量: " + instanceSet.size());

        if (instanceSet.size() > 1) {
            System.err.println("❌ 灾难发生:单例被打破!内存中出现了多个不同的实例!");
            for (Singleton_01 instance : instanceSet) {
                System.out.println("实例内存地址:" + instance);
            }
        } else {
            System.out.println("✅ 单例正常工作。");
        }



    }


    public static void main(String[] args) {

        test_singleton_01();
    }

}

上述测试结果如下:

结果说明:

面对 100 个同时涌入的线程, getInstance() 方法彻底失效了。

系统中唯一的对象重复创建了 96 次 ,底部的那些 @43211023@4f667964 就是这些对象在 JVM 堆内存中的真实物理地址,说明这个引擎不是同一个引擎。

在真实的维纳斯系统里,这个引擎可能包含庞大的图像解析模型。正常情况下它只需要加载一次(消耗 50MB 内存)。但在这种并发漏洞下,系统瞬间为它分配了 96 次内存(近 5GB 资源被瞬间吞噬!)。如果是在生产环境,这会导致两个灾难性后果:

  1. OOM (Out Of Memory) 内存溢出:系统资源瞬间被抽干,服务器直接宕机。
  2. 状态覆盖:哪怕内存没爆,96 个独立的引擎实例也会导致后续处理影像时,数据状态完全无法同步,出现"医生 A 保存的诊断记录,医生 B 完全看不到"的幽灵 Bug。

2.3 懒汉式 - 同步锁版(线程安全但低效)

为了解决上面的线程不安全问题,接下来在 getInstance() 方法上直接加上了 synchronized 关键字。在 Java 中,静态方法上的 synchronized 相当于把整个类对象(Class 对象)当作了锁。

2.3.1 模板代码
Java 复制代码
public class Singleton_02 {

    private static Singleton_02 instance;

    private Singleton_02() {
        // 模拟第一次加载庞大的维修问答知识图谱
        System.out.println(">>> 正在初始化维修问答系统的全局知识图谱...");
    }

    // 【性能瓶颈】:直接在方法上加锁(相当于锁住了整个 Singleton_02.class)
    public static synchronized Singleton_02 getInstance() {
        if (instance == null) {
            instance = new Singleton_02();
        }
        // 对于已经创建好实例之后的几百万次调用来说,这里纯粹只是一个返回引用的"读操作"
        return instance;
    }
}
  • 缺点 :锁的粒度太大。如果只有第一次创建实例时才需要同步,后续获取实例全是读操作。每次调用 getInstance() 都要竞争锁,性能极差。
2.3.2 详细测试代码

模拟以下场景:

假设系统里有一个全局海洋装备知识图谱检索组件 。这个组件只需要在第一次被调用时加载一次知识库(写操作),但之后,每一次用户的提问、每一个并发的检索任务,都需要频繁调用 getInstance() 来获取它进行查询(高频读操作)。

为了体现出差距,我们在测试中用饿汉式 Singleton_00(它的 getInstance() 是无锁的)作为参照物。

我们让 100 个线程,每个线程疯狂调用 100 万次 getInstance(),看看有锁和无锁在极端高频"读操作"下的耗时天堑。

java 复制代码
public class LazySafeSingleton {
    private static LazySafeSingleton instance;
    private LazySafeSingleton() {}

    // 直接在方法上加锁
    public static synchronized LazySafeSingleton getInstance() {
        if (instance == null) {
            instance = new LazySafeSingleton();
        }
        return instance;
    }
}



public class ApiTest {

    public static void test_singleton_02() throws InterruptedException {
        System.out.println("========== 维修问答系统并发性能压测:有锁 VS 无锁 ==========\n");

        int threadCount = 100;       // 100个并发线程
        int loopCount = 1_000_000;   // 每个线程调用 100万次获取实例的方法

        ExecutorService executor = Executors.newFixedThreadPool(threadCount);

        // ================= 第 1 轮:测试无锁的获取方式(对照组) =================
        // 先触发一次类加载,排除初始化耗时干扰
        Singleton_00.getInstance();

        CountDownLatch startLatch1 = new CountDownLatch(1);
        CountDownLatch endLatch1 = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    startLatch1.await();
                    // 模拟高频次获取实例进行知识库检索
                    for (int j = 0; j < loopCount; j++) {
                        Singleton_00.getInstance(); // 无锁调用
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    endLatch1.countDown();
                }
            });
        }

        long startTime1 = System.currentTimeMillis();
        startLatch1.countDown(); // 发令枪响
        endLatch1.await();       // 等待所有线程跑完
        long endTime1 = System.currentTimeMillis();
        System.out.println("✅ [无锁获取] 100个线程各获取 100万次,总耗时: " + (endTime1 - startTime1) + " ms");


        // ================= 第 2 轮:测试同步锁方法(性能瓶颈组) =================
        // 先触发一次初始化,确保接下来的测试全是"读操作"
        Singleton_02.getInstance();

        CountDownLatch startLatch2 = new CountDownLatch(1);
        CountDownLatch endLatch2 = new CountDownLatch(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    startLatch2.await();
                    // 模拟高频次获取实例进行知识库检索
                    for (int j = 0; j < loopCount; j++) {
                        Singleton_02.getInstance(); // 有锁调用!
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    endLatch2.countDown();
                }
            });
        }

        long startTime2 = System.currentTimeMillis();
        startLatch2.countDown(); // 发令枪响
        endLatch2.await();       // 等待所有线程跑完
        long endTime2 = System.currentTimeMillis();
        System.out.println("❌ [同步锁获取] 100个线程各获取 100万次,总耗时: " + (endTime2 - startTime2) + " ms");

        executor.shutdown();
    }

    public static void main(String[] args) {

        test_singleton_02();
    }

}

代码运行结果如下:

这里 78 ms 对比 6045 ms 的效果,说明高并发场景下最真实的底层性能鸿沟,简单说明原因:

使用静态变量返回单例的底层操作:

  • 在 Java 底层,当 JVM 第一次加载你的静态内部类或饿汉式类时,它会执行一个叫 <clinit>(Class Initialization)的底层方法来给静态变量赋值。

  • JVM 规定:多线程同时去初始化一个类时,JVM 会在底层加一把极其严格的隐式锁,只允许一个线程去执行 <clinit>,其他线程全部在外面死等。

  • 当 100 个线程执行 return INSTANCE; 时,这完全是一个纯粹的内存屏障/寄存器寻址操作。所有的线程在"用户态"下并行狂奔,直接从 L1/L2 高速缓存或者主存中把引用地址读出来。不排队、不等待,CPU 的算力被 100% 用于执行业务逻辑。

使用 synchronized 保证线程安全的底层性能差距:

  • 串行化灾难(Monitor Lock):100 个线程瞬间变成只能单列排队过独木桥,并发执行退化为串行执行。
  • 线程挂起与唤醒(EntryList) :抢不到锁的 99 个线程不能在原地瞎等,JVM 会把它们塞进一个叫 EntryList 的等待队列里。
  • 核心元凶:用户态与内核态的上下文切换(Context Switch) :图中标红虚线的部分是性能杀手。当 JVM 要挂起或唤醒线程时,它自己做不到,必须向底层操作系统(OS)发送系统调用指令
  • 操作系统的线程调度器介入,把 CPU 当前的寄存器状态、程序计数器全部保存到内存,再把 CPU 交给别人。这一来一回的上下文切换,每一次都要耗费数万个时钟周期。

这里是性能分析可视化:

2.4 双重检查锁(DCL, Double-Checked Locking)- 面试/工程高频

为了兼顾**"延迟加载(省内存)" "高性能并发(省CPU)"**而设计的一种高级加锁方案。它把沉重的同步锁(synchronized)退到了方法内部,并用两次 if 判断将其巧妙地包裹起来。

实现原理:先判断是否需要加锁,加锁后再次判断是否需要创建实例。

关键点

  • volatile 防止指令重排
  • 第一次判断提升性能
  • 第二次判断保证线程安全

适用场景:对性能有要求但又需要延迟加载的场景

2.4.1 模板代码
Java 复制代码
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;
    }
}
  • 为什么必须用 volatile

    instance = new DclSingleton(); 这行代码在底层 JVM 并非原子操作,它分为三步:

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

    编译器和 CPU 为了性能优化,可能会发生指令重排(Instruction Reordering),将步骤变为 1 -> 3 -> 2。

    如果是 1->3->2,当线程 A 执行完 3(此时 instance 已经不为 null,但还没初始化),线程 B 抢占 CPU,执行到外层的 if (instance == null)。线程 B 发现不为 null,直接返回了这个半成品(未初始化完)的对象 ,一旦使用就会报空指针异常(NPE)。volatile 关键字的作用就是禁止指令重排,保证了内存的可见性和有序性。

  • 为什么代码里要写两个 if (instance == null)?

    1. 为什么要有外层的第一重 if?(为了性能,(门外隔着玻璃看,省去拿钥匙的麻烦)

      • 对象一旦创建成功,后续几千万次调用走到这层 if,发现不为 null,就直接把对象拿走用了。

      • 程序根本不会去碰里面那把极度耗时的重型锁,保证了极高的读取速度。

    2. 为什么有了锁,锁里面还要有第二重 if?(为了绝对安全)

      • 假设线程 A 和 B 同时越过了第一重 if。线程 A 抢到了锁,进去把对象 new 出来了,然后出门释放锁。

      • 此时线程 B 终于拿到锁冲进去,如果没有这第二层 if 拦着,线程 B 就会不管三七二十一,再 new 一个新对象覆盖掉旧的,单例就彻底崩溃了。

2.4.2 详细测试代码
java 复制代码
public class Singleton_03 {

    // 【核心重点 1】:volatile 严防指令重排(禁止 1->3->2)
    private static volatile Singleton_03 instance;

    // 【核心重点 2】:私有化构造器
    private Singleton_03() {
        System.out.println(Thread.currentThread().getName() + " 真正执行了 new 操作,创建了单例!");
    }

    // 【核心重点 3】:全局访问点
    public static Singleton_03 getInstance() {
        // 第一重检查:如果已经创建好了,直接返回,避开下面沉重的 synchronized 锁
        if (instance == null) {

            // 加锁:确保只有第一个冲进来的线程能去创建对象
            synchronized (Singleton_03.class) {

                // 第二重检查:防止后面的线程排队进来后重复创建
                if (instance == null) {

                    // 这里的底层逻辑:1.分配空间 -> 2.初始化 -> 3.指针赋值
                    // 因为加了 volatile,绝对保证按 1->2->3 执行
                    instance = new Singleton_03();
                }
            }
        }
        return instance;
    }
}



public class ApiTest {

    public static void test_singleton_03() throws InterruptedException {
        System.out.println("========== DCL 单例并发安全测试 ==========\n");

        int threadCount = 100; // 100 个并发线程

        CountDownLatch startLatch = new CountDownLatch(1); // 发令枪
        CountDownLatch endLatch = new CountDownLatch(threadCount); // 终点线

        // 用线程安全的 Set 收集获取到的实例
        Set<Singleton_03> instanceSet = ConcurrentHashMap.newKeySet();

        ExecutorService executor = Executors.newFixedThreadPool(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    startLatch.await(); // 所有线程在此等待发令枪

                    // 去获取 DCL 单例
                    Singleton_03 parser = Singleton_03.getInstance();

                    // 存入集合中
                    instanceSet.add(parser);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    endLatch.countDown(); // 跑到终点
                }
            });
        }

        // 发令枪响,100个线程瞬间并发!
        startLatch.countDown();

        // 等待所有线程执行完毕
        endLatch.await();
        executor.shutdown();

        System.out.println("\n========== 测试结果 ==========");
        System.out.println("并发线程数: " + threadCount);
        System.out.println("Set 集合中实例的数量: " + instanceSet.size());

        if (instanceSet.size() == 1) {
            System.out.println("✅ 测试通过!完美的单例,多线程下依然绝对安全。");
        } else {
            System.err.println("❌ 测试失败!单例被打破了!");
        }
    }

    public static void main(String[] args) {

        test_singleton_03();
    }

}

2.5 静态内部类(Static Inner Class)- 优雅且安全

实现原理:利用 Java 类加载机制延迟加载,且由 JVM 保证线程安全。

优点

  • 延迟加载
  • 线程安全
  • 实现简单优雅

注意 :类加载器会在第一次调用 getInstance() 时加载内部类,从而实现懒加载。

2.5.1 模板代码

综合了懒加载和线程安全,且不需要加锁,强烈推荐。

Java 复制代码
public class InnerClassSingleton {
    private InnerClassSingleton() {}

    // 静态内部类,只有在被调用时才会被类加载器加载
    private static class SingletonHolder {
        private static final InnerClassSingleton INSTANCE = new InnerClassSingleton();
    }

    public static InnerClassSingleton getInstance() {
        return SingletonHolder.INSTANCE; // 此时才会触发 SingletonHolder 的加载
    }
}
  • 原理 :利用了 JVM 的类加载机制。外部类被加载时,静态内部类并不会被加载;只有调用 getInstance() 时才会加载并初始化 INSTANCE。JVM 保证了类的初始化过程是线程安全的。

原理详细分析:

  1. 外部类加载,不会触发内部类加载(天然懒加载)

    当 JVM 加载外部类 时,只要你不去使用内部类 ,JVM 就绝对不会去加载这个内部类。这就完美解决了"饿汉式"只要类一加载就立刻分配内存的资源浪费问题。

  2. 类的初始化阶段是绝对线程安全的(天然防并发)

    当我们第一次调用 getInstance() 方法,JVM 才会去加载并初始化内部类。

    JVM 规范严格保证:虚拟机会保证一个类的 <clinit>()(类初始化方法)在多线程环境中被正确地加锁、同步。 如果有 100 个线程同时去初始化这个内部类,JVM 在底层会默默加上一把不可见的初始化锁,保证只有 1 个线程能执行 new 操作,其他 99 个线程都在外面阻塞等待。

总结: 我们不用手写任何锁,而是直接白嫖了 JVM 内部的类加载锁

2.5.2 详细测试代码

测试代码分为两个环节:

  • 环节一:验证懒加载。我们尝试访问外部类的其他变量,看看单例对象会不会被意外创建。
  • 环节二:验证并发安全。00个线程同时去获取单例。
java 复制代码
public class Singleton_04 {

    // 模拟一个外部类的普通静态变量
    public static String NORMAL_STATIC_FIELD = "我是外部类的普通静态变量";

    // 外部类的静态代码块,用于监控外部类何时被 JVM 加载
    static {
        System.out.println("【JVM 日志】 >>> 外部类 MaintenanceConfigManager 正在被加载...");
    }

    // 私有化构造器
    private Singleton_04() {
        System.out.println(Thread.currentThread().getName() + " >>> 真正执行 new 操作:维修问答系统配置项加载中(耗时50MB内存)...");
    }

    // 静态内部类:它像一个潜伏的刺客,只有被召唤时才会出动
    private static class SingletonHolder {
        // 内部类的静态代码块,用于监控内部类何时被 JVM 加载
        static {
            System.out.println("【JVM 日志】 >>> 静态内部类 SingletonHolder 正在被加载...");
        }

        // JVM 会保证这行代码的线程绝对安全!
        private static final Singleton_04 INSTANCE = new Singleton_04();
    }

    public static Singleton_04 getInstance() {
        return SingletonHolder.INSTANCE; // 只有执行到这一行,内部类才会被触发加载
    }
}



public class ApiTest {
    public static void test_singleton_04() throws InterruptedException {
        System.out.println("========== 阶段一:验证【懒加载】特性 ==========\n");

        System.out.println("业务代码:我现在仅仅想读取一下外部类的普通变量...");
        String temp = Singleton_04.NORMAL_STATIC_FIELD;
        System.out.println("业务代码获取完成。\n");

        // 模拟系统运行了一段时间...
        Thread.sleep(1000);

        System.out.println("========== 阶段二:验证【高并发安全】特性 ==========\n");
        System.out.println("业务代码:100 个请求同时涌入维修问答系统,同时索要配置管理器!\n");

        int threadCount = 100;
        CountDownLatch startLatch = new CountDownLatch(1);
        CountDownLatch endLatch = new CountDownLatch(threadCount);
        Set<Singleton_04> instanceSet = ConcurrentHashMap.newKeySet();
        ExecutorService executor = Executors.newFixedThreadPool(threadCount);

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                try {
                    startLatch.await(); // 100个线程在此阻塞等待发令枪

                    // 核心:并发调用 getInstance()
                    Singleton_04 manager = Singleton_04.getInstance();
                    instanceSet.add(manager);

                } catch (InterruptedException e) {
                    e.printStackTrace();
                } finally {
                    endLatch.countDown();
                }
            });
        }

        // 发令枪响!
        startLatch.countDown();
        endLatch.await();
        executor.shutdown();

        System.out.println("\n========== 测试结果统计 ==========");
        System.out.println("并发线程数: " + threadCount);
        System.out.println("Set 集合中实例的数量: " + instanceSet.size());

        if (instanceSet.size() == 1) {
            System.out.println("✅ 测试通过!完美的单例,既实现了懒加载,又保证了绝对的并发安全。");
        }
    }

    public static void main(String[] args) {

        test_singleton_04();
    }

}

代码运行结果:

运行结果说明:

懒加载(按需加载)实现: 在阶段一,我们读取了外部类的普通变量,触发了外部类 的加载。但是内部类并没有被加载,构造器也没有被执行。那 50MB 的内存没有被哦占用。

高并发安全被彻底证实: 在阶段二,当 100 个线程冲向 getInstance() 方法时,JVM 锁住了内部类 SingletonHolder 的加载过程。最终只有 pool-1-thread-4 真正执行了 new 操作。其他 99 个线程都在等待 JVM 的底层锁释放,并在锁释放后直接拿到了现成的对象。

关于这里静态内部类的加载和应用原理如下:

  1. 外部类加载,不会触发内部类加载(天然懒加载)

    当 JVM 加载外部类 时,只要你不去使用内部类 ,JVM 就绝对不会去加载这个内部类,这就完美解决了"饿汉式"只要类一加载就立刻分配内存的资源浪费问题。

  2. 类的初始化阶段是绝对线程安全的(天然防并发)

    当我们第一次调用 getInstance() 方法,代码执行到内部类SingletonHolder.INSTANCE 时,JVM 才会去加载并初始化内部类。

    JVM 规范严格保证:虚拟机会保证一个类的 <clinit>()(类初始化方法)在多线程环境中被正确地加锁、同步。 如果有 100 个线程同时去初始化这个内部类,JVM 在底层会默默加上一把不可见的初始化锁,保证只有 1 个线程能执行 new 操作,其他 99 个线程都在外面阻塞等待。

总结: 通过静态内部类,我们不用手写任何锁,而是直接白嫖了 JVM 内部的类加载锁!

可视化静态内部类加载:

2.6 枚举(Enum)- 单例的终极防御

《Effective Java》作者 Joshua Bloch 极力推荐的写法。

实现原理:利用 Java 枚举的天然单例特性。

优点

  • 写法最简单
  • JVM 从语言层面保证线程安全和单例性
  • 防止反射与反序列化攻击

缺点

  • 写法略有语法限制,不适合对类结构有复杂依赖的情境,要知道此种方式在存在继承场景下是不可用的
2.6.1 模板代码
Java 复制代码
public enum EnumSingleton {
    INSTANCE;

    // 可以添加具体的业务方法
    public void doDiagnosticHistorySync() {
        // ...业务逻辑...
    }
}
2.6.2 详细测试代码

枚举单例之所以有更改的安全性,是因为前面的所有方案在面对两个高级黑客手段------反射攻击序列化破坏时,都会瞬间崩溃。

而枚举,是 Java 语言在编译器和 JVM 底层级别可以应对这两招。

模拟这个场景:

维纳斯系统(Venus System)的诊断历史同步引擎。这个引擎如果被反射或者序列化搞出了多个实例,会导致多线程同步病历记录时出现重复或丢失。但是在枚举的单例模式之下,就没有这个问题。

这段代码将分别使用"反射暴力破解"和"序列化克隆克隆"两种极端的破坏手段,来看看枚举能不能防得住。

java 复制代码
/**
 * 枚举单例:维纳斯系统 - 诊断历史同步引擎
 * 注意:枚举天生实现了 Serializable 接口,所以可以直接被序列化
 */
public enum Singleton_05 {

    // 1. 定义一个枚举元素,它就是那个全局唯一的实例
    INSTANCE;

    // 2. 内部的业务状态
    private int syncCount = 0;

    // 3. 具体的业务方法
    public void doDiagnosticHistorySync(String patientId) {
        syncCount++;
        System.out.println("正在同步患者 [" + patientId + "] 的诊断影像历史... (当前同步总数: " + syncCount + ")");
    }

    public int getSyncCount() {
        return syncCount;
    }
}

public class ApiTest {
    public static void test_singleton_05() throws InterruptedException {
        System.out.println("========== 维纳斯系统防御测试:枚举单例的终极考验 ==========\n");

        Singleton_05 originalInstance = Singleton_05.INSTANCE;
        originalInstance.doDiagnosticHistorySync("P-001"); // 初始状态,同步数 = 1

        // ================= 攻击一:反射暴力破解 =================
        System.out.println("\n[攻击一] 黑客尝试使用反射机制暴力破解单例...");
        try {
            // 枚举的底层其实有一个 (String name, int ordinal) 的隐式构造器
            Constructor<Singleton_05> constructor =
                    Singleton_05.class.getDeclaredConstructor(String.class, int.class);

            // 强行关闭安全检查!
            constructor.setAccessible(true);

            // 尝试强行 new 一个新的枚举实例
            System.out.println("准备执行 constructor.newInstance()...");
            Singleton_05 evilInstance = constructor.newInstance("EVIL_INSTANCE", 1);

        } catch (Exception e) {
            System.err.println("🛡️ 攻击失败!底层 JVM 拦截了反射创建枚举的请求!");
            System.err.println("拦截原因: " + e.getCause());
        }

        // ================= 攻击二:序列化克隆破坏 =================
        System.out.println("\n[攻击二] 黑客尝试使用序列化与反序列化来克隆单例对象...");
        Singleton_05 clonedInstance = null;
        try {
            // 1. 将原实例写入内存数组(模拟写入磁盘/网络传输)
            ByteArrayOutputStream bos = new ByteArrayOutputStream();
            ObjectOutputStream oos = new ObjectOutputStream(bos);
            oos.writeObject(originalInstance);
            oos.close();

            // 2. 从内存数组中重新读取出来(模拟反序列化)
            ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
            ObjectInputStream ois = new ObjectInputStream(bis);
            clonedInstance = (Singleton_05) ois.readObject();
            ois.close();

            System.out.println("克隆完成!正在比对克隆对象与原对象...");

            // 3. 验证两个对象是否是同一个内存地址
            if (originalInstance == clonedInstance) {
                System.out.println("🛡️ 攻击失败!反序列化出来的依然是原本内存中的那个实例!");
            } else {
                System.err.println("❌ 灾难爆发!单例被克隆破坏了!");
            }

            // 4. 验证状态是否一致
            System.out.println("原对象同步总数: " + originalInstance.getSyncCount());
            System.out.println("反序列化对象的同步总数: " + clonedInstance.getSyncCount());

        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {

        test_singleton_04();
    }

}

代码运行结果:

具体原理如下:

当你写下 public enum Singleton_05 { INSTANCE; } 时,Java 编译器在底层其实把它编译成了一个普通的类,继承自 java.lang.Enum

  • 天生线程安全(和静态内部类一样)INSTANCE 在底层会被编译为 public static final Singleton_05 INSTANCE;,并在静态代码块中初始化。所以它同样白嫖了 JVM 类加载机制的隐式锁,绝对线程安全。
  • 防御一:绝对免疫"反射攻击" : 普通的单例类,黑客可以通过 Class.getDeclaredConstructor() 强行获取你的私有构造器,并用 setAccessible(true) 破门而入,暴力 new 出新对象。 但 Java 底层 Constructor.newInstance() 的源码,里面有一行极其霸道的强制判断:如果发现当前类是 ENUM 修饰的,直接抛出 IllegalArgumentException("Cannot reflectively create enum objects") 也就是 JVM 源码级别直接不让枚举的反射创建。
  • 防御二:绝对免疫"序列化破坏" : 当一个对象被写入磁盘(序列化)再读取出来(反序列化)时,Java 默认会通过反射绕过构造器,重新生成一个全新的对象。 但 Java 规范对枚举的序列化做了特殊规定:序列化时仅仅是将枚举的 name(比如 "INSTANCE" 字符串)输出。反序列化时,底层强制调用 Enum.valueOf(Class, String) 方法,根据名字去内存里找已经存在的那个实例。这就彻底杜绝了反序列化克隆出新对象的可能。

总结

单例模式思想简单:确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。在实现时都包含:私有化构造函数、提供访问这个单例对象的公开方法。这个单例对象在类中才创建。

但是具体实现中有很多细节和考虑:

  1. 饿汉式:
    • 在未获取该单例对象时就创建好该对象,通过静态变量来让类加载时就创建好单例对象;
    • 缺点是在未使用该单例对象时该对象可能被创建,导致占用内存浪费资源。
  2. 懒汉式:
    • 在获取该单例对象时才创建该对象;
    • 需要考虑线程安全问题,这也是常见面试问题。
    • 推荐静态内部类来返回单例对象。

我们使用的时候可以参考以下原则:

  • 注重代码优雅与性能 :日常工程开发,首选静态内部类
  • 涉及配置项极多或面试考察底层:必须掌握**双重检查锁(DCL + volatile)**及其背后的内存模型原理。
  • 涉及频繁序列化或需要绝对安全 :直接使用枚举
相关推荐
iCxhust3 分钟前
微机原理实践教程(C语言篇)---A002流水灯
c语言·开发语言·单片机·嵌入式硬件·51单片机·课程设计·微机原理
FQNmxDG4S19 分钟前
Maven依赖管理:版本冲突解决与生命周期控制
java·数据库·maven
莎士比亚的文学花园24 分钟前
Linux驱动开发(3)——设备树
开发语言·javascript·ecmascript
图码32 分钟前
如何用多种方法判断字符串是否为回文?
开发语言·数据结构·c++·算法·阿里云·线性回归·数字雕刻
傻瓜搬砖人37 分钟前
Spring集成Web环境
java·spring·maven
U盘失踪了38 分钟前
python curl转python脚本
开发语言·chrome·python
charlie11451419138 分钟前
Linux 字符设备驱动:cdev、设备号与设备模型
linux·开发语言·驱动开发·c
handler0140 分钟前
Linux 内核剖析:进程优先级、上下文切换与 O(1) 调度算法
linux·运维·c语言·开发语言·c++·笔记·算法
FQNmxDG4S41 分钟前
Java泛型编程:类型擦除与泛型方法的应用场景
java·开发语言·python
我星期八休息1 小时前
IT疑难杂症诊疗室:AI时代工程师Superpowers进化论
linux·开发语言·数据结构·人工智能·python·散列表