单例模式
从巧克力锅炉说起
工厂里有一台巧克力锅炉:先 fill()(注入原料),再 boil()(加热),最后 drain()(放出)。这台设备价值昂贵,工厂里只有一台。控制程序必须保证全程只有一个"锅炉控制对象"——如果同时存在两个实例,一个认为锅炉是空的,另一个已经在 boil(),就会溢出或损坏设备。
起初,开发者用了"懒加载"写法——第一次调用时才创建实例。但忘了加同步,结果在高并发下,两个线程同时通过了 if (instance == null) 的检查,各自创建了一个实例:
🔍 定义
单例模式(Singleton)确保一个类**只有一个实例**,并提供一个全局访问点。
核心手段:构造函数私有化 + 静态方法返回唯一实例。
⚠️ 不使用该模式存在的问题
| SingletonBadExample.java |
|---|
| package com.example.creational.singleton;
/**
* 单例模式 - 反例
* 问题:没有任何限制,每次使用都创建新的连接池实例,导致资源浪费
*/
public class SingletonBadExample {
public static void main(String[] args) {
UserServiceBad userService = new UserServiceBad();
OrderServiceBad orderService = new OrderServiceBad();
System.out.println(userService.findUser(1L));
System.out.println(orderService.findOrder(1L));
// 控制台会打印两次"连接池初始化",说明创建了两个实例 ❌
}
}
// ❌ 没有限制:每次都创建新的连接池
class UserServiceBad {
public String findUser(Long id) {
ConnectionPoolBad pool = new ConnectionPoolBad(); // 每次都重新初始化!
String conn = pool.getConnection();
return conn + ":user" + id;
}
}
class OrderServiceBad {
public String findOrder(Long id) {
ConnectionPoolBad pool = new ConnectionPoolBad(); // 又一个连接池实例!
String conn = pool.getConnection();
return conn + ":order" + id;
}
}
// 模拟一个重量级的连接池对象
class ConnectionPoolBad {
public ConnectionPoolBad() {
// 模拟耗时初始化
System.out.println("❌ 连接池初始化(耗时操作)!");
}
public String getConnection() {
return "Connection";
}
}
|
🏗️ 设计模式结构
%%{init: {'themeVariables': {'noteBkgColor': 'transparent', 'noteBorderColor': '#768390'}}}%%
classDiagram
classDef default fill:transparent,stroke:#768390
class ChocolateBoiler {
-empty: boolean
-boiled: boolean
-instance$: ChocolateBoiler
-ChocolateBoiler()
+getInstance()$ ChocolateBoiler
+fill() void
+boil() void
+drain() void
}
note for ChocolateBoiler "单例(Singleton)"
💻 三种线程安全实现
书中重点展示了三种修复方案,各有取舍:
| 方案 |
线程安全 |
懒加载 |
推荐度 |
| 饿汉式(类加载即创建) |
✅ |
❌ |
适合轻量级对象 |
| 双重检查锁(DCL + volatile) |
✅ |
✅ |
⭐ 生产首选 |
| 枚举 |
✅ |
❌ |
最简洁,防反射/反序列化 |
| SingletonExample.java |
|---|
| package com.example.creational.singleton;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 单例模式 - 四种常见实现方式
*/
public class SingletonExample {
public static void main(String[] args) {
// 方式一:饿汉式
AppConfig cfg1 = AppConfig.getInstance();
AppConfig cfg2 = AppConfig.getInstance();
System.out.println("✅ 饿汉式同一实例:" + (cfg1 == cfg2)); // true
// 方式二:双重检查锁(懒加载)
ConnectionPool p1 = ConnectionPool.getInstance();
ConnectionPool p2 = ConnectionPool.getInstance();
System.out.println("✅ DCL 同一连接池:" + (p1 == p2)); // true
// 方式三:静态内部类
Logger.getInstance().log("测试日志");
// 方式四:枚举
RedisCache.INSTANCE.put("user:1", "张三");
System.out.println("✅ 枚举缓存:" + RedisCache.INSTANCE.get("user:1"));
}
}
// 方式一:饿汉式(类加载时创建,JVM 保证线程安全)
class AppConfig {
// 类加载时立即创建,JVM 保证线程安全
private static final AppConfig INSTANCE = new AppConfig();
private String dbUrl;
private int maxPoolSize;
private AppConfig() {
// 模拟从配置文件加载
this.dbUrl = "jdbc:mysql://localhost:3306/app";
this.maxPoolSize = 20;
System.out.println("配置中心初始化完成");
}
public static AppConfig getInstance() { return INSTANCE; }
public String getDbUrl() { return dbUrl; }
public int getMaxPool() { return maxPoolSize; }
}
// 方式二:双重检查锁(懒加载 + 线程安全,推荐)
class ConnectionPool {
// volatile:防止 JVM 指令重排序导致其他线程拿到"半初始化"的对象
private static volatile ConnectionPool instance;
private ConnectionPool() {
System.out.println("连接池初始化,预建立 10 条连接...");
}
public static ConnectionPool getInstance() {
if (instance == null) { // 第一次检查:避免每次加锁
synchronized (ConnectionPool.class) {
if (instance == null) { // 第二次检查:防止并发时重复创建
instance = new ConnectionPool();
}
}
}
return instance;
}
public String getConnection() { return "Connection"; }
}
// 方式三:静态内部类(懒加载 + 线程安全,最优雅)
class Logger {
private Logger() {}
// Holder 类只有在 getInstance() 被调用时才会被加载,JVM 类加载天然线程安全
private static class Holder {
static final Logger INSTANCE = new Logger();
}
public static Logger getInstance() { return Holder.INSTANCE; }
public void log(String message) {
System.out.println("[LOG] " + message);
}
}
// 方式四:枚举(最简洁,额外防御反序列化和反射破坏)
enum RedisCache {
INSTANCE;
private final Map<String, Object> store = new ConcurrentHashMap<>();
public void put(String key, Object value) { store.put(key, value); }
public Object get(String key) { return store.get(key); }
public boolean contains(String key) { return store.containsKey(key); }
}
|
为什么 DCL 一定要加 volatile?
new ChocolateBoilerDCL() 在 JVM 内部分三步:① 分配内存;② 初始化对象;③ 将引用赋值给 instance。
JVM 允许重排序为 ①③②——这时另一个线程在第一次检查时看到 instance != null,但对象还未初始化完成,直接返回了一个"半成品"对象。
volatile 禁止这种重排序,确保对象完全初始化后 instance 才对其他线程可见。
⚖️ 优缺点
优点:
- 重量级对象(锅炉、连接池)只初始化一次,节省资源
- 全局共享同一个实例,状态统一可控
缺点:
- 单元测试困难:全局状态难以在测试间隔离
- 隐藏依赖:
Xxx.getInstance() 调用不透明,违反依赖倒置
- 实现不当(漏
volatile)在并发下仍会创建多个实例
🔗 与其它模式的关系
| 相关模式 |
关系说明 |
| 外观模式 |
外观对象通常实现为单例 |
| 享元模式 |
两者都只有一个实例,但目的不同:单例关注"唯一性",享元关注"共享节省内存" |
| 抽象工厂、建造者、原型 |
这三种模式的工厂/管理类本身,常被实现为单例 |
🗂️ 应用场景
- 重量级资源管理:连接池
HikariPool、线程池 ThreadPoolExecutor
- 全局配置:
application.properties 加载后的包装对象
- JDK:
Runtime.getRuntime()、System.console()
- Spring Bean 默认
@Scope("singleton")(容器级别唯一,非 JVM 级别)
🏭 工业视角
单例的五宗罪:为什么它被称为反模式
单例并不只是线程安全问题,它在工程实践中带来五类真实伤害:
- 违反 OOP 抽象特性:
IdGenerator.getInstance().getId() 是硬编码具体类,无法通过接口替换实现,扩展时要改动所有调用点。
- 隐藏依赖关系:单例不通过构造函数或参数声明依赖,阅读代码时必须逐行查找
getInstance() 调用,依赖关系完全不透明。
- 扩展性差:若需要两个数据库连接池(快 SQL 和慢 SQL 隔离),单例设计就无法适配,需要大规模重构。
- 可测试性差:单例持有全局状态,测试用例之间会互相污染;依赖重量级外部资源(如 DB)的单例也无法被 mock 替换。
- 不支持有参构造:连接池大小、超时时间等初始化参数无法优雅传入,只能绕路(全局配置类、
init() 方法等)。
| 单例导致可测试性问题 |
|---|
| // ❌ OrderService 依赖单例 IdGenerator,无法在测试中替换
public class OrderService {
public void createOrder() {
long id = IdGenerator.getInstance().getId(); // 全局状态,无法 mock
}
}
// ✅ 通过构造函数注入,测试时可传入 mock 实现
public class OrderService {
private final IdGenerator idGenerator;
public OrderService(IdGenerator idGenerator) {
this.idGenerator = idGenerator;
}
}
|
Spring IoC 是更优雅的"单例管理"方案
Spring 的 Bean 默认是单例作用域(@Scope("singleton")),但它的做法与手写单例有本质区别:
- Bean 本身不知道自己是单例——类只是普通 POJO,不持有
getInstance() 静态方法
- 依赖通过构造函数或
@Autowired 注入——依赖关系可见、可替换
- 测试时可切换 Bean——
@MockBean、@TestConfiguration 可轻松替换实现
推荐做法
在 Spring 体系中,让容器管理对象的生命周期和唯一性,而不是在类内部手写单例逻辑。
需要全局唯一的对象,用 @Component + @Autowired 注入,而非 Singleton.getInstance()。
单例的唯一性边界:进程而非集群
经典单例模式保证的是**进程内唯一**——不同进程(包括同一机器上的多个 JVM 实例)各有独立的单例对象。
在分布式/微服务场景下,"全局唯一"需要借助外部共享存储(如 Redis 分布式锁 + 序列化存储)才能实现,手写单例模式无能为力。
Java 的额外边界
严格来说,Java 单例的唯一性作用范围是**类加载器(ClassLoader)**,而非进程。
同一进程内若存在多个 ClassLoader(如 OSGi、插件容器),同一类可被加载多次,单例保证即告失效。