单例模式完全指南:从双重检查锁到枚举单例,以及为什么大家都说"少用"

单例模式是 23 个 GoF 设计模式里最简单也最常被误用的一个。它简单到一句话能讲完——"保证一个类只有一个实例,并提供全局访问点";但它被滥用到很多团队代码 review 时会专门列一条"少用单例"。这篇文章把单例的所有实现方式、线程安全细节、坑点、替代方案,以及"什么时候真的该用它"一次讲透。

单例的本质问题:全局唯一

单例要解决的问题极其具体:有一类对象,整个程序运行期间必须只存在一个实例,而且需要在程序的任何地方都能拿到这个实例。典型场景:

  • 配置管理器:程序运行的全局配置,多个实例会导致配置不一致。
  • 日志器:多个 Logger 抢同一个文件会乱序、被截断。
  • 连接池:数据库/Redis 连接池本身就是一个共享资源池。
  • 缓存:进程内的内存缓存,多实例就失去缓存意义。
  • 设备访问:硬件资源(打印机、串口)本来就只有一个。

注意"全局唯一"和"全局访问"是两件事 —— 单例同时承担了这两个职责。这也是它后来被批评"违反单一职责"的根源。

实现 1:最朴素版(线程不安全)

// Java
public class Config {
    private static Config instance;
    private Config() {}                  // 私有构造,外部不能 new

    public static Config getInstance() {
        if (instance == null) {          // 检查 1
            instance = new Config();     // 创建
        }
        return instance;
    }

    private Map<String, String> data = new HashMap<>();
    public String get(String key) { return data.get(key); }
    public void set(String key, String v) { data.put(key, v); }
}

// 使用
Config.getInstance().set("env", "prod");

问题:多线程下,线程 A 和 B 同时调 getInstance,都通过了 instance == null 检查,各自创建一个实例,赋值时谁后赋值谁存活 —— "单例"不再单。这种 bug 极隐蔽:单线程跑没事,压测才出现。

实现 2:加锁(线程安全但慢)

public class Config {
    private static Config instance;
    private Config() {}

    public static synchronized Config getInstance() {
        if (instance == null) {
            instance = new Config();
        }
        return instance;
    }
}

问题:每次调用都要进入锁,但实际上只有第一次创建时需要锁,之后 99.9% 的调用是"读已有实例" —— 加锁让所有读都串行,性能下降明显。

实现 3:双重检查锁(DCL)

解决"只有第一次需要锁"这个问题,经典写法是双重检查锁:

public class Config {
    private static volatile Config instance;     // volatile 关键
    private Config() {}

    public static Config getInstance() {
        if (instance == null) {                  // 第一次检查,不加锁
            synchronized (Config.class) {
                if (instance == null) {          // 第二次检查,加锁后
                    instance = new Config();
                }
            }
        }
        return instance;
    }
}

这里有个魔鬼细节:volatile 不能省。原因在于 instance = new Config() 不是原子操作,它分三步:

  1. 分配内存。
  2. 调用构造函数,初始化对象。
  3. 把对象引用赋给 instance

JVM/CPU 可能对 2、3 重排序变成"先把引用赋值,后初始化对象"。线程 A 完成第 3 步但未完成第 2 步时,线程 B 走到第一次检查,看到 instance != null,直接返回 —— 但拿到的是一个未初始化的对象,使用时会 NPE。volatile 禁止这种重排序,DCL 才真正安全。

实现 4:静态内部类(推荐之一)

利用 Java 类加载机制的天然线程安全:

public class Config {
    private Config() {}

    private static class Holder {
        static final Config INSTANCE = new Config();
    }

    public static Config getInstance() {
        return Holder.INSTANCE;
    }
}

原理:Holder 类只在第一次调用 getInstance 时被 JVM 加载,加载过程由 JVM 保证线程安全。这种写法既懒加载又线程安全,代码还短。

实现 5:枚举(最强力,推荐)

Joshua Bloch 在《Effective Java》里强力推荐的写法:

public enum Config {
    INSTANCE;

    private Map<String, String> data = new HashMap<>();
    public String get(String key) { return data.get(key); }
    public void set(String key, String v) { data.put(key, v); }
}

// 使用
Config.INSTANCE.set("env", "prod");

这种写法天然防御了三大威胁:

  • 反射攻击:Java 反射可以调用私有构造创建新实例,但 JVM 规定不能用反射 newInstance 枚举类型。
  • 反序列化:普通单例反序列化会创建新实例,枚举的反序列化由 JVM 保证返回原实例。
  • 克隆:枚举不能被克隆。

缺点是不能继承(枚举本身就是 final),但工程里继承单例的需求几乎不存在。

各语言里的单例实现

Python:模块即单例

# config.py
_data = {}
def get(key): return _data.get(key)
def set(key, v): _data[key] = v

# 其他文件
import config
config.set('env', 'prod')
print(config.get('env'))     # 'prod'

Python 模块在第一次 import 时执行一次,后续 import 直接拿缓存。这本质就是单例 —— 而且最 Python 的写法。需要类形式时:

