系列定位:软考软件设计师 / 系统架构设计师 --- 创建型模式专题第 3 讲
考察分值:上午题 1-2 分,下午题常作为代码填空或类图识别出现
难度等级:⭐⭐⭐☆☆(概念简单,但线程安全与实现细节陷阱极多)
一、考纲定位与模式定义
1.1 考纲要求
单例模式在软考中属于 创建型模式 的基础内容。考察形式包括:
-
上午选择题:判断场景描述所属模式;识别饿汉式、懒汉式、双重检查锁的实现差异;判断线程安全性
-
下午设计题 :补全单例类的私有构造器、静态实例字段、
getInstance()方法;识别类图中单例角色的典型特征(私有构造器 + 静态获取方法)
1.2 模式定义
单例模式:确保某一个类只有一个实例,并提供一个全局访问点来访问这个唯一实例。
核心意图 :控制实例化过程,让类自身负责保存它的唯一实例,并阻止外部通过 new 随意创建。
二、UML 类图与角色划分
┌─────────────────────────────────────────────┐
│ Singleton │
│ - instance: Singleton │
│ - Singleton() │
│ + getInstance(): Singleton │
│ + businessMethod() │
└─────────────────────────────────────────────┘
表格
| 角色 | 职责 | 软考填空关键词 |
|---|---|---|
| Singleton(单例类) | 持有唯一实例,提供全局访问点 | private static 实例字段 |
| 私有构造器 | 阻止外部 new 创建 |
private Singleton() |
| 静态获取方法 | 返回唯一实例,控制实例化时机 | public static getInstance() |
类图识别要点 :软考类图中,单例类的构造器是
private,且包含一个指向自身的private static字段,以及一个public static的getInstance()方法。
三、场景一:饿汉式单例(类加载即初始化,线程安全)
业务背景 :系统配置管理器 AppConfig,在应用启动时就需要加载配置文件,且整个生命周期只需一个实例。
说明:饿汉式的特点是 类加载时就完成实例化,借助 JVM 类加载机制保证线程安全,实现最简单,但可能造成资源浪费(如果实例从未被使用)。
3.1 代码实现
java
public class AppConfig {
// 1. 私有静态实例:类加载时立即初始化
private static final AppConfig instance = new AppConfig();
// 2. 私有构造器:阻止外部 new
private AppConfig() {
System.out.println("AppConfig 初始化,加载 application.properties");
}
// 3. 公共静态获取方法
public static AppConfig getInstance() {
return instance;
}
// 业务方法
public String getProperty(String key) {
return "value-of-" + key;
}
}
// 客户端
public class Client {
public static void main(String[] args) {
AppConfig config1 = AppConfig.getInstance();
AppConfig config2 = AppConfig.getInstance();
System.out.println(config1 == config2); // true,同一实例
System.out.println(config1.getProperty("db.url"));
}
}
特点总结:
-
✅ 线程安全(JVM 类加载机制保证)
-
✅ 实现简单,无同步开销
-
❌ 类加载即初始化,无法延迟加载
-
❌ 如果实例创建开销大且很少使用,浪费资源
四、场景二:懒汉式单例(延迟加载,非线程安全)
业务背景 :数据库连接池管理器 ConnectionPool,只有在第一次真正需要连接时才初始化,避免启动时占用资源。
说明:懒汉式的特点是 第一次调用 getInstance() 时才创建实例,实现了延迟加载,但基础版本存在线程安全问题。
4.1 代码实现(基础版,有线程安全问题)
java
public class ConnectionPool {
// 1. 私有静态实例:先不初始化
private static ConnectionPool instance;
// 2. 私有构造器
private ConnectionPool() {
System.out.println("初始化数据库连接池...");
}
// 3. 公共静态获取方法(非线程安全!)
public static ConnectionPool getInstance() {
if (instance == null) { // 判断 1
instance = new ConnectionPool(); // 创建 2
}
return instance;
}
public void getConnection() {
System.out.println("获取数据库连接");
}
}
4.2 线程安全问题分析
当两个线程同时执行 getInstance() 时:
| 时间线 | 线程 A | 线程 B | instance 状态 |
|---|---|---|---|
| T1 | 判断 instance == null ✅ |
null | |
| T2 | 判断 instance == null ✅ |
null | |
| T3 | 执行 new ConnectionPool() |
null → 对象 A | |
| T4 | 执行 new ConnectionPool() |
对象 A → 对象 B |
结果 :两个线程各自创建了一个实例,违反了单例原则。这是软考 高频考点。
五、场景三:双重检查锁(DCL,延迟加载 + 线程安全)
业务背景 :高并发场景下的缓存管理器 CacheManager,需要延迟加载且保证线程安全,同时尽量减少同步开销。
说明:双重检查锁(Double-Checked Locking)是懒汉式的线程安全优化版,通过 两次判空 + 同步块 实现,既保证线程安全,又避免每次调用都加锁。
5.1 代码实现
java
public class CacheManager {
// 1. 私有静态实例:volatile 防止指令重排序
private static volatile CacheManager instance;
// 2. 私有构造器
private CacheManager() {
System.out.println("初始化缓存管理器...");
}
// 3. 双重检查锁
public static CacheManager getInstance() {
// 第一次检查:避免不必要的同步
if (instance == null) {
// 同步块:保证线程安全
synchronized (CacheManager.class) {
// 第二次检查:防止多个线程穿透后重复创建
if (instance == null) {
instance = new CacheManager();
}
}
}
return instance;
}
public void put(String key, Object value) {
System.out.println("缓存写入: " + key);
}
}
// 客户端
public class Client {
public static void main(String[] args) {
CacheManager cache1 = CacheManager.getInstance();
CacheManager cache2 = CacheManager.getInstance();
System.out.println(cache1 == cache2); // true
cache1.put("user:1001", "张三");
}
}
5.2 为什么必须用 volatile?
instance = new CacheManager() 在 JVM 中并非原子操作,可能拆分为:
-
分配内存空间
-
初始化对象(执行构造器)
-
将引用指向内存地址
如果发生 指令重排序 (2 和 3 交换),其他线程可能在对象未完全初始化时就拿到引用,导致空指针异常。volatile 关键字禁止指令重排序,保证 happens-before 关系。
软考要点 :如果代码填空考双重检查锁,实例字段必须加
volatile,否则存在隐患。
六、场景四:静态内部类(延迟加载 + 线程安全,推荐写法)
业务背景 :日志管理器 LogManager,要求延迟加载、线程安全,且实现简洁优雅。
说明:静态内部类方式是 Java 中 最推荐的单例实现 之一。利用 JVM 的类加载机制:外部类加载时不会加载内部类,只有调用 getInstance() 时才触发内部类加载,从而完成实例化。
6.1 代码实现
java
public class LogManager {
// 1. 私有构造器
private LogManager() {
System.out.println("初始化日志管理器...");
}
// 2. 静态内部类:持有唯一实例
private static class SingletonHolder {
private static final LogManager INSTANCE = new LogManager();
}
// 3. 公共静态获取方法
public static LogManager getInstance() {
return SingletonHolder.INSTANCE;
}
public void log(String message) {
System.out.println("[LOG] " + message);
}
}
// 客户端
public class Client {
public static void main(String[] args) {
LogManager log = LogManager.getInstance();
log.log("系统启动成功");
}
}
特点总结:
-
✅ 延迟加载(第一次调用
getInstance()时才加载内部类) -
✅ 线程安全(JVM 类加载机制保证)
-
✅ 无同步开销(不需要
synchronized) -
✅ 实现简洁,无
volatile等复杂关键字
七、场景五:枚举单例(最简洁、防反射、防序列化破坏)
业务背景 :系统全局计数器 GlobalCounter,要求绝对单例,且防止通过反射或序列化破坏。
说明:枚举方式是 Java 中 最安全的单例实现,由 Joshua Bloch(《Effective Java》作者)推荐。它天然防止反射攻击和序列化破坏。
7.1 代码实现
java
public enum GlobalCounter {
INSTANCE; // 唯一枚举常量,就是单例实例
private int count = 0;
public void increment() {
count++;
System.out.println("当前计数: " + count);
}
public int getCount() {
return count;
}
}
// 客户端
public class Client {
public static void main(String[] args) {
GlobalCounter counter = GlobalCounter.INSTANCE;
counter.increment(); // 当前计数: 1
counter.increment(); // 当前计数: 2
GlobalCounter another = GlobalCounter.INSTANCE;
System.out.println(counter == another); // true
}
}
为什么枚举最安全?
-
防反射:枚举的构造器由 JVM 控制,反射无法调用
-
防序列化 :枚举的序列化机制只输出名称,反序列化时直接返回
INSTANCE常量,不会创建新对象 -
线程安全:枚举实例的创建由 JVM 保证线程安全
软考注意:软考代码题通常用 Java 标准写法(饿汉/懒汉/DCL),枚举方式在选择题中可能作为"最佳实践"选项出现。
八、五种实现方式对比
| 维度 | 饿汉式 | 懒汉式(基础) | 双重检查锁 | 静态内部类 | 枚举 |
|---|---|---|---|---|---|
| 初始化时机 | 类加载时 | 第一次调用 | 第一次调用 | 第一次调用 | 类加载时 |
| 线程安全 | ✅ 安全 | ❌ 不安全 | ✅ 安全 | ✅ 安全 | ✅ 安全 |
| 性能 | 高(无锁) | 高(无锁但不安全) | 高(细粒度锁) | 高(无锁) | 高 |
| 实现复杂度 | 简单 | 简单 | 较复杂(需 volatile) |
简单 | 最简单 |
| 延迟加载 | ❌ 否 | ✅ 是 | ✅ 是 | ✅ 是 | ❌ 否 |
| 防反射/序列化 | ❌ 否 | ❌ 否 | ❌ 否 | ❌ 否 | ✅ 是 |
| 软考出现频率 | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐ |
九、软考高频考点与易混淆辨析
9.1 高频考点
| 考点 | 内容 |
|---|---|
| 模式分类 | 创建型模式(GoF 23 正式成员) |
| 核心三要素 | ① 私有构造器 ② 私有静态实例 ③ 公共静态获取方法 |
| 线程安全判断 | 饿汉式安全(类加载机制);懒汉式不安全;DCL 安全(需 volatile);静态内部类安全 |
DCL 的 volatile |
必须加,防止 new 操作的指令重排序导致半初始化对象暴露 |
| 适用场景 | 全局配置、连接池、缓存、日志、计数器等需要唯一实例的场景 |
| 与工厂模式区别 | 单例控制"实例数量"(只有一个);工厂控制"创建逻辑"(创建哪种) |
9.2 易混淆辨析:单例 vs 简单工厂中的 "唯一实例"
| 对比项 | 单例模式 | 简单工厂返回单例 |
|---|---|---|
| 核心目的 | 保证一个类只有一个实例 | 工厂内部可能缓存实例,但目的是创建对象 |
| 实现方式 | 私有构造器 + 静态获取方法 | 工厂类内判断后返回 new 或缓存实例 |
| 客户端调用 | Singleton.getInstance() |
Factory.createProduct() |
| 是否 GoF 模式 | ✅ 是 | 简单工厂 ❌ 不是 GoF 模式 |
关键区分 :如果类图里只有一个类,构造器私有,有
getInstance()→ 单例。如果类图里有工厂类、产品接口、多个产品实现 → 工厂模式。
十、真题风格模拟与代码填空
模拟题 1(上午选择题)
以下关于单例模式的叙述中,正确的是()。
A. 单例模式属于结构型模式
B. 单例模式通过公共构造器确保只有一个实例
C. 双重检查锁实现单例时,实例字段必须声明为
volatile以保证线程安全D. 懒汉式单例在类加载时就完成了实例化
答案:C
解析:
-
A 错误:单例属于 创建型 模式,不是结构型。
-
B 错误:单例通过 私有构造器 阻止外部创建,不是公共构造器。
-
C 正确:
volatile防止new操作的指令重排序,是 DCL 的必要条件。 -
D 错误:懒汉式是 第一次调用 时才初始化,饿汉式才是类加载时初始化。
模拟题 2(下午代码填空)
某系统需要实现全局唯一的配置管理器
ConfigManager,使用单例模式实现。请补全(1)~(4)。
java
public class ConfigManager {
// 饿汉式:类加载时初始化
private (1)______ ConfigManager instance = (2)______;
// 私有构造器
(3)______ ConfigManager() {
System.out.println("配置管理器初始化");
}
// 全局访问点
public (4)______ ConfigManager getInstance() {
return instance;
}
public String getConfig(String key) {
return "config-" + key;
}
}
答案:
-
(1)
static final -
(2)
new ConfigManager() -
(3)
private -
(4)
static
阅卷要点 :(1) 必须是
static(静态字段),饿汉式通常加final;(3) 必须是private,这是单例的核心特征;(4) 必须是static,客户端通过类名调用。
模拟题 3(下午代码填空 --- 双重检查锁)
请补全以下双重检查锁单例的(1)~(3)。
java
public class TaskScheduler {
private (1)______ TaskScheduler instance;
private TaskScheduler() {}
public static TaskScheduler getInstance() {
if (instance == null) {
synchronized (TaskScheduler.class) {
if ((2)______) {
instance = (3)______;
}
}
}
return instance;
}
}
答案:
-
(1)
static volatile -
(2)
instance == null -
(3)
new TaskScheduler()
阅卷要点 :(1) 必须同时有
static和volatile,缺一不可。如果只写static会扣分,因为缺少volatile无法防止指令重排序。
十一、常见陷阱与注意事项
陷阱 1:误认为单例是结构型模式
单例属于 创建型 模式,因为它控制对象的创建过程。软考上午题如果问"以下属于结构型模式的是",选项里出现"单例" 不能选。
陷阱 2:懒汉式不加同步就直接用
懒汉式的 if (instance == null) { instance = new Xxx(); } 在多线程环境下 一定不安全。如果题目要求"线程安全",必须写 DCL 或静态内部类。
陷阱 3:DCL 忘记加 volatile
这是软考 最常考的填空陷阱 。DCL 中实例字段必须声明为 private static volatile Xxx instance,否则存在指令重排序隐患。阅卷时缺少 volatile 通常直接判错。
陷阱 4:构造器权限写错
单例的构造器必须是 private。如果写成 public 或 protected,外部可以直接 new,单例就被破坏了。软考代码填空里这是送分点,但也是失分点。
陷阱 5:与 "静态类" 混淆
有人用全静态方法的类(如 public class Utils { public static void doXxx() {} })来替代单例,但这不是单例模式。单例模式的核心是 对象级别的唯一性,可以继承、可以实现接口、可以参与多态;静态类只是工具方法的集合。
陷阱 6:单例的序列化破坏
如果单例类实现了 Serializable,反序列化时会通过反射创建新实例,破坏单例。需要在类中重写 readResolve() 方法:
java
protected Object readResolve() {
return getInstance(); // 返回唯一实例
}
软考中此知识点出现频率较低,但选择题可能作为干扰项出现。
十二、总结
| 要点 | 内容 |
|---|---|
| 定义 | 确保一个类只有一个实例,并提供全局访问点 |
| 分类 | 创建型模式(GoF 23 正式成员) |
| 核心三要素 | ① private 构造器 ② private static 实例 ③ public static getInstance() |
| 饿汉式 | 类加载时初始化,线程安全,无延迟加载 |
| 懒汉式(基础) | 延迟加载,非线程安全 |
| 双重检查锁 | 延迟加载 + 线程安全,必须用 volatile |
| 静态内部类 | 延迟加载 + 线程安全,无锁开销,最推荐 |
| 枚举 | 最简洁、最安全,防反射和序列化破坏 |
| 软考重点 | 代码填空补全三要素;判断线程安全性;DCL 中 volatile 的作用 |
| 答题技巧 | 看到"只有一个实例"、"全局访问点" → 单例;看到 private 构造器 + getInstance() → 确认单例 |
系列预告 :下一篇将讲 建造者模式 ------ 当对象的构造过程复杂、参数众多时,如何用"导演 + 建造者"的分工协作,一步步组装出复杂对象。咱们下回见。