2、单例模式原理:单例模式的定义、适用场景、饿汉式与懒汉式的核心区别
单例模式,说白了就是保证一个类在整个系统里只有一个实例。你想想看,有些对象我们真的不需要创建多个——比如配置管理器、线程池、数据库连接池。如果每个地方都 new 一个,内存浪费不说,数据还可能不一致。
我个人习惯把单例模式看作「全局唯一看门人」。它自己管着自己的实例,别人想拿?只能通过它提供的那个静态方法。嗯,这里要注意:单例模式不光管创建,还管生命周期。
单例模式的定义
定义其实很简单:保证一个类仅有一个实例,并提供一个全局访问点。这句话里藏着两个关键动作:
- 控制实例数量:构造函数私有化,外部不能 new
- 提供统一入口:通过静态方法返回唯一实例
我在项目中遇到过有人把构造函数写成 public,然后跟我说「我保证只 new 一次」——这种靠自觉的方式,迟早会出问题。单例模式就是用代码强制约束,而不是靠约定。
适用场景
不是所有类都需要做成单例。我总结了几类典型场景:
| 场景 | 说明 | 我踩过的坑 |
|---|---|---|
| 配置管理 | 全局配置只需加载一次 | 曾经把配置类做成多实例,结果不同模块读到不同配置值,排查了一整天 |
| 日志记录器 | 所有模块写同一个日志文件 | 多实例会导致日志交错、文件锁冲突 |
| 线程池 | 系统内线程资源统一管理 | 多个线程池实例容易把系统资源耗尽 |
| 数据库连接池 | 复用连接,避免频繁创建销毁 | 连接池单例没做好,生产环境直接 OOM |
| 设备管理器 | 打印机、显卡等硬件只能有一个管理器 | —— |
你想想看,如果一个系统里有 10 个地方都在 new 配置对象,每个对象都要读一次配置文件——这不仅是性能问题,更可怕的是数据不一致。我曾经在一个支付系统里遇到过,因为配置对象不是单例,导致 A 模块读到的是旧配置,B 模块读到的是新配置,结果签名校验失败,线上事故。
饿汉式与懒汉式的核心区别
这两种实现方式,说白了就是「什么时候创建实例」的问题。
饿汉式
类加载的时候就创建实例。不管用不用,先 new 出来再说。
public class EagerSingleton {
// 类加载时直接创建
private static final EagerSingleton instance = new EagerSingleton();
private EagerSingleton() {}
public static EagerSingleton getInstance() {
return instance;
}
}
优点很明显:线程安全,JVM 在类加载阶段就保证了只有一个线程能执行静态初始化。缺点呢?如果这个类很重,而且程序启动后一直没用它,那就白白浪费了资源。
我记得有一次做嵌入式开发,内存只有 64MB。用了饿汉式,结果启动时加载了一堆单例对象,直接 OOM。后来改成懒汉式才解决问题。
懒汉式
等到第一次调用 getInstance() 时才创建实例。说白了就是「懒加载」。
public class LazySingleton {
private static LazySingleton instance;
private LazySingleton() {}
public static LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
}
这个版本有个大问题:线程不安全。两个线程同时进入 if 判断,就会创建两个实例。我曾经在生产环境遇到过,日志里出现了两个不同的实例地址,排查了半天才发现是懒汉式没加锁。
改进版——加个 synchronized:
public static synchronized LazySingleton getInstance() {
if (instance == null) {
instance = new LazySingleton();
}
return instance;
}
但这样性能太差,每次调用都要加锁。更好的方式是双重检查锁(DCL):
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;
}
}
这里 volatile 关键字很关键。我曾经见过有人漏掉 volatile,结果在并发场景下拿到的是半初始化的对象——那个 bug 查了我整整两天。
核心区别总结
| 对比维度 | 饿汉式 | 懒汉式 |
|---|---|---|
| 创建时机 | 类加载时 | 首次调用时 |
| 线程安全 | 天然安全 | 需要额外处理 |
| 资源占用 | 启动即占用 | 按需占用 |
| 实现复杂度 | 简单 | 较复杂(需考虑锁和 volatile) |
| 适用场景 | 实例轻量、一定会被使用 | 实例重量、可能不被使用 |
我的建议:如果没有特殊理由,优先用饿汉式。简单、安全、不容易出错。懒汉式虽然灵活,但实现细节多,稍不注意就埋坑。
小技巧:如果既想要懒加载又想要线程安全,可以考虑用静态内部类方式。它利用了 JVM 的类加载机制,既延迟加载又天然线程安全。
警告:单例模式在分布式环境下会失效!每个 JVM 进程都有自己的实例。如果要做全局唯一,需要引入分布式锁或注册表方案。
知识体系图
这张图把单例模式的核心脉络理清楚了。从定义出发,到适用场景,再到两种实现方式的分支。你想想看,饿汉式和懒汉式就像两种性格——一个急性子,一个慢性子。选哪个,取决于你的业务场景和资源约束。
我个人更倾向于:能用饿汉式就别折腾懒汉式。除非你明确知道这个单例对象很重,而且大部分情况下不会被用到。我曾经在一个微服务网关里,把路由表做成了懒汉式单例,因为不是每个请求都需要加载全部路由规则。这种场景下,懒加载确实能省不少内存。
嗯,单例模式看似简单,但坑不少。多线程下的指令重排序、反射破坏单例、序列化破坏单例……这些进阶话题我们后面再聊。今天先把基础打牢:定义、场景、两种实现的核心区别,这三个点吃透了,单例模式你就掌握了八成。