重学 Java 设计模式:实战单例模式——7 种写法、线程安全剖析与 Effective Java 枚举方案
重学 Java 设计模式:实战单例模式——7 种写法、线程安全剖析与 Effective Java 枚举方案
单例模式是整个设计模式中最简单也最常见的一种,但它的写法之多、细节之深,足以检验一个 Java 开发者的基本功:懒汉、饿汉、静态内部类、双重锁校验、CAS、枚举……每一种写法背后都牵涉懒加载、线程安全、可见性与指令重排等并发知识。本文以 CodeGuide 仓库中《重学 Java 设计模式:实战单例模式》为骨架,完整呈现 7 种单例实现,并结合仓库内 Spring、MyBatis 源码级文档,讲透"什么时候用单例、哪种写法最安全、面试怎么答"。
一、为什么需要单例模式
1.1 它是创建型模式的收官之作
在设计模式中,按照不同的处理方式共包含三大类:创建型模式、结构型模式和行为模式。其中创建型模式在之前的文章中已经介绍了四个——工厂方法模式、抽象工厂模式、生成器模式和原型模式,除此之外还有最后一个 单例模式。本仓库的设计模式专栏按照这套体系逐一展开,单例模式正是其中使用面最广、写法最多的一环。
1.2 单例模式解决的核心问题
在编程开发中经常会遇到这样一种场景:需要保证一个类只有一个实例,哪怕多线程同时访问,并且要提供一个全局访问此实例的点。综合日常开发经验可以总结出一条规律:
单例模式主要解决的是,一个全局使用的类被频繁地创建和消费,从而提升整体的代码性能。
1.3 日常开发中的典型场景
本章节所对应的场景非常简单,也是日常开发中随处可见的,例如:
- 数据库的连接池不会反复创建;
- Spring 中单例 Bean 的生成和使用;
- 平常的代码中需要设置全局属性保存。
虽然单例模式并不复杂,但使用面却比较广。值得一提的是,本仓库的手撸 Spring 系列在实现 Bean 容器时就专门设计了单例注册机制,而 MyBatis 设计模式分析中也明确指出 Configuration 就是一个贯穿整个会话生命周期的"大单例",这些都可以作为单例模式在真实框架中应用的直接证据。
二、开发环境
- JDK 1.8
- IDEA + Maven
- 涉及工程 1 个(单例模式 7 种写法的示例代码,包含在原文对应的设计模式工程中)
三、7 种单例模式实现
单例模式的实现方式比较多,主要在是否支持懒加载(懒汉模式)、是否线程安全上运用各项技巧。当然也有一些场景不需要考虑懒加载,会直接使用 static 静态类或属性和方法的方式进行处理,供外部调用。
下面逐一实现并讲解。
0. 静态类使用(全局访问)
public class Singleton_00 {
public static Map<String,String> cache = new ConcurrentHashMap<String, String>();
}
- 这种方式在平常的业务开发中非常常见,静态类的方式可以在第一次运行的时候直接初始化 Map,这里我们也不需要延迟加载再使用。
- 在不需要维持任何状态、仅仅用于全局访问的场景下,使用静态类的方式更加方便。
- 但如果需要被继承以及需要维持一些特定状态的情况下,就适合使用单例模式。
1. 懒汉模式(线程不安全)
public class Singleton_01 {
private static Singleton_01 instance;
private Singleton_01() {
}
public static Singleton_01 getInstance(){
if (null != instance) return instance;
instance = new Singleton_01();
return instance;
}
}
- 单例模式有一个特点就是不允许外部直接创建,也就是
new Singleton_01(),因此这里在默认的构造函数上添加了私有属性private。 - 目前此种方式的单例确实满足了懒加载,但是如果多个访问者同时去获取对象实例(可以想象成"一堆人在抢厕所"),就会造成多个同样的实例并存,从而没有达到单例的要求。
2. 懒汉模式(线程安全)
public class Singleton_02 {
private static Singleton_02 instance;
private Singleton_02() {
}
public static synchronized Singleton_02 getInstance(){
if (null != instance) return instance;
instance = new Singleton_02();
return instance;
}
}
- 此种模式虽然是安全的,但由于把锁加到了方法上,所有的访问都因需要锁占用而导致资源浪费。如果不是特殊情况下,不建议此种方式实现单例模式。
3. 饿汉模式(线程安全)
public class Singleton_03 {
private static Singleton_03 instance = new Singleton_03();
private Singleton_03() {
}
public static Singleton_03 getInstance() {
return instance;
}
}
- 此种方式与开头第一个实例化
Map的方式基本一致,在程序启动的时候直接运行加载,后续有外部需要使用的时候直接获取即可。 - 但此种方式并不是懒加载,也就是说无论程序中是否用到这样的类,都会在程序启动之初进行创建。
- 这种方式导致的问题就像你下载一个游戏软件,可能你游戏地图还没有打开,但程序已经将这些地图全部实例化。最明显的体验就是:一开游戏内存就满了,手机卡了。
4. 使用类的静态内部类(线程安全)
public class Singleton_04 {
private static class SingletonHolder {
private static Singleton_04 instance = new Singleton_04();
}
private Singleton_04() {
}
public static Singleton_04 getInstance() {
return SingletonHolder.instance;
}
}
- 使用类的静态内部类实现的单例模式,既保证了线程安全又保证了懒加载,同时不会因为加锁的方式耗费性能。
- 这主要是因为 JVM 虚拟机可以保证多线程并发访问的正确性,也就是一个类的构造方法在多线程环境下可以被正确的加载。
- 此种方式也是非常推荐使用的一种单例模式。
5. 双重锁校验 DCL(线程安全)
public class Singleton_05 {
private static volatile Singleton_05 instance;
private Singleton_05() {
}
public static Singleton_05 getInstance(){
if(null != instance) return instance;
synchronized (Singleton_05.class){
if (null == instance){
instance = new Singleton_05();
}
}
return instance;
}
}
- 双重锁(Double Check Lock,DCL)的方式是方法级锁的优化,减少了部分获取实例的耗时。
- 同时这种方式也满足了懒加载。
为什么需要 volatile? 这是双重锁校验中最容易在面试中翻车的一个点。本仓库的面经手册:面试现场(美团一面)就以此为入口考察并发编程三要素:原子性、可见性、有序性。具体来说:
- 可见性:被
volatile修饰的共享变量在转换成汇编语言时,会加上一个以lock为前缀的指令。当 CPU 发现这个指令时,立即将当前内核高速缓存行的数据回写到内存,同时使其他内核里缓存了该内存地址的数据无效(现代 CPU 通过 MESI 缓存一致性协议实现)。volatile是一种非锁机制,可以避免锁机制引起的线程上下文切换和调度问题,执行成本比synchronized更低;但它只能保证可见性,不能保证原子性。 - 指令重排:在程序执行过程中为了性能考虑,编译器和 CPU 可能会对指令重新排序。
instance = new Singleton_05()并不是一个原子操作,它大致包含"分配内存、初始化对象、赋值引用"三步。如果不加volatile,在多线程场景下可能发生"先赋值引用、后初始化对象"的重排,导致其他线程拿到一个尚未初始化完成的对象。volatile通过在 JVM 层面提供内存屏障(LL、LS、SL、SS),禁止了这种重排,保证instance的发布是安全的。
正因为具备上述三要素知识点的单例实现是"双重检查锁 + volatile + CAS 忙等策略",所以它一直是面试官考察并发编程的最佳入口:既能看你对基础的了解,又能顺着往下聊可见性、指令重排、内存屏障、MESI 协议等一连串话题。
6. CAS「AtomicReference」(线程安全)
public class Singleton_06 {
private static final AtomicReference<Singleton_06> INSTANCE = new AtomicReference<Singleton_06>();
private Singleton_06() {
}
public static final Singleton_06 getInstance() {
for (; ; ) {
Singleton_06 instance = INSTANCE.get();
if (null != instance) return instance;
INSTANCE.compareAndSet(null, new Singleton_06());
return INSTANCE.get();
}
}
public static void main(String[] args) {
System.out.println(Singleton_06.getInstance()); // org.itstack.demo.design.Singleton_06@2b193f2d
System.out.println(Singleton_06.getInstance()); // org.itstack.demo.design.Singleton_06@2b193f2d
}
}
- Java 并发库提供了很多原子类来支持并发访问的数据安全性:
AtomicInteger、AtomicBoolean、AtomicLong、AtomicReference。 AtomicReference<V>可以封装引用一个 V 实例,支持并发访问,上面的单例方式就是使用了这样的一个特点。- 使用 CAS 的好处就是不需要使用传统的加锁方式保证线程安全,而是依赖于 CAS 的忙等算法、依赖底层硬件的实现来保证线程安全。相对于其他锁的实现,没有线程的切换和阻塞,也就没有了额外的开销,并且可以支持较大的并发性。
- 当然 CAS 也有一个缺点就是忙等,如果一直没有获取到,将会处于死循环中。
7. Effective Java 作者推荐的枚举单例(线程安全)
public enum Singleton_07 {
INSTANCE;
public void test(){
System.out.println("hi~");
}
}
约书亚·布洛克(Joshua J. Bloch,1961年8月28日-),美国著名程序员。他为 Java 平台设计并实现了许多功能,曾担任 Google 的首席 Java 架构师(Chief Java Architect)。
- Effective Java 作者推荐使用枚举的方式解决单例模式,此种方式可能是平时最少用到的。
- 这种方式解决了最主要的三个问题:线程安全、自由串行化、单一实例。
调用方式
@Test
public void test() {
Singleton_07.INSTANCE.test();
}
这种写法在功能上与共有域方法相近,但是它更简洁,无偿地提供了串行化机制,绝对防止对此实例化,即使是在面对复杂的串行化或者反射攻击的时候。虽然这种方法还没有广泛采用,但是单元素的枚举类型已经成为实现 Singleton 的最佳方法。
但也要知道:此种方式在存在继承场景下是不可用的(枚举类型隐式继承 java.lang.Enum,无法再继承其他类)。
四、仓库源码佐证:框架中的单例实践
单例模式不止存在于教科书代码中,真实框架到处都有它的身影。本仓库的源码解析文档恰好提供了两份极具代表性的证据。
4.1 手撸 Spring:单例 Bean 注册机制
在手撸 Spring 第 3 章:运用设计模式,实现 Bean 的定义、注册、获取中,作者复刻了 Spring 单例 Bean 的核心设计:
- 设计目标:在 Bean 注册的时候只注册一个类信息,而不会直接把实例化信息注册到容器中;获取 Bean 对象时处理实例化操作,并判断当前单例对象在容器中是否已经缓存。
- 核心抽象:定义了
SingletonBeanRegistry接口,由DefaultSingletonBeanRegistry实现,再被抽象类AbstractBeanFactory继承,使AbstractBeanFactory具备单例 Bean 的注册能力。 - 关键流程:
getBean时先尝试从单例缓存中获取,取不到才根据BeanDefinition实例化,实例化完成后调用addSingleton把对象存放到单例缓存中。
这正是"饿汉/懒汉 + 缓存"思想在框架层面的工程化落地:单例注册表(缓存池)+ 首次创建、二次复用。
4.2 手写 MyBatis:Configuration 大单例
在Mybatis 设计模式中,单例模式被单独列为一种设计模式进行剖析:
- 场景介绍:
Configuration就像"狗皮膏药"一样的大单例,贯穿整个会话的生命周期。所有的配置对象——映射、缓存、入参、出参、拦截器、注册机、对象工厂等,都在Configuration配置项中初始化,并随着SqlSessionFactoryBuilder构建阶段完成实例化操作。
也就是说,MyBatis 的 Configuration 采用的是"程序启动时一次性创建、全局共享访问"的策略,与本文第 0 种静态类方式、第 3 种饿汉模式属于同一类"启动即加载"思想。
五、总结与面试考点提炼
5.1 各实现方式速查对比
| 实现方式 | 懒加载 | 线程安全 | 特点与适用建议 |
|---|---|---|---|
| 静态类使用 | 否 | 天然安全(启动即初始化) | 无状态、仅全局访问时最方便;无法继承、无法维持状态 |
| 懒汉(线程不安全) | 是 | 否 | 仅单线程场景可用,多线程会创建多个实例 |
| 懒汉(线程安全) | 是 | 是 | 方法级锁开销大,不推荐 |
| 饿汉 | 否 | 是 | 启动即创建,无懒加载;适合程序必须用到的类 |
| 静态内部类 | 是 | 是 | 兼顾懒加载与线程安全,无锁开销,非常推荐 |
| 双重锁校验 DCL | 是 | 是 | 方法级锁的优化;必须配合 volatile 防指令重排 |
| CAS AtomicReference | 是 | 是 | 无锁、无阻塞、支持大并发;忙等有死循环风险 |
| 枚举 | 否(类加载时初始化) | 是 | Effective Java 推荐;天然防反射、防反序列化攻击;无法继承 |
5.2 如何选择
虽然只是一个很平常的单例模式,但在各种的实现上真的可以看到 Java 的基本功体现,这里包括了:懒汉、饿汉、线程是否安全、静态类、内部类、加锁、串行化等等。
- 在平时的开发中,如果可以确保此类是全局可用、不需要做懒加载,那么直接创建并给外部调用即可;
- 但如果是很多的类,有些需要在用户触发一定的条件后才显示(例如游戏关卡解锁),那么一定要用懒加载;
- 线程安全上可以按需选择。
5.3 高频面试追问链
结合面经手册:面试现场,单例模式在面试中常以连环追问的方式出现:
- 先手写一个线程安全的单例——答案首选双重检查锁(DCL)+
volatile; - 高并发下单例模式会存在哪些问题——引出 DCL 的设计初衷;
- 可见性和指令重排是怎么回事——链接 JMM、Happen-Before 规则、As-if-serial;
- volatile 为什么能解决可见性和指令重排——字节码层面的
ACC_VOLATILE、汇编层面的lock前缀指令、硬件层面的sfence/lfence/mfence内存屏障; - 除了双重检查锁还有什么办法实现线程安全单例——此时抛出 Effective Java 推荐的枚举单例,既体现了知识面的广度,也展示了阅读经典书籍的深度。
最后建议在学习过程中一定要加以实践,例如文中多次引用的《Effective Java》一书就非常值得通读,把每一项盲点逐字逐句地扫清,才能真正把"看过的知识"变成"掌握的技能"。