单例模式 前言 单例模式的目的极其明确:确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点。
这看似简单,但在实际的工程应用(尤其是高并发后端系统)中,要做到“绝对安全”却暗藏玄机。
在系统开发中,有些对象如果出现多个实例,会导致资源浪费、状态不一致或逻辑混乱。
全局状态与历史管理器 :例如在医疗辅助诊断系统中,针对某次会话的诊断历史记录管理器(Diagnostic History Manager) 。如果在不同模块(如影像解析模块和问诊模块)中存在多个历史管理器实例,会导致历史数据无法同步,出现丢记录的 Bug。
硬件/系统资源访问 :数据库连接池(Connection Pool)、线程池。建立连接极其消耗资源,整个系统应当共享同一个连接池来统一调度。
系统配置信息类 :读取全局的配置文件(如 .properties 或 .yml),这些配置在内存中只需存一份即可。
本文项目源码链接:https://github.com/likerhood/CodeDesignWork/tree/main/codedesign4.0-0
一、单例模式介绍
单例模式是设计模式中最简单、也最符合开发者直觉的模式。
它在实际开发中的应用极高,其核心设计理念可以高度浓缩为两点:
绝对唯一 :在多线程等复杂环境下,保证一个类仅有一个实例,并提供一个全局访问点。
性能优化 :阻断全局类的频繁创建与销毁,降低资源开销,从根本上提升系统整体性能。
二、 六种实现方式 单例的核心构造原则是:构造方法私有化(Private Constructor) 。
但在多线程环境下,如何安全地向外暴露这个实例,就演化出了不同的写法。
2.1 饿汉式 最简单,但可能浪费资源。 类加载时就立即初始化,利用类加载机制保证了线程安全。
私有化构造函数;
通过static静态变量保证类加载时就会创建该类的实例对象 ;
提供全部访问这个单例对象 的公开方法(静态方法)。
在 Java 中,当一个类被 JVM 加载到内存并进行初始化时,JVM 会自动执行类中的静态代码块(static {})以及静态变量的赋值操作 。
因为我们写了 private static final EagerSingleton INSTANCE = new EagerSingleton();,所以当 EagerSingleton 这个类一被 JVM 识别并加载,这行代码就会立刻执行,从而调用了私有构造器,把对象创建出来了。而且,JVM 底层的类加载机制天生是线程安全的 ,这就保证了在多线程环境下,这个对象也只会被创建一次。
缺点 :如果这个类包含了庞大的数据字典或需要加载大量本地资源,且系统启动后很久都没用到它,就会白白占用内存。
2.1.1 模板代码 1 2 3 4 5 6 7 8 9 10 public class EagerSingleton { private EagerSingleton () {} private static final EagerSingleton INSTANCE = new EagerSingleton (); public static EagerSingleton getInstance () { return INSTANCE; } }
2.1.2 详细测试代码 模拟如下场景,在单例模式的类中初始化需要做这些工作:
读取了一个 50MB 的本地配置文件。
建立了一个数据库连接池。
初始化了一个庞大的数据字典。
如果你的系统在启动时触发了这个类的加载(比如你不小心调用了这个类的其他静态方法,或者用反射扫到了它 ),即使你当前根本不需要用到这个单例对象 ,这 50MB 的内存和数据库连接也就被死死占用了。这就叫“白白浪费了系统资源”。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 package com.likerhood.design;public class Singleton_00 { public static final String SOME_OTHER_STATIC_FIELD = new String ("饿汉式单例模式" ); private static final Singleton_00 INSTANCE = new Singleton_00 (); private Singleton_00 () { System.out.println(">>> 构造器被调用!Singleton_00实例正在被创建..." ); System.out.println(">>> 正在加载 50MB 配置文件..." ); System.out.println(">>> 正在建立数据库连接...\n" ); } 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" ); 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 模板代码 1 2 3 4 5 6 7 8 9 10 11 public class LazyUnsafeSingleton { private static LazyUnsafeSingleton instance; private LazyUnsafeSingleton () {} public static LazyUnsafeSingleton getInstance () { if (instance == null ) { instance = new LazyUnsafeSingleton (); } return instance; } }
系统刚启动,或者很长一段时间内 :没有任何业务调用 getInstance(),那么 instance 就一直是 null。什么都没发生,内存极其干净。
第一次有人调用 getInstance() :
程序走到 if (instance == null),发现确实是 null(条件成立)。
于是进入 if 代码块内部,执行 new LazyUnsafeSingleton(),在这个时刻,才真正去申请内存、消耗 CPU 资源来创建对象。
创建完成后,把这个对象的地址赋给 instance 变量,最后返回。
回忆一下在前文对“饿汉式”的测试与总结:饿汉式最大的缺点是“只要触发类加载,不管你用不用,对象都会被立刻创建,白白浪费资源”。
而懒汉式的这个 if,就是专门为了解决这个资源浪费而生的。
场景对比(以耗时 50MB 内存的诊断影像解析引擎为例):
使用“饿汉式”的灾难: 系统刚启动,仅仅是因为某个模块读取了该类的一个普通静态配置,JVM 触发了类加载。不管当前有没有医生在使用影像解析功能,引擎立刻被初始化,瞬间吃掉 50MB 内存,甚至霸占了几个数据库连接。如果今天一整天都没有人拍片子,这 50MB 内存就白白浪费了一整天。
使用“懒汉式”的优雅(有了这个 if): 系统启动,类被加载,但此时 instance 只是一个空荡荡的 null,完全不占用那 50MB 的核心内存 。
但在多线程下会发生“意外”。
意外情况 :在高并发下,多个线程同时通过了 if (instance == null) 的判断,最终会产生多个实例,导致内存泄漏或状态覆盖。
线程 A 发现它是 null,刚准备进去 new,还没来得及 new,操作系统的 CPU 把线程 A 暂停了。
此时线程 B 来了,它看到 instance 依然是 null (因为 A 还没造出来),于是 B 进去了,并且顺利 new 了一个对象。
接着 CPU 切换回线程 A,A 从刚才暂停的地方继续往下走,它不会再去判断一次 if ,而是直接又执行了一次 new!
这就导致了之前压测中出现的情况:原本为了节省资源的 if,在并发下由于防线被集体突破,反而造出了无数个多余的实例。
这就是为什么我们在后面的演进中,必须在这个 if 上面加锁(synchronized),甚至演化出双重检查锁(DCL) ——本质上都是在给这个脆弱但又极其重要的 if 穿上防弹衣。
2.2.2 详细测试代码 模拟以下场景:
维纳斯系统(一个医学图像辅助诊断系统)(Venus System)刚刚部署上线或重启,系统还处于冷启动状态,内存中尚未初始化“全局诊断影像解析引擎”。
突然,早上 8 点整,全院 100 位门诊医生在同一瞬间 点击了患者历史病历中的“查看影像诊断”按钮。这 100 个请求犹如潮水一般涌入后端服务器,几乎在同一微秒向系统索要解析引擎的实例(即疯狂调用 getInstance())。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 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 () { if (instance == null ) { try { Thread.sleep(10 ); } catch (InterruptedException e) { e.printStackTrace(); } 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 ; CountDownLatch startLatch = new CountDownLatch (1 ); CountDownLatch endLatch = new CountDownLatch (threadCount); Set<Singleton_01> instanceSet = ConcurrentHashMap.newKeySet(); ExecutorService executor = Executors.newFixedThreadPool(threadCount); for (int i = 0 ; i < threadCount; i++) { executor.submit(() -> { try { startLatch.await(); Singleton_01 parser = Singleton_01.getInstance(); instanceSet.add(parser); } 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("实际创建的影像解析引擎实例数量: " + 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 资源被瞬间吞噬!)。如果是在生产环境,这会导致两个灾难性后果:
OOM (Out Of Memory) 内存溢出 :系统资源瞬间被抽干,服务器直接宕机。
状态覆盖 :哪怕内存没爆,96 个独立的引擎实例也会导致后续处理影像时,数据状态完全无法同步,出现“医生 A 保存的诊断记录,医生 B 完全看不到”的幽灵 Bug。
2.3 懒汉式 - 同步锁版(线程安全但低效) 为了解决上面的线程不安全问题,接下来在 getInstance() 方法上直接加上了 synchronized 关键字。在 Java 中,静态方法上的 synchronized 相当于把整个类对象(Class 对象)当作了锁。
2.3.1 模板代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 public class Singleton_02 { private static Singleton_02 instance; private Singleton_02 () { System.out.println(">>> 正在初始化维修问答系统的全局知识图谱..." ); } 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(),看看有锁和无锁在极端高频“读操作”下的耗时天堑。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 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 ; int loopCount = 1_000_000 ; ExecutorService executor = Executors.newFixedThreadPool(threadCount); 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" ); 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 模板代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 public class DclSingleton { 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; } }
2.4.2 详细测试代码 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 public class Singleton_03 { private static volatile Singleton_03 instance; private Singleton_03 () { System.out.println(Thread.currentThread().getName() + " 真正执行了 new 操作,创建了单例!" ); } public static Singleton_03 getInstance () { if (instance == null ) { synchronized (Singleton_03.class) { if (instance == null ) { 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 ; CountDownLatch startLatch = new CountDownLatch (1 ); CountDownLatch endLatch = new CountDownLatch (threadCount); Set<Singleton_03> instanceSet = ConcurrentHashMap.newKeySet(); ExecutorService executor = Executors.newFixedThreadPool(threadCount); for (int i = 0 ; i < threadCount; i++) { executor.submit(() -> { try { startLatch.await(); Singleton_03 parser = Singleton_03.getInstance(); instanceSet.add(parser); } 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("✅ 测试通过!完美的单例,多线程下依然绝对安全。" ); } else { System.err.println("❌ 测试失败!单例被打破了!" ); } } public static void main (String[] args) { test_singleton_03(); } }
2.5 静态内部类(Static Inner Class)- 优雅且安全 实现原理 :利用 Java 类加载机制延迟加载,且由 JVM 保证线程安全。
优点 :
注意 :类加载器会在第一次调用 getInstance() 时加载内部类,从而实现懒加载。
2.5.1 模板代码 综合了懒加载和线程安全,且不需要加锁,强烈推荐。
1 2 3 4 5 6 7 8 9 10 11 12 public class InnerClassSingleton { private InnerClassSingleton () {} private static class SingletonHolder { private static final InnerClassSingleton INSTANCE = new InnerClassSingleton (); } public static InnerClassSingleton getInstance () { return SingletonHolder.INSTANCE; } }
原理 :利用了 JVM 的类加载机制。外部类被加载时,静态内部类并不会被加载;只有调用 getInstance() 时才会加载并初始化 INSTANCE。JVM 保证了类的初始化过程是线程安全的。
原理详细分析:
外部类加载,不会触发内部类加载(天然懒加载)
当 JVM 加载外部类 时,只要你不去使用内部类 ,JVM 就绝对不会 去加载这个内部类。这就完美解决了“饿汉式”只要类一加载就立刻分配内存的资源浪费问题。
类的初始化阶段是绝对线程安全的(天然防并发)
当我们第一次调用 getInstance() 方法,JVM 才会去加载并初始化内部类。
JVM 规范严格保证:虚拟机会保证一个类的 <clinit>()(类初始化方法)在多线程环境中被正确地加锁、同步。 如果有 100 个线程同时去初始化这个内部类,JVM 在底层会默默加上一把不可见的初始化锁,保证只有 1 个线程能执行 new 操作,其他 99 个线程都在外面阻塞等待。
总结: 我们不用手写任何锁,而是直接白嫖了 JVM 内部的类加载锁 !
2.5.2 详细测试代码 测试代码分为两个环节:
环节一:验证懒加载 。我们尝试访问外部类的其他变量,看看单例对象会不会被意外创建。
环节二:验证并发安全 。00个线程同时去获取单例。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 public class Singleton_04 { public static String NORMAL_STATIC_FIELD = "我是外部类的普通静态变量" ; static { System.out.println("【JVM 日志】 >>> 外部类 MaintenanceConfigManager 正在被加载..." ); } private Singleton_04 () { System.out.println(Thread.currentThread().getName() + " >>> 真正执行 new 操作:维修问答系统配置项加载中(耗时50MB内存)..." ); } private static class SingletonHolder { static { System.out.println("【JVM 日志】 >>> 静态内部类 SingletonHolder 正在被加载..." ); } 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(); 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 的底层锁释放,并在锁释放后直接拿到了现成的对象。
关于这里静态内部类的加载和应用原理如下:
外部类加载,不会触发内部类加载(天然懒加载)
当 JVM 加载外部类 时,只要你不去使用内部类 ,JVM 就绝对不会去加载这个内部类,这就完美解决了“饿汉式”只要类一加载就立刻分配内存的资源浪费问题。
类的初始化阶段是绝对线程安全的(天然防并发)
当我们第一次调用 getInstance() 方法,代码执行到内部类SingletonHolder.INSTANCE 时,JVM 才会去加载并初始化内部类。
JVM 规范严格保证:虚拟机会保证一个类的 <clinit>()(类初始化方法)在多线程环境中被正确地加锁、同步。 如果有 100 个线程同时去初始化这个内部类,JVM 在底层会默默加上一把不可见的初始化锁,保证只有 1 个线程能执行 new 操作,其他 99 个线程都在外面阻塞等待。
总结: 通过静态内部类,我们不用手写任何锁,而是直接白嫖了 JVM 内部的类加载锁!
可视化静态内部类加载:
2.6 枚举(Enum)- 单例的终极防御 《Effective Java》作者 Joshua Bloch 极力推荐的写法。
实现原理 :利用 Java 枚举的天然单例特性。
优点 :
写法最简单
JVM 从语言层面保证线程安全和单例性
防止反射与反序列化攻击
缺点 :
写法略有语法限制,不适合对类结构有复杂依赖的情境,要知道此种方式在存在继承场景下是不可用的 。
2.6.1 模板代码 1 2 3 4 5 6 7 8 public enum EnumSingleton { INSTANCE; public void doDiagnosticHistorySync () { } }
2.6.2 详细测试代码 枚举单例之所以有更改的安全性,是因为前面的所有方案在面对两个高级黑客手段——反射攻击 和序列化破坏 时,都会瞬间崩溃。
而枚举,是 Java 语言在编译器和 JVM 底层级别可以应对这两招。
模拟这个场景:
维纳斯系统(Venus System)的诊断历史同步引擎 。这个引擎如果被反射或者序列化搞出了多个实例,会导致多线程同步病历记录时出现重复或丢失。但是在枚举的单例模式之下,就没有这个问题。
这段代码将分别使用“反射暴力破解”和“序列化克隆克隆”两种极端的破坏手段,来看看枚举能不能防得住。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 public enum Singleton_05 { INSTANCE; private int syncCount = 0 ; 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" ); System.out.println("\n[攻击一] 黑客尝试使用反射机制暴力破解单例..." ); try { Constructor<Singleton_05> constructor = Singleton_05.class.getDeclaredConstructor(String.class, int .class); constructor.setAccessible(true ); 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 { ByteArrayOutputStream bos = new ByteArrayOutputStream (); ObjectOutputStream oos = new ObjectOutputStream (bos); oos.writeObject(originalInstance); oos.close(); ByteArrayInputStream bis = new ByteArrayInputStream (bos.toByteArray()); ObjectInputStream ois = new ObjectInputStream (bis); clonedInstance = (Singleton_05) ois.readObject(); ois.close(); System.out.println("克隆完成!正在比对克隆对象与原对象..." ); if (originalInstance == clonedInstance) { System.out.println("🛡️ 攻击失败!反序列化出来的依然是原本内存中的那个实例!" ); } else { System.err.println("❌ 灾难爆发!单例被克隆破坏了!" ); } 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) 方法,根据名字去内存里找已经存在的那个实例 。这就彻底杜绝了反序列化克隆出新对象的可能。
总结 单例模式思想简单:确保一个类在任何情况下都绝对只有一个实例,并提供一个全局访问点 。在实现时都包含:私有化构造函数、提供访问这个单例对象的公开方法。这个单例对象在类中才创建。
但是具体实现中有很多细节和考虑:
饿汉式:
在未获取该单例对象时就创建好该对象,通过静态变量 来让类加载时就创建好单例对象;
缺点是在未使用该单例对象时该对象可能被创建,导致占用内存浪费资源。
懒汉式:
在获取该单例对象时才创建该对象;
需要考虑线程安全问题,这也是常见面试问题。
推荐静态内部类来返回单例对象。
我们使用的时候可以参考以下原则:
注重代码优雅与性能 :日常工程开发,首选静态内部类 。
涉及配置项极多或面试考察底层 :必须掌握**双重检查锁(DCL + volatile)**及其背后的内存模型原理。
涉及频繁序列化或需要绝对安全 :直接使用枚举 。