class Config:
    _instance = None
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
            cls._instance._data = {}
        return cls._instance

c1 = Config(); c2 = Config()
print(c1 is c2)              # True

# 用元类(更优雅,但显得高深)
class Singleton(type):
    _instances = {}
    def __call__(cls, *args, **kwargs):
        if cls not in cls._instances:
            cls._instances[cls] = super().__call__(*args, **kwargs)
        return cls._instances[cls]

class Config(metaclass=Singleton):
    def __init__(self): self.data = {}

JavaScript / TypeScript:模块缓存 + 闭包

// config.ts —— ES Module 是单例的
const data: Record<string, string> = {};
export const config = {
    get: (k: string) => data[k],
    set: (k: string, v: string) => { data[k] = v; },
};

// 想做成类形式
class Config {
    private static instance: Config;
    private data: Record<string, string> = {};
    private constructor() {}
    static getInstance() {
        if (!Config.instance) Config.instance = new Config();
        return Config.instance;
    }
}

Go:sync.Once

package config

import "sync"

type Config struct { data map[string]string }

var (
    instance *Config
    once     sync.Once
)

func Instance() *Config {
    once.Do(func() {                   // 保证只执行一次,且并发安全
        instance = &Config{ data: map[string]string{} }
    })
    return instance
}

sync.Once 是 Go 实现单例的标准方式 —— 比双重检查锁清晰得多,且无需考虑内存模型细节。

单例的真实问题:为什么大家都说"少用"

单例的几个深层缺陷,让它从"模式"变成"反模式"的速度,在所有 23 个模式里数一数二:

1. 隐藏依赖,违反"依赖显式"原则

// 看 OrderService.placeOrder 的签名,只知道它需要 order
public class OrderService {
    public void placeOrder(Order order) {
        Config.getInstance().get("rate");           // 隐藏依赖!
        Logger.getInstance().info("order placed");  // 又一个隐藏依赖
        // ... 真实业务
    }
}

// 改造:把依赖通过构造函数传进来,看签名就知道它依赖什么
public class OrderService {
    private final Config config;
    private final Logger logger;
    public OrderService(Config config, Logger logger) { ... }
}

这就是依赖注入(DI)取代单例的核心动机:单例像全局变量,任何代码都能偷偷依赖它。DI 强迫你在构造时声明所有依赖,代码的依赖关系一目了然。

2. 测试困难

测试 OrderService 时,要 mock Config:

// 单例版:Config 是私有构造,Logger 是私有构造,你 mock 不了
// 只能在测试里改 Config.getInstance() 内部的 instance 字段(用反射)
// 或者 PowerMock 这种重型工具

// DI 版:测试随便传 mock
OrderService svc = new OrderService(mockConfig, mockLogger);

当一个团队全靠单例时,单元测试要么写不出来,要么测试间互相干扰(测试 A 改了 Config.instance,测试 B 跑时 Config 状态混乱)。

3. 全局状态导致并发风险

单例提供"全局对象",意味着所有线程都在共享这一份状态。一旦它有可变字段,你就要为每个字段考虑线程安全。漏掉一个就是数据竞争。

4. 类设计被绑死

类决定了它是单例,所有调用者都被绑定到"它只能有一个"。哪天业务变了想要两个不同配置(测试环境 + 生产环境隔离),单例的代码要大改。如果是普通类,new 两个对象的事,新建一行代码搞定。

什么时候真的该用单例

排除上面那些坑后,单例真正合适的场景缩到很小:

  • 对象是无状态的(或状态完全不可变),线程安全和测试问题不复存在。例:工具方法集合、不可变常量集合。
  • 对象代表真实的、物理上唯一的资源,如系统时钟、当前进程标识、JVM 唯一的 GC 实例。
  • 对象的创建是极其昂贵的,且程序整个生命周期都需要它。例:加载几百 MB 数据的机器学习模型推理器。

而工程上更推荐的替代方案是"DI 容器 + Scope=Singleton":对象本身不知道自己是单例,由 IoC 容器决定它在容器里只有一个实例。Spring、Guice、ASP.NET Core 的 DI 都支持这种声明式单例。

// Spring
@Component
public class Config { ... }   // 默认就是 singleton scope

@Service
public class OrderService {
    private final Config config;
    public OrderService(Config config) { this.config = config; }  // 容器注入
}

// .NET Core
services.AddSingleton<IConfig, Config>();
public OrderService(IConfig config) { ... }

这种写法既享受了"全局唯一"的好处,又保留了"依赖显式 + 可测试"的优点,是单例模式现代最优的表达形式。

常见坑与排查

坑 1:饿汉 vs 懒汉的取舍。 饿汉式(类加载时就创建)简单且线程安全,但程序启动时间可能被拉长;懒汉式(第一次访问时创建)启动快,但首次访问可能 spike。一般原则:启动期间一定会用到的,饿汉;不确定何时用、且启动敏感的,懒汉。

