单例模式
1. 噩梦的开端:混乱的全局变量
你在写一个日志工具 Logger,为了方便,你把它写成 public static,想着全系统共用,简单粗暴。
public class Logger {
public static String logFilePath = "/app/logs/run.log";
public static void write(String msg) { ... }
}
一开始岁月静好。但项目变大后,噩梦来了:
- 模块A在启动时修改了
logFilePath指向/moduleA/; - 模块B因为依赖加载顺序问题,拿到的是A改过的路径,日志写到了A的目录里;
- 线上查Bug时,A的日志里混着B的内容,B的日志里找不到关键信息。
你用 private修饰变量,加上 get/set,稍微好点,但依然治标不治本——任何代码都能随意new,或者通过反射、序列化破坏封装。你需要一个铁律:这个类在JVM中必须且只有一个实例,且全局提供一个访问点。
这就是单例模式要解决的核心矛盾:如何控制类的实例化过程,确保全局唯一。
2. 第一版:饿汉式 —— 简单粗暴的“开机自启”
最简单的做法:类加载进内存时,立刻创建好实例,像手机里的预装应用,开机就有。
public class Logger {
// 类加载时即初始化,由JVM保证线程安全
private static final Logger INSTANCE = new Logger();
private Logger() {} // 堵住new的路
public static Logger getInstance() { return INSTANCE; }
public void log(String msg) { ... }
}
真实翻车案例:某支付系统在启动时加载了所有对账规则到单例中,结果业务量增长后,就因为这一个类导致JVM堆内存溢出,每次扩容都要加内存,成本极高。
优点:绝对的线程安全(JVM的 <clinit>是加锁的),代码极其简洁。
说明:关于 为什么是加锁的
这句话的意思是:JVM 在执行类的初始化方法 <clinit> 时,会自动加锁,保证类初始化的线程安全。
详细解释
1. <clinit> 是什么?
<clinit>是 Class Initialization Method(类初始化方法)的缩写。- 它是编译器自动生成的特殊方法,用来执行:
static变量的赋值static {}静态代码块
- 每个类最多只有一个
<clinit>方法(接口也有)。
2. 为什么说「是加锁的」?
JVM 规范规定:类的初始化必须是线程安全的。
具体机制是:
- 当多个线程同时第一次使用某个类时(触发类初始化),JVM 会对这个类的 Class 对象加锁。
- 只有拿到锁的那个线程才能执行该类的
<clinit>方法。 - 其他线程会被阻塞等待,直到持有锁的线程把
<clinit>执行完毕。 - 初始化完成后,JVM 会把类的状态标记为「已初始化」,后续线程直接使用,不再执行
<clinit>。
这相当于 JVM 内部自动实现了类似下面的同步逻辑(伪代码):
synchronized (Class对象) {
if (类还没初始化) {
执行 <clinit>();
标记为已初始化;
}
}
3. 实际意义
- 防止多线程重复初始化:避免静态变量被多次赋值,或者静态代码块被多次执行。
- 保证可见性:一个线程初始化完后,其他线程能立刻看到正确的静态状态。
举个简单例子
public class Test {
static {
System.out.println(Thread.currentThread().getName() + " 正在执行静态代码块");
try {
Thread.sleep(2000); // 故意睡2秒,方便观察
} catch (InterruptedException e) {}
}
public static void main(String[] args) {
new Thread(() -> new Test(), "线程A").start();
new Thread(() -> new Test(), "线程B").start();
}
}
你会看到类似输出:
线程A 正在执行静态代码块
(过了2秒)
只有一个线程真正执行了静态代码块,另一个线程会等它执行完。这就是 <clinit> 被加锁的效果。
总结一句话:
「<clinit> 是加锁的」= JVM 在执行类初始化时,自动对 Class 对象加了同步锁,保证同一个类的静态初始化只会被一个线程执行一次。
致命的缺点:内存占用不可控。如果这个Logger需要加载2GB的离线词库,而你整个项目其实只在一个罕见分支下才用到它,那无论用不用,这2GB的内存都被霸占了。这在微服务容器内存紧张的时代,是不可接受的。
3. 第二版:懒汉式 —— “随用随加载”的懒加载
既然怕浪费内存,那就不在类加载时创建,等到第一次调用 getInstance时再创建。
public class Logger {
private static Logger instance;
private Logger() {}
public static Logger getInstance() {
if (instance == null) { // 问题在这里!
instance = new Logger();
}
return instance;
}
}
代码很美,直到上线高并发场景——两个线程同时调用 getInstance,线程A判断为 null,刚准备创建,CPU时间片切换给线程B,B也判断为 null。结果A和B各自 new了一个Logger对象,单例破功。线上表现为:两个线程拿到的对象 hashCode不同,各自维护的缓冲区不一致,日志乱序,排错排了通宵。
4. 第三版:加锁 —— 用性能换安全
解决并发最简单就是加锁,让整条马路只过一个线程:
public static synchronized Logger getInstance() {
if (instance == null) { instance = new Logger(); }
return instance;
}
线程安全了,但性能雪崩。在十万级QPS的系统里,即使实例已经创建好了,每次获取还要排队抢锁。高并发下,成千上万个线程堵在这个方法门口,吞吐量直线下降90%。这就是过度同步的代价。
5. 第四版:双重检查锁(DCL)—— 竞态条件下的平衡艺术
计算机科学家们提出一个精妙方案:先不加锁检查,如果没创建再抢锁,抢到锁后再检查一次。
public class Logger {
private static volatile Logger instance;
private Logger() {}
public static Logger getInstance() {
if (instance == null) { // 第一次检查(无锁)
synchronized (Logger.class) {
if (instance == null) { // 第二次检查(有锁)
instance = new Logger();
}
}
}
return instance;
}
}
关键细节:为什么必须加 volatile?
因为 instance = new Logger();在JVM底层是三个步骤:
- 分配内存空间;
- 初始化对象;
- 将引用指向内存地址。
如果没有 volatile,JVM为了性能可能指令重排成 1 → 3 → 2。当线程A执行到第3步(引用已赋值,但对象还没初始化),线程B来了,第一次检查发现 instance != null,直接返回这个半成品对象。线程B调用 log()方法,可能读到的是未初始化的默认值(比如文件路径是 null),直接抛出空指针异常。
volatile在这里的作用就是禁止指令重排,保证写操作“先行发生于”读操作。
现实意义:这是Java并发编程中最经典的考点,也是许多中间件(如连接池、缓存管理器)初始化时的标准写法。
6. 第五版:静态内部类 —— 最优雅的“懒加载”实现
上面DCL代码臃肿,且 volatile有一定性能损耗(禁用缓存行优化)。更优雅的办法是利用JVM的类加载机制:
public class Logger {
private Logger() {}
private static class Holder {
private static final Logger INSTANCE = new Logger();
}
public static Logger getInstance() {
return Holder.INSTANCE; // 第一次调用时,Holder类才被加载
}
}
原理:外部类 Logger加载时,内部类 Holder并不会被加载。只有调用 getInstance触发 Holder.INSTANCE时,Holder才会被JVM加载,并在 <clinit>中线程安全地创建实例。这既实现了懒加载,又不用显式加锁,代码极简。
7. 终极版:枚举单例 —— 抵御反射与序列化的“物理级”防御
以上的所有方式,包括DCL和静态内部类,都有两个致命漏洞:
- 反射攻击:通过
setAccessible(true)强行调用私有构造器,可以创建新实例; - 序列化攻击:将单例对象写入文件再反序列化,会生成新对象(除非重写
readResolve方法)。
为了堵死这些漏洞,Java大神Josh Bloch(《Effective Java》作者)给出终极方案:枚举。
public enum Logger {
INSTANCE;
private String logPath;
// 构造器默认是private的
Logger() { logPath = "/default/path"; }
public void log(String msg) { ... }
}
为什么枚举无敌?
- 枚举类没有构造器(即使反射也无法创建枚举实例,会直接抛异常);
- 序列化机制对枚举特殊处理,只会查找已存在的枚举常量,不会新建对象;
- 线程安全由JVM保证。
在如今的实际工程中,如果是一个轻量级工具包,枚举单例几乎是最佳选择。
8. 现代视角:单例模式的“去中心化”
随着Spring、Guice等依赖注入(DI)框架的普及,我们不再手工写单例。Spring容器默认将所有Bean的作用域设为 Singleton,由容器来保证唯一性。你只需要加个 @Component注解,容器启动时帮你创建,运行时替你注入。
但这并不是说单例模式过时了。理解它的演化史,能帮你明白:
- JVM类加载器的机制;
- 内存可见性(volatile)的本质;
- 反射与序列化的安全性边界;
- IoC容器解决单例“全局耦合”问题的底层逻辑(把唯一性控制权交给容器,便于单元测试时替换为Mock对象)。
本章核心脉络图(无需记忆,重在理解过程)
| 演进版本 | 核心痛点 | 解决方案 | 代价/新问题 |
|---|---|---|---|
| 全局变量(反模式) | 随处修改,无法管控 | public static | 状态混乱,无唯一性保障 |
| 饿汉式 | 控制唯一性 | 类加载即创建 | 内存浪费,启动慢 |
| 懒汉式 | 节省内存 | 延迟加载 | 线程不安全 |
| 同步懒汉式 | 保证线程安全 | synchronized | 性能瓶颈(锁竞争) |
| DCL双重检查 | 提升性能 | volatile + 两次检查 | 代码复杂,依赖内存模型 |
| 静态内部类 | 解耦并发与加载 | 类加载机制托管 | 无法抵御反射/序列化 |
| 枚举 | 终极安全 | 语言级特性支持 | 无法继承(但单例不需要) |
| IoC容器单例 | 去中心化管理 | 外部容器(Spring) | 需要引入框架,增加复杂度 |
本章总结
单例模式看似简单,但它的每次进化都对应着一个真实的软件工程困境:从内存控制、并发安全,到防御性编程,再到架构层面的解耦。掌握它,你不仅学会了如何创建一个全局唯一对象,更学会了权衡的艺术——在性能、安全、简洁和可测试性之间找到当前场景的最优解。