六、单例模式:定义、结构、实现与实战
单例模式,说白了就是保证一个类只有一个实例。你想想看,有些对象我们只需要一个,比如配置管理器、线程池、数据库连接池。如果搞出多个实例,要么浪费内存,要么数据不一致,要么资源冲突。
我记得刚入行那会儿,有个同事写了个日志工具类,每次调用都 new 一个实例。结果线上日志文件被多个实例同时写,日志全乱了。后来改成单例,问题就解决了。嗯,这就是单例模式最典型的应用场景。
6.1 定义与核心思想
单例模式的定义很简单:确保一个类只有一个实例,并提供一个全局访问点。
核心思想就两条:
- 私有化构造方法——不让外部 new 对象
- 提供一个静态方法——返回唯一的实例
这两条缺一不可。我见过有人只私有化构造方法,但忘了提供静态方法,结果类根本没法用。也有人提供了静态方法,但构造方法没私有化,外部照样 new 出多个实例。都是坑。
6.2 结构图
先看一张结构图,帮你建立整体认知:
6.3 五种实现方式
单例模式的实现方式很多,我挑最常用的五种来讲。每种都有它的适用场景和坑。
6.3.1 饿汉式
饿汉式,就是类加载时就创建实例。简单粗暴,线程安全。
public class Singleton {
// 类加载时就创建实例
private static final Singleton INSTANCE = new Singleton();
// 构造方法私有
private Singleton() {}
// 提供全局访问点
public static Singleton getInstance() {
return INSTANCE;
}
}
优点:实现简单,线程安全,没有锁的性能开销。
缺点:类加载时就创建,如果一直没用这个实例,就白白浪费了内存。
我的建议:如果实例不重,或者确定会被用到,用饿汉式最省心。我在项目中写配置管理器就用的饿汉式,因为配置肯定会被用到。
6.3.2 懒汉式(线程不安全)
懒汉式,就是延迟加载,用的时候才创建。但注意,这个版本是线程不安全的。
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton(); // 多线程下可能创建多个实例
}
return instance;
}
}
注意:这个版本在多线程环境下会出问题。两个线程同时判断 instance == null,都会创建实例。我曾经在面试中问过这个问题,很多人答不上来。
6.3.3 懒汉式(同步方法)
给 getInstance 方法加 synchronized,线程安全了,但性能下降了。
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
问题:每次调用 getInstance 都要加锁,性能开销大。其实只有第一次创建时需要同步,后面读操作不需要。
6.3.4 双重检查锁(DCL)
双重检查锁,既保证了延迟加载,又保证了线程安全,还减少了锁的开销。这是我最常用的方式。
public class Singleton {
// volatile 防止指令重排序
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
}
注意:instance 必须用 volatile 修饰。为什么?因为 new Singleton() 不是原子操作,它分三步:分配内存、初始化对象、将引用指向内存。如果没有 volatile,JVM 可能重排序,导致其他线程拿到一个未初始化的对象。我曾经踩过这个坑,排查了好久才发现是 volatile 的问题。
6.3.5 静态内部类
静态内部类方式,既实现了延迟加载,又线程安全,还没有锁。我个人最喜欢这种方式。
public class Singleton {
private Singleton() {}
// 静态内部类,只有在调用 getInstance 时才会加载
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
原理:JVM 在加载外部类时不会加载内部类,只有调用 getInstance 时才会加载 SingletonHolder,从而创建实例。JVM 的类加载机制保证了线程安全。
我的建议:如果项目没有特殊要求,优先用静态内部类方式。代码简洁,性能好,线程安全。我在好几个生产项目里都用这种方式,从来没出过问题。
6.4 优缺点总结
| 实现方式 | 延迟加载 | 线程安全 | 性能 | 推荐指数 |
|---|---|---|---|---|
| 饿汉式 | 否 | 是 | 高 | ⭐⭐⭐⭐ |
| 懒汉式(不安全) | 是 | 否 | 高 | ⭐ |
| 懒汉式(同步方法) | 是 | 是 | 低 | ⭐⭐ |
| 双重检查锁 | 是 | 是 | 高 | ⭐⭐⭐⭐ |
| 静态内部类 | 是 | 是 | 高 | ⭐⭐⭐⭐⭐ |
6.5 应用场景
单例模式的应用场景其实挺多的,我挑几个常见的说说:
- 配置管理器——全局只需要一份配置,多个实例会导致配置不一致
- 日志记录器——多个实例写同一个日志文件会冲突
- 线程池——全局共享一个线程池,避免资源浪费
- 数据库连接池——连接池本身是共享资源,多个实例会创建多余连接
- 缓存——全局缓存只需要一份,多个实例会缓存重复数据
核心原则:当系统中某个对象只需要一个实例,或者多个实例会导致资源浪费、数据不一致时,就用单例模式。
6.6 避坑指南
最后,分享几个我踩过的坑:
- 序列化破坏单例:如果单例类实现了 Serializable,反序列化时会创建新实例。解决方案是添加 readResolve 方法。
- 反射破坏单例:通过反射调用私有构造方法可以创建新实例。解决方案是在构造方法中加判断,如果实例已存在就抛出异常。
- 类加载器不同:不同类加载器会加载出不同的单例。这个在 Web 容器中要注意。
我曾经在写一个缓存工具时,忘了处理序列化问题。结果缓存数据在集群间同步时,每个节点都创建了自己的单例,缓存完全失效。排查了两天才找到原因。嗯,从那以后我写单例都会检查序列化和反射的问题。
单例模式虽然简单,但细节不少。选对实现方式,注意边界情况,才能写出靠谱的单例。