设计模式:单例模式(Singleton Pattern)的原理、实现、线程安全性与典型应用场景
1. 什么是单例模式?
单例模式是一种创建型设计模式。它的核心思想是确保一个类在整个应用程序的生命周期中只有一个实例,并提供一个全局访问点来获取这个唯一实例。
为什么要用单例模式?
想象一下,你正在开发一个大型应用,需要管理数据库连接池、日志记录器、应用程序的配置信息等。这些组件如果被重复创建,不仅浪费内存资源,还可能导致数据不一致(比如多个配置管理器加载了不同的配置)。单例模式就是为了解决这类问题,确保某些关键、昂贵或全局唯一的资源“只存在一份”。
2. 单例模式的核心要素
- 私有化构造函数:防止外部通过
new关键字随意创建实例。 - 私有静态成员变量:用于保存这个唯一的实例。
- 公共静态访问方法:作为全局访问点,当外界需要这个实例时,通过这个方法获取。
3. 单例模式的实现演进(从“不好”到“好”)
我们将从最简单、不安全的版本,逐步完善,最终实现一个完美的、高效且线程安全的单例。
(1) 懒汉式(基础版,线程不安全)
这是最直观的想法:等到第一次需要用的时候,再去创建实例。
public class Singleton {
// 1. 私有静态变量,初始化为null
private static Singleton instance = null;
// 2. 私有构造函数
private Singleton() {
// 防止通过new创建实例
}
// 3. 公共静态访问方法
public static Singleton getInstance() {
if (instance == null) { // 步骤A:检查
instance = new Singleton(); // 步骤B:创建
}
return instance;
}
}
问题:在多线程环境下,线程1和线程2可能同时执行到 步骤A,此时 instance 都为 null,于是它们都会进入 if 块,各自创建一个实例。这就破坏了“唯一性”原则。
(2) 懒汉式(加锁同步,线程安全但效率低)
为了解决线程安全问题,最直接的想法是给 getInstance() 方法加上 synchronized 锁。
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
问题:虽然线程安全了,但每次调用 getInstance() 都需要加锁、解锁。而实际上,只有在第一次创建实例时才需要同步,实例创建后,每次调用都只是读取,无需同步。这种粗粒度的锁会导致严重的性能开销。
(3) 双重检查锁定(DCL,优化版懒汉式)
为了减少加锁的开销,我们只在实例未被创建时才进入同步代码块。这是经典的双重检查锁定模式。
public class Singleton {
// 注意:这里使用 volatile 关键字修饰
private static volatile Singleton instance = null;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查,避免不必要的加锁
synchronized (Singleton.class) { // 加锁
if (instance == null) { // 第二次检查,在锁的保护下进行
instance = new Singleton();
}
}
}
return instance;
}
}
关键点解析:
- 第一次检查:如果实例已存在,直接返回,避免了绝大多数情况下的加锁。
- 加锁:只有第一次检查为
null的线程才会进入同步块。 - 第二次检查:这是为了防止“在加锁的瞬间,已经有别的线程创建好了实例”这种情况。假设线程A和B都通过了第一次检查,A先获得锁并创建了实例,之后B获得锁,如果没有这第二次检查,B又会创建一个新实例。
volatile的作用:instance = new Singleton();这行代码在JVM中并不是一个原子操作,它大致分为三步:- 分配内存
- 初始化对象
- 将内存地址赋值给
instance引用
JVM可能出于优化,进行“指令重排”,导致顺序变成 1 -> 3 -> 2。如果一个线程执行到“3”但还没执行“2”(即对象尚未完全初始化),另一个线程在第一次检查时发现instance不为null,就会直接返回一个未初始化完成的半成品对象,从而引发程序错误。volatile关键字可以禁止JVM进行这种指令重排,并确保变量的修改对所有线程立即可见,从而保证DCL的正确性。
(4) 饿汉式(最简洁,天生线程安全)
与“懒加载”相反,饿汉式在类加载时就立即初始化实例。
public class Singleton {
// 类加载时,JVM就帮我们创建好了唯一实例
private static final Singleton instance = new Singleton();
private Singleton() {}
public static Singleton getInstance() {
return instance; // 直接返回
}
}
优点:
- 实现极其简单,无需担心线程安全问题(JVM保证类加载过程的线程安全)。
- 没有加锁开销,性能高。
缺点: - 无论这个实例是否会被用到,它都会在程序启动时被创建。如果这个实例初始化非常耗时,或者非常占用资源,但程序在运行中又很少用到它,就会造成资源浪费。
(5) 静态内部类式(推荐,兼顾懒加载与简单性)
这是结合了懒汉式和饿汉式优点的一种实现,也是《Effective Java》推荐的方式。
public class Singleton {
private Singleton() {}
// 静态内部类
private static class SingletonHolder {
// 内部类的静态成员,会在内部类首次被引用时加载和初始化
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE; // 此时才会触发SingletonHolder类的加载和INSTANCE的初始化
}
}
核心原理:
- JVM在加载外部类
Singleton时,并不会立即加载其内部类SingletonHolder。 - 只有当调用
getInstance()方法,触发了对SingletonHolder.INSTANCE的引用时,JVM才会去加载和初始化SingletonHolder类。 - JVM的类加载机制保证了其线程安全性,且
static final保证了实例的唯一性。 - 这种方式既实现了懒加载,又避免了同步开销,代码还非常简洁。
4. 典型应用场景
- 配置管理类:整个应用共享一份配置。
- 数据库连接池:管理有限的数据库连接资源。
- 日志记录器:统一记录应用日志。
- 线程池:管理应用中的工作线程。
- 缓存:如应用级缓存(非分布式缓存)。
总结
| 实现方式 | 线程安全 | 懒加载 | 性能 | 推荐度 |
|---|---|---|---|---|
| 基础懒汉式 | ❌ 不安全 | ✅ 是 | 高 | ❌ 不推荐 |
| 同步方法懒汉式 | ✅ 安全 | ✅ 是 | 低(频繁加锁) | ❌ 不推荐 |
| 双重检查锁定 | ✅ 安全 | ✅ 是 | 高 | ⭐⭐⭐ 推荐 |
| 饿汉式 | ✅ 安全 | ❌ 否 | 高 | ⭐⭐ 简单场景可用 |
| 静态内部类 | ✅ 安全 | ✅ 是 | 高 | ⭐⭐⭐⭐ 强烈推荐 |
在面试中,面试官通常希望你能清晰地讲出从“基础懒汉式”到“双重检查锁定”再到“静态内部类”的演进思路,并解释清楚 synchronized、volatile 和类加载机制是如何解决线程安全问题的。静态内部类的实现通常是最高效、最优雅的解决方案。