「设计模式与范式」系列 Day09
写在前面
单例模式大概是所有设计模式里最简单、也是最容易被写错的一个------网上随便一搜就有五六种写法,简历上也几乎人人都会背"饿汉懒汉双重检测"这几个词。但真放到高并发场景下追问一句"这样写到底安不安全",很多人就说不清楚了。这篇把这几种写法拆开揉碎,讲清楚它们各自在赌什么、又是靠什么兜底的。
一、是什么:单例要解决的问题,以及实现时要权衡的四件事
单例设计模式很好理解:一个类只允许创建一个实例 ,这个类就是单例类。它主要用在两类场景:一是处理资源访问冲突 (比如多个线程往同一个日志文件写内容,如果各自持有一个 Logger 对象,很容易出现日志错乱,交给唯一实例统一调度就没有这个问题);二是表示一个全局唯一的概念(比如配置信息、ID 生成器,业务上本来就该只有一份)。
写一个单例类,需要同时权衡四件事:
- 构造函数必须私有 ,堵死外部
new的路 - 创建过程的线程安全,不能让并发场景下同一个类被 new 出两个实例
- 要不要支持延迟加载(用到的时候才创建,还是类加载时就创建好)
getInstance()的性能,高并发下频繁调用会不会成为瓶颈
后面要看的五种实现方式,本质上都是在这四个约束之间做不同的取舍。
二、为什么:五种写法,其实是三个特性之间的不同取舍
线程安全、延迟加载、高性能,这三个特性不容易同时兼得------想要延迟加载就得在第一次调用时判断"要不要创建",这个判断本身在并发下就不安全;想要线程安全最简单的办法是加锁,但锁又拖累性能。五种常见实现方式对这三者的取舍如下:
| 实现方式 | 支持延迟加载 | 线程安全 | 性能 |
|---|---|---|---|
| 饿汉式 | 不支持 | 天然安全(类加载时创建) | 高,无锁 |
| 懒汉式(方法加锁) | 支持 | 安全 | 低,每次调用都要抢锁 |
| 双重检测锁 | 支持 | 安全(需配合 volatile) |
高,只有首次创建时才进锁 |
| 静态内部类 | 支持 | 安全(依赖类加载机制) | 高,无锁 |
| 枚举 | 不支持 | 安全(JVM 保证) | 高,无锁 |
三、怎么用:五种实现,以及各自的坑
饿汉式:用空间换安全,代价是不支持延迟加载
java
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private static final IdGenerator instance = new IdGenerator();
private IdGenerator() {}
public static IdGenerator getInstance() {
return instance;
}
public long getId() { return id.incrementAndGet(); }
}
类加载的时候实例就创建好了,天然线程安全,但如果这个类一直没被用到,也照样占着这份初始化开销------不支持延迟加载。
懒汉式:延迟加载了,但锁挡在了每次调用前
java
public static synchronized IdGenerator getInstance() {
if (instance == null) {
instance = new IdGenerator();
}
return instance;
}
synchronized 加在方法上,意味着每一次 调用 getInstance() 都要抢这把锁,哪怕实例早就创建好了、后面的调用本该是无锁的读操作。高并发场景下这把锁会变成明显的性能瓶颈。
双重检测锁:只在真正需要创建时才进锁,但少了一个关键字就不安全
java
private static volatile IdGenerator instance; // volatile 不能省
public static IdGenerator getInstance() {
if (instance == null) {
synchronized (IdGenerator.class) {
if (instance == null) {
instance = new IdGenerator();
}
}
}
return instance;
}
第一次判断 instance == null 是无锁的,只有真正需要创建实例时才会进同步块,进去之后再判断一次(避免多个线程都排队等到锁之后重复创建)。这个流程画出来是这样的:
最容易被忽略的坑 :instance 字段必须加 volatile。new IdGenerator() 这一步在虚拟机里实际拆成三个动作------分配内存、执行构造函数初始化、把引用赋值给 instance------如果编译器/CPU 对这三步做了指令重排序,赋值可能发生在初始化完成之前。这时另一个线程走到第一层判断,看到 instance != null 就直接返回了,但拿到的其实是一个还没构造完成 的半成品对象。volatile 的作用正是禁止这里的指令重排序,这是双重检测锁能安全工作的必要条件,不是可有可无的修饰符。
静态内部类:比双重检测更简单,同样能延迟加载
java
public class IdGenerator {
private IdGenerator() {}
private static class SingletonHolder {
private static final IdGenerator instance = new IdGenerator();
}
public static IdGenerator getInstance() {
return SingletonHolder.instance;
}
}
SingletonHolder 只有在 getInstance() 第一次被调用时才会被加载,类加载机制本身保证了这个过程线程安全------既做到了延迟加载,又完全不用手写锁,是双重检测锁之外更省心的选择。
枚举:最简单,但放弃了延迟加载
java
public enum IdGenerator {
INSTANCE;
private AtomicLong id = new AtomicLong(0);
public long getId() { return id.incrementAndGet(); }
}
依靠枚举类型本身的特性由 JVM 保证唯一性和线程安全,代码最少,但和饿汉式一样不支持延迟加载。
常见的坑:单例本身自带的副作用
单例虽然好写,但一直是被诟病较多的模式:它破坏了面向对象通过多态实现扩展的能力(全局唯一意味着没法有多个实现互换);调用方直接写死 getInstance(),会隐藏类之间真实的依赖关系,不像构造函数参数那样能一眼看出依赖了谁;也因为全局状态难以隔离,单元测试里很难替换成 mock 对象;此外它天生不支持带参数的构造函数。业务代码里更推荐用工厂模式 或者IOC 容器(比如 Spring 默认的单例 bean)来管理全局唯一的对象------依赖关系通过参数或者注入显式表达出来,也方便测试时替换实现。
单例的唯一性范围之外:线程唯一、集群唯一、多例
前面五种实现的"唯一",指的都是进程内唯一 (进程唯一天然包含线程内、线程间都唯一)。如果只需要线程唯一(同一线程内单例,不同线程之间可以有不同实例),可以按线程 ID 存一份实例:
java
private static final ConcurrentHashMap<Long, IdGenerator> instances = new ConcurrentHashMap<>();
public static IdGenerator getInstance() {
long tid = Thread.currentThread().getId();
instances.putIfAbsent(tid, new IdGenerator());
return instances.get(tid);
}
集群唯一(多个进程间也只能存在一份对象)思路上要跳出单个 JVM:把对象序列化后存到外部共享存储(比如文件、Redis),进程使用前先加分布式锁、把对象读出来反序列化使用,用完再写回存储、释放锁------本质上是把"唯一性"的保证从 JVM 内部的同步机制,转移到了外部存储 + 分布式锁上。
还有一种容易和单例搞混的是多例模式 :一个类允许创建的实例个数有限(比如固定 3 个后端节点对象),同一类型只有一份,不同类型可以有多份。它和工厂模式的区别在于,多例创建的始终是同一个类 的多个实例,工厂模式创建的是不同子类的实例。
四、面试追问
Q1:单例模式主要用来解决什么问题?
主要两类场景:一是处理资源访问冲突,比如多线程写同一份日志,用单例统一调度可以避免内容错乱;二是表达业务上本来就该全局唯一的概念,比如配置信息、ID 生成器。核心约束是"一个类只允许创建一个实例"。
Q2:实现单例要权衡哪几个因素?为什么会衍生出这么多种写法?
要同时权衡构造函数私有化、创建过程的线程安全、是否支持延迟加载、getInstance() 的性能这四点。线程安全、延迟加载、高性能这三个特性不容易同时满足,饿汉式、懒汉式、双重检测锁、静态内部类、枚举这几种写法本质上是在这三者之间做出的不同取舍。
Q3:双重检测锁为什么必须给 instance 字段加 volatile?不加会有什么后果?
因为 new 一个对象在虚拟机层面拆成分配内存、执行构造函数初始化、赋值引用三步,如果发生指令重排序,赋值可能先于初始化完成。不加 volatile 时,另一个线程可能在第一层判断里拿到一个非空但还没构造完成的半成品对象,直接使用就会出错。volatile 的作用就是禁止这里的指令重排序,是双重检测锁安全工作的必要条件。
Q4:单例模式有哪些常被诟病的问题?业务代码里有什么更推荐的替代方案?
主要问题有:破坏面向对象通过多态实现扩展的能力、隐藏类之间真实的依赖关系(不像构造参数那样一目了然)、让单元测试难以替换成 mock、以及天生不支持带参数的构造函数。业务代码里更推荐用工厂模式,或者交给 IOC 容器(比如 Spring 的单例 bean)来管理,依赖关系可以通过参数或注入显式表达,也方便测试时替换实现。
Q5:单例说的"唯一",具体指多大范围?如果只想要线程唯一或者集群唯一,思路上要怎么调整?
标准单例的唯一性范围是"进程内唯一"(进程唯一天然包含线程内、线程间都唯一)。如果只需要线程唯一,可以用一个以线程 ID 为 key 的 Map 给每个线程保存各自的实例;如果需要集群唯一(多进程间也只有一份),思路要跳出单个 JVM,把对象序列化存到外部共享存储,配合分布式锁保证任意时刻只有一个进程持有并使用这个对象。
Q6:多例模式和工厂模式看起来都能创建"一批"对象,区别是什么?
多例模式里,允许创建的实例数量有限,但这些实例都是同一个类 的对象,只是通过某个 key(比如线程 ID、节点编号)区分不同实例;工厂模式创建的则是不同子类的对象,由传入的参数决定具体创建哪一个子类的实例。两者的相同点都是把创建逻辑收敛到统一的入口,区别在于产出的对象是否属于同一个类。
下一篇预告
Day10 工厂模式三兄弟:简单工厂、工厂方法、抽象工厂到底怎么选。