# 01 单例（创建型）

> 保证一个类型只有一个实例、并给一个全局访问点。听起来最简单，却是被骂得最惨的模式——本篇讲清它到底解决什么痛、Go 里的 sync.Once 惯用法、以及它为什么常被当成“披着模式外衣的全局变量”。

- 作者：David（道雾轩）
- 专栏：经典设计模式浅析（https://daiw.org/manual/design-patterns.md）
- 最后更新：2026-09-13
- 原文：https://daiw.org/manual/design-patterns/singleton
- 转载与引用：请注明出处并附原文链接（https://daiw.org/about/copyright）

# 01 单例模式 · Singleton（创建型）

我们从最简单的模式开始热身。简单到什么程度呢？一句话就能说完：**让某个类型在整个程序里只有一个实例，并且提供一个统一的地方去拿它。**

但别被“简单”骗了——单例是 23 个模式里**争议最大、被骂得最多**的一个。有人天天用，有人见了就皱眉说“这不就是全局变量化了个妆嘛”。热身归热身，这里面的门道不少。

## 一、这是什么：公司只有一个 CEO

现实里的单例随处可见：

- 一家公司，**CEO 只有一个**。全公司上下要找“最终拍板的人”，指向的都是同一个。你不会说“给我 new 一个新 CEO”。
- 一个国家，**中央政府只有一个**。
- 你电脑上，**打印任务队列**通常只有一个——不然两个队列各排各的，纸就打架了。

这些东西的共同点是：**它天然就该只有一份，而且大家得能方便地找到这一份。** 这就是单例。

## 二、解决什么痛：两个需求捆在一起

单例其实同时回答了两个问题，这两个问题必须一起满足，才轮得到单例出场：

1. **“只能有一个”** —— 如果 new 出好几个日志器、好几个配置管理器，各自维护一份状态，那就乱套了。想象配置管理器有三个实例，A 改了配置 B 看不见，这种 bug 能让人查到怀疑人生。
2. **“到处都要用”** —— 这个唯一实例得有个全局、方便的访问点，不能让调用方费劲地层层传递。

<Callout type="warn">
  **划重点：必须两个条件同时成立。** 如果只是“想全局访问”，那是全局变量的活儿；如果只是“想控制数量”，可能对象池更合适。单例的独特价值在于把“唯一性”和“全局访问”打包保证。很多人滥用单例，恰恰是因为只想要第 2 点(图方便)，却顺手把第 1 点(强唯一)也焊死了——埋雷就是从这儿开始的。
</Callout>

## 三、怎么实现：Go 的 `sync.Once`

在 Go 里，单例的惯用写法干净得几乎不像个“模式”：一个包级变量存实例，一个 `sync.Once` 保证初始化只跑一次。

```go title="go/singleton/main.go（核心）"
var (
	instance *Logger
	once     sync.Once
)

// GetLogger 返回全局唯一的 Logger 实例。
// sync.Once 保证懒加载且并发安全——这是 Go 里实现单例的标准答案。
func GetLogger() *Logger {
	once.Do(func() {
		fmt.Println("(创建 Logger 实例...)")
		instance = &Logger{level: LevelInfo}
	})
	return instance
}
```

`once.Do(f)` 的语义是：无论多少个 goroutine 同时调用 `GetLogger()`，里头那个初始化函数 `f` **保证只执行一次**，其余调用会阻塞到第一次执行完再返回。于是“创建 Logger 实例...”这行，你跑多少遍都只打印一次。

对比一下你可能在别的语言里见过的、著名的“双重检查锁定”(Double-Checked Locking)，就知道 Go 有多省心了：

```java title="Java —— 经典但容易写错的双重检查锁"
class Logger {
    private static volatile Logger instance;   // volatile 一个都不能少
    private Logger() {}
    static Logger getInstance() {
        if (instance == null) {                // 第一次检查(不加锁)
            synchronized (Logger.class) {
                if (instance == null) {        // 第二次检查(加锁后)
                    instance = new Logger();
                }
            }
        }
        return instance;
    }
}
```

