欢迎光临
我们一直在努力

大白话说Java设计模式-02-单例模式(业务实战篇)

大白话说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 个核心要点)

  • 本质:单例 = 全局唯一访问点,私有构造 + 私有静态引用 + 公共静态方法。
  • 首选:枚举(JDK 1.5+),天然防反射、防反序列化、线程安全、代码极简。
  • 容器内:Spring Bean 默认就是单例,@Scope("singleton") 可显式标注。
  • 关键点:DCL 必须用 volatile 防止指令重排。
  • 避坑:单例不能持有请求级别的状态,否则就是埋雷。
  • 7.2 一句话总结

    单例模式不是炫技,是工程必需。配置错了资损,连接池错了 OOM,ID 错了主键冲突——这些血泪教训都浓缩在这 5 行代码里。

    7.3 知识脑图

    单例模式
    ├── 五大实现
    │ ├── 饿汉式(不推荐)
    │ ├── 懒汉式基础版(千万别用)
    │ ├── DCL 双重检查锁(兼容老代码)
    │ ├── 静态内部类(优雅懒加载)
    │ └── 枚举(Joshua Bloch 终极方案)★ 首选
    ├── Spring 单例
    │ ├── @Scope("singleton") 默认
    │ ├── 容器级 vs ClassLoader 级
    │ └── 父子容器陷阱
    ├── 工程避坑
    │ ├── volatile 不能忘
    │ ├── 反射攻击
    │ ├── 反序列化破坏
    │ └── 内存泄露
    └── 模式协作
    ├── + 工厂方法(支付渠道)
    ├── + 外观(下单门面)
    ├── + 建造者(复杂对象)
    └── + 享元(共享对象)

    7.4 下篇预告

    第 03 篇【单例模式 – 源码剖析篇】:JDK / Spring / Spring Cloud Alibaba 中的单例实现

    下篇我们会深入源码层面,回答三个问题:

  • java.lang.Runtime 是怎么用饿汉式单例的?
  • Spring 的 DefaultSingletonBeanRegistry 是如何解决循环依赖的?
  • Nacos 客户端 NamingService 的单例是怎么保证全局唯一的?
  • 并附完整的源码解读 + 流程图 + 大白商城的"抄作业"实践。


    觉得对您有帮助,麻烦点点关注啦,您的关注是我创作的最大动力~ 🎯

    赞(0)
    未经允许不得转载:171主机测评 » 大白话说Java设计模式-02-单例模式(业务实战篇)
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址