大白话说Java设计模式-02-单例模式(业务实战篇):大白商城配置中心的"唯一管家"
📌 一句话本质:单例模式就是"全公司只准有一个管家,所有人想用都得通过他"。
🏷️ 标签:单例模式 / Java 设计模式 / Spring / 大白商城 🎯 适合:初中级后端 / 准备面试的程序员
目录
- 一、业务场景引入:大白商城为什么必须有个"唯一管家"?
- 二、反面教材:如果不控制实例化会出什么事?
- 三、模式原理:单例模式的"三个规矩 + 一张图"
- 四、实战代码:大白商城配置中心完整实现
- 五、工程决策 Checklist:什么时候用、什么时候别用
- 六、与其他模式协作:单例从来不是一个人在战斗
- 七、本篇小结 + 下篇预告
一、业务场景引入:大白商城为什么必须有个"唯一管家"?
大白商城是公司基于 Spring Cloud Alibaba 搭建的 B2C 电商平台,上线一年多,扛过大促、扛过秒杀、扛过 618。最近产品提了一个看似简单、实则"动全身"的需求:
“秒杀活动配置要支持热更新。后台改了配置,不用重启服务,前端立刻生效。”
一听就头大。“热更新"涉及到的核心对象就是配置中心——它是大白商城的"中枢神经”,所有服务都从它这里拿开关、拿参数、拿白名单。
如果让每个服务自己去 new 一个配置中心,会出大问题。我先带大家看看这个"最朴素"的想法有多可怕。
1.1 配置中心承担的"四大职责"
大白商城的 MallConfigCenter(配置中心)需要承担:
| ① 秒杀活动开关 | seckill.enabled=true/false | 多实例配置不一致,有的服务在跑秒杀、有的没跑,直接资损 |
| ② 支付渠道路由 | payment.channel=alipay/wechat/unionpay | 同一个订单被不同实例路由到不同渠道,对账对不上 |
| ③ 白名单 IP | whitelist.ips=10.x.x.x,192.x.x.x | 每个实例持的白名单不一致,限流形同虚设 |
| ④ 分布式 ID 生成器 | 雪花算法 WorkerId | 多实例生成重复 ID,订单主键冲突 |
看明白没? 四个职责都有一个共同特征:全局必须只有一份,不能各搞各的。
这就是单例模式最经典的业务场景:有状态、需要全局唯一访问点的对象。
1.2 用大白话讲透单例
不讲 UML,不讲定义。咱们就拿"公司前台"打比方:
- 错误做法:每个员工想进门,自己去前台领一张门禁卡,自己去前台查快递。结果就是:门禁卡发重了(状态不一致)、查快递信息不一致(数据不一致)、前台小姐姐被烦死(资源浪费)。
- 正确做法:公司只有一个前台,所有员工进门、查快递、领资料都找她(唯一一个)。她手里的所有信息自然全局一致。
单例模式 = 全 JVM / 全 Spring 容器只允许存在一个实例,所有人共用。
这个需求贯穿整个大白商城,远不止配置中心。再举几个:
- RedisTemplate(Spring 自动配置的单例 Bean)
- OkHttpClient(连接池,多个实例等于连接泄露)
- ObjectMapper(Jackson 序列化器,复用配置)
- ScheduledExecutorService(定时任务线程池,多实例 = 任务重复执行)
- Logger(每个类一个 logger,logback 默认就是单例)
理解了场景,咱们就来看:如果不控制,会怎么翻车。
二、反面教材:如果不控制实例化会出什么事?
我先写一段"新手程序员最容易写出来的版本",让大家看看它是怎么一步步崩溃的。
2.1 第一版:最朴素的写法
/**
* ❌ 反面教材 v1:朴素的配置中心
* 看似能跑,实则每个调用方都 new 一个新实例,全局状态彻底乱套
*/
public class MallConfigCenterV1 {
// 模拟从配置中心拉取的配置
private Map<String, String> configCache = new HashMap<>();
public MallConfigCenterV1() {
// 模拟初始化:从 Nacos 拉取配置,耗时 500ms
System.out.println("【V1】初始化 MallConfigCenter,耗时 500ms…");
this.configCache.put("seckill.enabled", "true");
this.configCache.put("payment.channel", "alipay");
}
public String get(String key) {
return configCache.get(key);
}
}
调用方代码:
@Service
public class SeckillServiceV1 {
public boolean isSeckillEnabled() {
// 每次调用都 new 一个新的配置中心
MallConfigCenterV1 config = new MallConfigCenterV1();
return "true".equals(config.get("seckill.enabled"));
}
}
翻车现场(4 个致命问题):
| ① | 每次调用 new 一个,500ms 初始化走 100 次 = 50s | 性能灾难 |
| ② | 每个实例的 configCache 是独立的 | 改了 A 实例的缓存,B 实例看不到,配置失效 |
| ③ | 多线程并发 new,可能拿到半初始化的对象 | 线程安全翻车 |
| ④ | 资源浪费:连接池、线程池被反复创建 | OOM 风险 |
2.2 第二版:加个 static 试试?
很多新手觉得"那简单,我加个 static 不就行了?"——这是 90% 的人会踩的第一个坑。
/**
* ❌ 反面教材 v2:用 static 模拟单例
* 看起来是单例了,但问题更大
*/
public class MallConfigCenterV2 {
// 用 static 引用一个"唯一"实例
private static MallConfigCenterV2 instance = new MallConfigCenterV2();
private Map<String, String> configCache = new HashMap<>();
private MallConfigCenterV2() {
// 模拟初始化
System.out.println("【V2】初始化 MallConfigCenter");
this.configCache.put("seckill.enabled", "true");
}
public static MallConfigCenterV2 getInstance() {
return instance;
}
public String get(String key) {
return configCache.get(key);
}
}
翻车现场:
| ① | 饿汉式加载 | 类一加载就初始化,项目启动慢,且如果配置中心初始化失败,整个类加载失败,波及所有引用方 |
| ② | 无法懒加载 | 单元测试时根本用不上配置中心,但它还是被 new 出来了 |
| ③ | 不支持依赖注入 | 写 MallConfigCenterV2.getInstance() 这种静态调用,Mock 不掉,单元测试困难 |
| ④ | 无法控制初始化顺序 | 配置中心依赖 Nacos 客户端,但 static 字段的初始化时机不在 Spring 手里 |
2.3 第三版:上 Spring 看看?
为了解决 Mock 问题,又有人想"那我直接用 Spring Bean 不就行了?"——这是第二个坑。
/**
* ❌ 反面教材 v3:依赖 Spring 容器就是单例?
*/
@Service
public class MallConfigCenterV3 {
private Map<String, String> configCache = new HashMap<>();
@PostConstruct
public void init() {
System.out.println("【V3】初始化 MallConfigCenter");
this.configCache.put("seckill.enabled", "true");
}
public String get(String key) {
return configCache.get(key);
}
}
问题暴露:
// 单元测试里:
new MallConfigCenterV3(); // 这不就又 new 出来了吗?单例破了!
关键认知:
Spring 的"单例"是 IoC 容器级别的,离开 Spring 容器,@Service 注解就是个摆设。 单例模式要解决的是 “在任意上下文里都只有一个实例”,不是"在 Spring 里只有一个"。
所以,咱们必须用正经的、教科书式的单例写法,再叠加 Spring 的能力,做到内外兼修。
三、模式原理:单例模式的"三个规矩 + 一张图"
3.1 三个硬性规矩(缺一不可)
| ① | 私有构造方法 | 禁止外部 new,把"生娃"权力收归类自己 |
| ② | 私有静态引用 | 类自己持有那个唯一实例的引用 |
| ③ | 公共静态访问点 | 提供 getInstance() 之类的全局入口 |
多线程场景下再加一个隐性规矩:
| ④ | 线程安全 | 多线程下不能出现多个实例、不能返回半初始化对象 |
3.2 一张图看懂五种单例实现
┌──────────────────────┐
│ 单例模式五种实现 │
└──────────┬───────────┘
│
┌──────────┬──────────┼──────────┬──────────┐
│ │ │ │ │
┌───▼───┐ ┌───▼───┐ ┌───▼───┐ ┌───▼───┐ ┌───▼───┐
│ 饿汉式 │ │ 懒汉式 │ │ DCL │ │静态内部│ │ 枚举 │
│ │ │ (基础) │ │双重锁│ │ 类 │ │ │
└───────┘ └───────┘ └───────┘ └───────┘ └───────┘
类加载即 调用才 调用才 调用才 JDK 1.5+
初始化 初始化 初始化 初始化 防反射/
序列化
3.3 五种实现的核心对比
| 饿汉式(static final) | ✅ | ❌ | ❌ | ❌ | ⭐⭐ 简单场景 |
| 懒汉式(基础版) | ❌ | ✅ | ❌ | ❌ | ❌ 千万别用 |
| 双重检查锁(DCL) | ✅ | ✅ | ❌ | ❌ | ⭐⭐⭐⭐ 主流 |
| 静态内部类 | ✅ | ✅ | ❌ | ❌ | ⭐⭐⭐⭐ 优雅 |
| 枚举(Joshua Bloch 推荐) | ✅ | ✅ | ✅ | ✅ | ⭐⭐⭐⭐⭐ 终极方案 |
| Spring @Scope("singleton") | ✅ | 默认懒 | — | — | ⭐⭐⭐⭐⭐ 容器内首选 |
大白商城的选型:
- 业务核心配置中心 → 枚举(最强防破坏)
- 工具类(无状态) → 静态内部类
- Spring 管理的 Bean → @Scope("singleton")(Spring 默认)
- 历史遗留代码 → DCL 改造
四、实战代码:大白商城配置中心完整实现
下面是大白商城真实在用的配置中心实现,全套代码可直接复制到 IDEA 里跑。
4.1 项目环境与依赖
pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.0</version>
<relativePath/>
</parent>
<groupId>com.dabai.mall</groupId>
<artifactId>mall-design-pattern-01</artifactId>
<version>1.0.0-SNAPSHOT</version>
<name>mall-design-pattern-01</name>
<description>大白商城 – 设计模式 02 单例模式</description>
<properties>
<java.version>17</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!– Spring Boot 基础 –>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!– Spring Boot Test –>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!– Lombok:简化代码(阿里规范允许使用) –>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
4.2 实现一:枚举单例(Joshua Bloch 终极方案)
Joshua Bloch 在《Effective Java》第 3 条明确说:“单元素的枚举类型是实现单例的最佳方式。” 原因:枚举天然防反射攻击、防反序列化攻击、线程安全、代码极简。
package com.dabai.mall.config;
import lombok.extern.slf4j.Slf4j;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* ✅ 终极方案:枚举单例 – 大白商城配置中心
* <p>
* 核心优势:
* 1. 防反射攻击:JDK 禁止通过反射创建枚举实例
* 2. 防反序列化攻击:枚举反序列化时不会调用 readObject,而是返回同一个实例
* 3. 线程安全:JVM 保证枚举常量只被实例化一次
* 4. 代码极简:一行搞定
*
* @author 大白商城技术团队
* @since 1.0.0
*/
@Slf4j
public enum MallConfigCenter {
/**
* 唯一实例
*/
INSTANCE;
/**
* 配置缓存:key=配置项,value=配置值
* 使用 ConcurrentHashMap 保证并发安全
*/
private final Map<String, String> configCache = new ConcurrentHashMap<>();
/**
* 初始化时间戳(用于单元测试验证单例)
*/
private final long initTimestamp = System.currentTimeMillis();
/**
* 枚举构造方法:JVM 保证只执行一次
*/
MallConfigCenter() {
log.info("【MallConfigCenter】初始化开始, instance={}", System.identityHashCode(this));
// 模拟从 Nacos 拉取配置
configCache.put("seckill.enabled", "true");
configCache.put("payment.channel", "alipay");
configCache.put("whitelist.ips", "10.0.0.0/8,192.168.0.0/16");
log.info("【MallConfigCenter】初始化完成, 共加载 {} 条配置", configCache.size());
}
/**
* 获取配置值
*
* @param key 配置项 key
* @return 配置值,未找到返回 null
*/
public String get(String key) {
return configCache.get(key);
}
/**
* 获取带默认值的配置
*
* @param key 配置项 key
* @param defaultValue 默认值
* @return 配置值或默认值
*/
public String get(String key, String defaultValue) {
return configCache.getOrDefault(key, defaultValue);
}
/**
* 热更新配置(接收 Nacos 推送)
*
* @param key 配置项
* @param value 新值
*/
public void update(String key, String value) {
log.info("【MallConfigCenter】配置变更: {} = {}", key, value);
configCache.put(key, value);
}
/**
* 获取初始化时间戳(用于验证单例)
*/
public long getInitTimestamp() {
return initTimestamp;
}
}
调用方代码:
package com.dabai.mall.service;
import com.dabai.mall.config.MallConfigCenter;
import org.springframework.stereotype.Service;
/**
* 秒杀服务:使用枚举单例的配置中心
*/
@Service
public class SeckillService {
/**
* 判断秒杀活动是否开启
*/
public boolean isSeckillEnabled() {
return "true".equals(MallConfigCenter.INSTANCE.get("seckill.enabled"));
}
}
枚举单例防反射攻击验证:
// 反射攻击枚举?JDK 早就防住了
Constructor<MallConfigCenter> constructor = MallConfigCenter.class.getDeclaredConstructor();
constructor.setAccessible(true);
// ❌ 抛异常:Cannot reflectively create enum objects
MallConfigCenter attackInstance = constructor.newInstance();
4.3 实现二:静态内部类(优雅懒加载)
适用场景:工具类、无状态的辅助类。兼顾懒加载和线程安全。
package com.dabai.mall.util;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
/**
* ✅ 优雅方案:静态内部类单例
* <p>
* 核心原理:
* 1. 外部类加载时,内部类不会被加载(懒加载)
* 2. 内部类被首次引用时,JVM 保证 static 字段只初始化一次(线程安全)
*
* @author 大白商城技术团队
*/
@Slf4j
public class DateFormatUtil {
/**
* 私有构造:禁止外部 new
*/
private DateFormatUtil() {
log.info("【DateFormatUtil】初始化");
}
/**
* 静态内部类持有唯一实例
*/
private static class Holder {
private static final DateFormatUtil INSTANCE = new DateFormatUtil();
}
/**
* 全局访问点
*/
public static DateFormatUtil getInstance() {
return Holder.INSTANCE;
}
/**
* 线程安全的日期格式化(DateTimeFormatter 本身线程安全)
*/
private static final DateTimeFormatter FORMATTER =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
public String format(LocalDateTime dateTime) {
return dateTime.format(FORMATTER);
}
}
4.4 实现三:双重检查锁(DCL)—— JDK 17 优化版
适用场景:需要兼容老代码 或 需要传构造参数 的场景。
package com.dabai.mall.id;
import lombok.extern.slf4j.Slf4j;
/**
* ✅ DCL 单例:分布式 ID 生成器
* <p>
* 关键点:
* 1. volatile 关键字绝对不能少(防止指令重排)
* 2. JDK 1.5+ 对 volatile 的内存语义已完善
*
* @author 大白商城技术团队
*/
@Slf4j
public class SnowflakeIdGenerator {
/**
* 起始时间戳:2024-01-01
*/
private static final long START_TIMESTAMP = 1_704_067_200_000L;
/**
* 各部分占用的位数
*/
private static final long WORKER_ID_BITS = 5L;
private static final long DATACENTER_ID_BITS = 5L;
private static final long SEQUENCE_BITS = 12L;
/**
* 最大值计算
*/
private static final long MAX_WORKER_ID = ~(–1L << WORKER_ID_BITS);
private static final long MAX_DATACENTER_ID = ~(–1L << DATACENTER_ID_BITS);
private static final long MAX_SEQUENCE = ~(–1L << SEQUENCE_BITS);
/**
* 位移
*/
private static final long WORKER_ID_SHIFT = SEQUENCE_BITS;
private static final long DATACENTER_ID_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS;
private static final long TIMESTAMP_SHIFT = SEQUENCE_BITS + WORKER_ID_BITS + DATACENTER_ID_BITS;
private final long workerId;
private final long datacenterId;
private long sequence = 0L;
private long lastTimestamp = –1L;
/**
* ✅ volatile 绝对不能少!防止指令重排导致返回未初始化的对象
*/
private static volatile SnowflakeIdGenerator instance;
/**
* 私有构造:接收 workerId 和 datacenterId
*/
private SnowflakeIdGenerator(long workerId, long datacenterId) {
if (workerId > MAX_WORKER_ID || workerId < 0) {
throw new IllegalArgumentException("workerId 越界");
}
if (datacenterId > MAX_DATACENTER_ID || datacenterId < 0) {
throw new IllegalArgumentException("datacenterId 越界");
}
this.workerId = workerId;
this.datacenterId = datacenterId;
log.info("【SnowflakeIdGenerator】初始化完成, workerId={}, datacenterId={}",
workerId, datacenterId);
}
/**
* ✅ DCL 双重检查锁获取实例
*/
public static SnowflakeIdGenerator getInstance() {
if (instance == null) { // 第一次检查:无锁,高性能
synchronized (SnowflakeIdGenerator.class) { // 加锁
if (instance == null) { // 第二次检查:防止并发
instance = new SnowflakeIdGenerator(1L, 1L);
}
}
}
return instance;
}
/**
* 生成下一个 ID
*/
public synchronized long nextId() {
long timestamp = currentTimestamp();
if (timestamp < lastTimestamp) {
throw new RuntimeException("时钟回拨,拒绝生成 ID");
}
if (timestamp == lastTimestamp) {
sequence = (sequence + 1) & MAX_SEQUENCE;
if (sequence == 0L) {
timestamp = waitNextMillis(lastTimestamp);
}
} else {
sequence = 0L;
}
lastTimestamp = timestamp;
return ((timestamp – START_TIMESTAMP) << TIMESTAMP_SHIFT)
| (datacenterId << DATACENTER_ID_SHIFT)
| (workerId << WORKER_ID_SHIFT)
| sequence;
}
private long waitNextMillis(long lastTimestamp) {
long timestamp = currentTimestamp();
while (timestamp <= lastTimestamp) {
timestamp = currentTimestamp();
}
return timestamp;
}
private long currentTimestamp() {
return System.currentTimeMillis();
}
}
DCL 关键点解读:
| volatile | 禁止 JVM 指令重排 | 可能返回一个 只完成了内存分配但未初始化的对象 |
| 第一次 if (instance == null) | 无锁快速返回 | 每次都进同步块,性能差 |
| 第二次 if (instance == null) | 防止并发重复创建 | 可能创建多个实例 |
4.5 实现四:Spring @Scope("singleton") —— 容器内首选
真实生产中,凡是 Spring 管理的 Bean,默认就是单例。但你得知道底层原理。
package com.dabai.mall.config;
import com.alibaba.nacos.api.config.ConfigService;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.config.ConfigurableBeanFactory;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;
import java.util.HashMap;
import java.util.Map;
/**
* ✅ Spring 容器单例:Nacos 配置中心
* <p>
* 默认 @Component/@Service/@Repository 都是 singleton
* 这里显式标注 @Scope 是为了演示
*
* @author 大白商城技术团队
*/
@Slf4j
@Component
@Scope(value = ConfigurableBeanFactory.SCOPE_SINGLETON)
public class NacosConfigCenter {
/**
* 注入 Nacos 客户端(Spring 自动管理依赖)
*/
@Resource
private ConfigService configService;
/**
* 配置缓存
*/
private final Map<String, String> configCache = new HashMap<>();
/**
* Spring 启动时执行一次
*/
@PostConstruct
public void init() {
log.info("【NacosConfigCenter】初始化开始, instance={}", System.identityHashCode(this));
// 实际项目里会从 Nacos 拉取配置
configCache.put("seckill.enabled", "true");
configCache.put("payment.channel", "alipay");
log.info("【NacosConfigCenter】初始化完成");
}
public String get(String key) {
return configCache.get(key);
}
}
配套单元测试(验证 Spring 单例):
package com.dabai.mall.config;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertEquals;
@SpringBootTest
class NacosConfigCenterTest {
@Autowired
private NacosConfigCenter configCenter1;
@Autowired
private NacosConfigCenter configCenter2;
@Test
void testSingleton() {
// Spring 容器内,多次注入拿到的是同一个实例
assertSame(configCenter1, configCenter2,
"Spring 容器内的 Bean 应该是单例");
System.out.println("configCenter1 hash: " + System.identityHashCode(configCenter1));
System.out.println("configCenter2 hash: " + System.identityHashCode(configCenter2));
}
@Test
void testGetConfig() {
assertEquals("true", configCenter1.get("seckill.enabled"));
}
}
4.6 五种实现的完整单元测试
package com.dabai.mall.singleton;
import com.dabai.mall.config.MallConfigCenter;
import com.dabai.mall.id.SnowflakeIdGenerator;
import com.dabai.mall.util.DateFormatUtil;
import org.junit.jupiter.api.Test;
import java.time.LocalDateTime;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.jupiter.api.Assertions.*;
class SingletonPatternTest {
/**
* 测试 1:枚举单例的全局唯一性
*/
@Test
void testEnumSingleton() {
MallConfigCenter instance1 = MallConfigCenter.INSTANCE;
MallConfigCenter instance2 = MallConfigCenter.INSTANCE;
assertSame(instance1, instance2, "枚举单例必须全局唯一");
// 初始化时间戳也必须一致
assertEquals(instance1.getInitTimestamp(), instance2.getInitTimestamp());
}
/**
* 测试 2:枚举单例的高并发安全
*/
@Test
void testEnumSingletonUnderConcurrency() throws InterruptedException {
int threadCount = 100;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger mismatchCount = new AtomicInteger(0);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
MallConfigCenter instance = MallConfigCenter.INSTANCE;
if (instance.getInitTimestamp() !=
MallConfigCenter.INSTANCE.getInitTimestamp()) {
mismatchCount.incrementAndGet();
}
} finally {
latch.countDown();
}
});
}
latch.await(5, TimeUnit.SECONDS);
executor.shutdown();
assertEquals(0, mismatchCount.get(), "高并发下枚举单例必须唯一");
}
/**
* 测试 3:静态内部类的懒加载
*/
@Test
void testStaticInnerClassSingleton() {
DateFormatUtil instance1 = DateFormatUtil.getInstance();
DateFormatUtil instance2 = DateFormatUtil.getInstance();
assertSame(instance1, instance2, "静态内部类单例必须唯一");
assertNotNull(instance1.format(LocalDateTime.now()));
}
/**
* 测试 4:DCL 单例的并发安全
*/
@Test
void testDCLSingletonUnderConcurrency() throws InterruptedException {
int threadCount = 100;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
ConcurrentHashMap<SnowflakeIdGenerator, Boolean> instanceMap = new ConcurrentHashMap<>();
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
SnowflakeIdGenerator instance = SnowflakeIdGenerator.getInstance();
instanceMap.put(instance, true);
} finally {
latch.countDown();
}
});
}
latch.await(5, TimeUnit.SECONDS);
executor.shutdown();
assertEquals(1, instanceMap.size(),
"DCL 单例在 100 线程并发下必须只产生 1 个实例");
}
/**
* 测试 5:雪花 ID 生成器正确性
*/
@Test
void testSnowflakeIdGenerator() {
SnowflakeIdGenerator generator = SnowflakeIdGenerator.getInstance();
long id1 = generator.nextId();
long id2 = generator.nextId();
assertTrue(id2 > id1, "ID 必须单调递增");
assertTrue(id1 > 0, "ID 必须为正数");
}
}
4.7 五种实现如何选型
| 业务核心配置中心 | 枚举 | 防反射 + 防反序列化,最安全 |
| 工具类(无状态) | 静态内部类 | 懒加载 + 简洁 |
| 分布式 ID 生成器 | DCL | 需要传构造参数(workerId) |
| Spring 管理的 Bean | @Scope("singleton") | Spring 容器内天然单例 |
| 老项目重构 | 枚举 或 DCL | 看是否兼容 |
五、工程决策 Checklist:什么时候用、什么时候别用
5.1 ✅ 这 5 种情况,强烈建议用单例
| ① | 配置中心 | 全局状态必须一致 |
| ② | 连接池 / 线程池 | 重复创建耗资源、易 OOM |
| ③ | 缓存客户端(Redis、Memcached) | 连接复用,避免连接风暴 |
| ④ | 日志对象(Logger) | logback 默认就是单例 |
| ⑤ | 工具类(DateUtil、JsonUtil) | 无状态,全局共享 |
5.2 ❌ 这 5 种情况,绝对不要用单例
| ① | 有状态且状态与请求相关 | 多线程并发修改会出大问题 |
| ② | 持有可变业务数据(如用户上下文) | 内存泄露 + 线程安全翻车 |
| ③ | 每次都需要新实例的对象(如 DTO、VO) | 单例会污染状态 |
| ④ | 有构造参数且参数动态变化 | 单例只能初始化一次 |
| ⑤ | 与 Spring 生命周期强绑定 | 静态单例脱离容器,事务、Mock 全部失效 |
5.3 ⚠️ 单例模式的 6 大常见坑
| ① | 忘记 volatile | DCL 下可能返回半初始化对象 | 加上 volatile |
| ② | 构造方法抛异常 | 类永久不可用 | 异常要在 getInstance 外捕获 |
| ③ | 反序列化破坏 | 反序列化会创建新实例 | 用枚举,或实现 readResolve() |
| ④ | 反射攻击 | setAccessible(true) 创建新实例 | 用枚举,或在构造方法加判断 |
| ⑤ | 类加载器不同 | Tomcat 等容器多 classloader 场景下出现多实例 | 用枚举或 Spring 容器管理 |
| ⑥ | 内存泄露 | 单例持有大对象引用,无法 GC | 慎用 static 引用业务对象 |
5.4 面试官视角:单例模式高频追问
Q1:为什么 DCL 需要 volatile?
答:new 一个对象在 JVM 字节码层面至少 3 步:①分配内存 ②初始化对象 ③赋值引用。没有 volatile 时,JVM 可能指令重排成 ①→③→②,导致其他线程拿到一个未初始化的对象,直接 NPE。
Q2:枚举为什么能防反射和反序列化?
答:JDK 源码里 Constructor.newInstance() 对枚举类型做了硬性检查(if ((clazz.getModifiers() & Modifier.ENUM) != 0) throw new IllegalArgumentException);反序列化时,JDK 走 Enum.valueOf() 而不是 readObject,所以返回的是同一个常量。
Q3:Spring 的单例和设计模式单例有什么区别?
答:Spring 单例是 IoC 容器级别的,不同容器(父子容器)会有不同实例;设计模式单例是 ClassLoader 级别的,全 JVM 唯一。
六、与其他模式协作:单例从来不是一个人在战斗
6.1 单例 + 工厂方法 = 全局唯一的对象工厂
场景:支付中心需要根据支付渠道(支付宝/微信/银联)创建不同的支付客户端,但支付客户端的连接池必须全局唯一。
/**
* 配合工厂方法使用:单例 + 工厂方法
*/
public class PaymentClientFactory {
private PaymentClientFactory() {}
// 三个支付客户端都是单例
private static final class AlipayHolder {
private static final AlipayClient INSTANCE = new AlipayClient("app_id_xxx");
}
private static final class WechatHolder {
private static final WechatClient INSTANCE = new WechatClient("app_id_yyy");
}
public static PaymentClient getClient(String channel) {
return switch (channel) {
case "alipay" -> AlipayHolder.INSTANCE;
case "wechat" -> WechatHolder.INSTANCE;
default -> throw new IllegalArgumentException("未知渠道: " + channel);
};
}
}
6.2 单例 + 外观模式 = 统一门面
场景:下单门面服务需要持有配置中心、日志器、分布式 ID 生成器,这些都是单例。
/**
* 配合外观模式:下单门面
*/
@Service
public class OrderFacade {
// 三个单例协作
@Resource
private MallConfigCenter configCenter; // 单例 1:配置
@Resource
private SnowflakeIdGenerator idGenerator; // 单例 2:ID 生成
private final Logger logger = LoggerFactory.getLogger(OrderFacade.class); // 单例 3:日志
public Order createOrder(OrderRequest request) {
long orderId = idGenerator.nextId();
logger.info("创建订单, orderId={}", orderId);
// 业务逻辑…
return new Order(orderId, request);
}
}
6.3 单例 + 建造者模式 = 复杂对象构造
场景:单例的配置中心持有建造者,用于构造复杂查询条件。
/**
* 配合建造者:单例配置中心 + 复杂对象构造
*/
public class OrderQueryBuilder {
private String orderId;
private String userId;
private LocalDateTime startTime;
private LocalDateTime endTime;
public static OrderQueryBuilder create() {
// 建造者也做成单例入口
return new OrderQueryBuilder();
}
public OrderQueryBuilder orderId(String orderId) {
this.orderId = orderId;
return this;
}
public OrderQueryBuilder userId(String userId) {
this.userId = userId;
return this;
}
public OrderQuery build() {
return new OrderQuery(orderId, userId, startTime, endTime);
}
}
6.4 大白商城模式协作全景图
┌──────────────┐
│ 单例模式 │ ← 本篇
└──────┬───────┘
│
┌───────────┬───────┼───────┬───────────┐
│ │ │ │ │
┌───▼───┐ ┌────▼───┐ ┌▼────┐ ┌▼─────┐ ┌───▼────┐
│工厂方法│ │ 外观 │ │建造者│ │策略 │ │ 享元 │
│(支付) │ │(下单) │ │(订单)│ │(优惠)│ │(SKU) │
└───────┘ └────────┘ └──────┘ └──────┘ └───────┘
单例是大白商城的"地基":几乎所有核心服务(配置、日志、ID、客户端)都是单例,然后才有上层的设计模式去组合。
七、本篇小结 + 下篇预告
7.1 本篇小结(5 个核心要点)
7.2 一句话总结
单例模式不是炫技,是工程必需。配置错了资损,连接池错了 OOM,ID 错了主键冲突——这些血泪教训都浓缩在这 5 行代码里。
7.3 知识脑图
单例模式
├── 五大实现
│ ├── 饿汉式(不推荐)
│ ├── 懒汉式基础版(千万别用)
│ ├── DCL 双重检查锁(兼容老代码)
│ ├── 静态内部类(优雅懒加载)
│ └── 枚举(Joshua Bloch 终极方案)★ 首选
├── Spring 单例
│ ├── @Scope("singleton") 默认
│ ├── 容器级 vs ClassLoader 级
│ └── 父子容器陷阱
├── 工程避坑
│ ├── volatile 不能忘
│ ├── 反射攻击
│ ├── 反序列化破坏
│ └── 内存泄露
└── 模式协作
├── + 工厂方法(支付渠道)
├── + 外观(下单门面)
├── + 建造者(复杂对象)
└── + 享元(共享对象)
7.4 下篇预告
第 03 篇【单例模式 – 源码剖析篇】:JDK / Spring / Spring Cloud Alibaba 中的单例实现
下篇我们会深入源码层面,回答三个问题:
并附完整的源码解读 + 流程图 + 大白商城的"抄作业"实践。
觉得对您有帮助,麻烦点点关注啦,您的关注是我创作的最大动力~ 🎯