这段 Java 代码是无数面试题的重灾区：`volatile` 忘了会因指令重排拿到半初始化对象，两次 `null` 检查的意义各不相同……**Go 用 `sync.Once` 把这一整套心智负担全吃掉了。** 这也是为什么在 Go 社区，单例几乎从不被当成一个“需要专门学”的模式——语言层面已经给好了。

<Callout type="info">
  更 Go 的观点是：很多所谓单例，其实一个**包级变量 + `init()` 函数**(饿汉式，程序启动即创建)就够了，连 `sync.Once` 都省了。只有当初始化开销大、希望“用到才创建”(懒汉式)时，`sync.Once` 才真正上场。
</Callout>

C++ 则在 C++11 之后靠“魔法静态变量”(Meyers' Singleton)达到同样的并发安全：

```cpp title="C++11 —— 函数内 static,标准保证线程安全初始化"
Logger& getLogger() {
    static Logger instance;   // C++11 起,静态局部变量的初始化是线程安全的
    return instance;
}
```

三种语言，同一个痛点，止痛方式一个比一个轻。**语言进化的一个方向，就是把过去要靠“模式”手写的东西，变成语言自带的保证。**

## 四、有哪些坑：它凭什么被骂

单例是“模式病”的头号病灶，几个经典槽点必须拎清：

- **它是伪装的全局状态。** 全局可变状态是万恶之源这句话你肯定听过。单例把全局变量包了层皮，但本质没变：任何地方都能改它，改坏了你很难定位是谁干的。
- **它让单元测试变地狱。** 测试要的是“隔离”和“可替换”。而单例是硬编码的全局引用，你想在测试里塞个假的(mock)进去？难。它把依赖藏在了 `GetLogger()` 这种静态调用背后，而不是老老实实从参数传进来——这叫“隐藏依赖”。
- **“唯一”的假设经常被现实打脸。** 今天你笃定“数据库连接只要一个”，明天要读写分离、要分库，就得有俩。当初焊死的单例，现在得连根拔。

<Callout type="warn">
  **实战建议：优先考虑“依赖注入 + 单一实例”，而不是“单例模式”。** 也就是说——在程序启动处**创建一个**实例，然后把它当普通参数**显式传**给需要的人。这样“只有一个”由你的组装逻辑保证(你就 new 了一次)，而每个使用者拿到的是一个普通对象，能测、能换、依赖关系一目了然。你既得到了“唯一”，又躲开了“全局访问”带来的所有毛病。**单例真正不可替代的场景，比大多数人以为的要少得多。**
</Callout>

## 五、开源里怎么用

抛开争议，单例(以及它的温和替代——包级单一实例)在真实项目里确实无处不在：

- **Go 标准库 `http.DefaultClient` / `http.DefaultServeMux`**：包级的默认单一实例，`http.Get()` 底层用的就是 `DefaultClient`。它没上锁保护(是导出变量)，Go 团队用“约定”而非“强制”来维持其单一性——很 Go 的取舍。
- **Go `database/sql` 的 `*sql.DB`**：官方明确建议一个数据库**全程共享一个 `*sql.DB`**(它内部是连接池)，典型的“进程内单例”用法——注意它单例的是“池”，不是“连接”。
- **Java `java.lang.Runtime`**:`Runtime.getRuntime()` 是 JDK 里教科书级的单例。
- **Spring 框架**：Bean 默认作用域就是 `singleton`——但它是“每个容器一个”，而且由容器统一管理生命周期和注入。**这其实正是上面推荐的“依赖注入 + 单一实例”，Spring 把单例做对了。**

从 `Runtime` 的硬单例，到 Spring 容器管理的软单例，你能清楚看到工程界这些年的态度转变：**“唯一”这件事，越来越倾向于交给外层的组装/容器来保证，而不是让类自己焊死。**

---

热身完毕。下一篇[工厂方法](https://daiw.org/manual/design-patterns/factory-method)，我们进入创建型的正题：当“到底 new 哪个类”本身都会变的时候，怎么办。