坑 2:子类化破坏单例。 单例类如果非 final,子类可以绕过私有构造(通过反射或直接调子类的构造)。要彻底封死,类标 final、构造私有、且枚举或静态内部类形式。

坑 3:多 ClassLoader 环境下"伪单例"。 Web 容器里多 app 用不同 ClassLoader 加载同一类,会得到多个 instance —— 单例只在一个 ClassLoader 内成立。Tomcat / OSGi 这类环境要特别留意。

坑 4:序列化反序列化破坏单例。 普通可序列化单例反序列化时会调用构造创建新对象。修复:实现 readResolve() 返回 instance,或者用枚举(自动免疫)。

各语言单例的几个补充细节

C++:Meyers Singleton

// 局部静态变量在 C++11 后,初始化是线程安全的
class Config {
public:
    static Config& instance() {
        static Config inst;       // 第一次进入函数时初始化,线程安全
        return inst;
    }
private:
    Config() = default;
    Config(const Config&) = delete;
    Config& operator=(const Config&) = delete;
};

// 使用
Config::instance().set("env", "prod");

这是 Scott Meyers 推荐的 C++ 单例写法,简洁且天然线程安全。注意 = delete 显式禁用拷贝和赋值,防止"复制单例"。

Swift:dispatch_once 或 static let

// 现代 Swift 推荐写法
class Config {
    static let shared = Config()    // static let 保证只初始化一次,线程安全
    private init() {}

    var data: [String: String] = [:]
}

Config.shared.data["env"] = "prod"

Rust:OnceCell / OnceLock

use std::sync::OnceLock;

struct Config { data: std::collections::HashMap<String, String> }

static CONFIG: OnceLock<Config> = OnceLock::new();

fn config() -> &'static Config {
    CONFIG.get_or_init(|| Config { data: HashMap::new() })
}

// 之后:config().data.get("env")

Rust 在标准库 std::sync 里提供了 OnceLock(同步)和 LazyLock(更高级),都是"全局唯一懒加载"的最佳实践。配合 Rust 的所有权 + 借用规则,这种全局单例比其他语言更安全 —— 编译器会确保你不会在持有可变借用时还能从外部访问。

测试里如何替换单例

遗留代码用了大量单例,新写测试时怎么办?几种实用做法:

// 1. 留一个"测试钩子":单例提供 setInstance 方法(只在测试代码可见)
public class Logger {
    private static Logger instance;
    public static Logger getInstance() {
        if (instance == null) instance = new Logger();
        return instance;
    }
    // 仅供测试,通过 package-private 限制范围
    static void setInstanceForTest(Logger mock) { instance = mock; }
}

// 测试里
@Before public void setup() { Logger.setInstanceForTest(mockLogger); }
@After  public void teardown() { Logger.setInstanceForTest(null); }

// 2. 用反射粗暴替换(不推荐但有效)
Field f = Logger.class.getDeclaredField("instance");
f.setAccessible(true);
f.set(null, mockLogger);

// 3. 重构:把单例改成接口 + DI,逐步替换

这三种里前两种是"治标",治本还是慢慢把单例换掉。每次新写功能,要求新代码"不依赖任何单例,只用注入"—— 时间久了单例使用面会自然萎缩。

"假单例"的常见出现场景

有些代码看着不是单例,但本质是 —— 它们更隐蔽:

  • 静态字段直接持有状态:public static List<User> cache = ...。这就是无遮拦的"假单例",所有上文的问题它都有。
  • 线程局部存储(ThreadLocal):技术上不是单例,但表现像"每个线程的单例"。常被滥用做"伪上下文",同样隐藏依赖,且易泄漏(线程池里 ThreadLocal 不清会污染下个任务)。
  • 静态工具类:全部静态方法的工具类,虽然没有 instance,但和单例同样违反"显式依赖"原则,同样难以替换和 mock。

下次评审代码看到任何"全局可访问、状态不清"的东西,都要警惕 —— 它本质就是单例,只是名字不叫单例。

写在最后

单例模式的"简单"是表象,把它写对、用对要踩过不少坑。如果非要用单例,优先选 枚举 / 静态内部类 / sync.Once 这类既安全又简洁的实现;但更值得思考的是:它真的需要是单例,还是只是被你写成了单例? 大多数情况答案是后者 —— 那就把它做成普通类,通过 DI 容器声明 "singleton scope",既得其便又不沾其害。

给设计模式学习者的最后一条建议:不要被"单例最简单"骗到先学它然后到处用。设计模式不是越多越好,单例是少数"越少越好"的模式之一。读完这篇,你下次写 getInstance 之前,先在心里问一句"我真的需要它是全局唯一吗"—— 这一秒钟的停顿,能省你未来几小时的调试和重构。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

单元测试与 TDD 完全指南:从 Red-Green-Refactor 到测试金字塔

2026-5-15 11:21:10

技术教程

工厂方法模式完全指南:从硬编码 new 到开闭原则的优雅实现

2026-5-15 11:35:34

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索