01 单例(创建型)
保证一个类型只有一个实例、并给一个全局访问点。听起来最简单,却是被骂得最惨的模式——本篇讲清它到底解决什么痛、Go 里的 sync.Once 惯用法、以及它为什么常被当成“披着模式外衣的全局变量”。
01 单例模式 · Singleton(创建型)
我们从最简单的模式开始热身。简单到什么程度呢?一句话就能说完:让某个类型在整个程序里只有一个实例,并且提供一个统一的地方去拿它。
但别被“简单”骗了——单例是 23 个模式里争议最大、被骂得最多的一个。有人天天用,有人见了就皱眉说“这不就是全局变量化了个妆嘛”。热身归热身,这里面的门道不少。
一、这是什么:公司只有一个 CEO
现实里的单例随处可见:
- 一家公司,CEO 只有一个。全公司上下要找“最终拍板的人”,指向的都是同一个。你不会说“给我 new 一个新 CEO”。
- 一个国家,中央政府只有一个。
- 你电脑上,打印任务队列通常只有一个——不然两个队列各排各的,纸就打架了。
这些东西的共同点是:它天然就该只有一份,而且大家得能方便地找到这一份。 这就是单例。
二、解决什么痛:两个需求捆在一起
单例其实同时回答了两个问题,这两个问题必须一起满足,才轮得到单例出场:
- “只能有一个” —— 如果 new 出好几个日志器、好几个配置管理器,各自维护一份状态,那就乱套了。想象配置管理器有三个实例,A 改了配置 B 看不见,这种 bug 能让人查到怀疑人生。
- “到处都要用” —— 这个唯一实例得有个全局、方便的访问点,不能让调用方费劲地层层传递。
划重点:必须两个条件同时成立。 如果只是“想全局访问”,那是全局变量的活儿;如果只是“想控制数量”,可能对象池更合适。单例的独特价值在于把“唯一性”和“全局访问”打包保证。很多人滥用单例,恰恰是因为只想要第 2 点(图方便),却顺手把第 1 点(强唯一)也焊死了——埋雷就是从这儿开始的。
三、怎么实现:Go 的 sync.Once
在 Go 里,单例的惯用写法干净得几乎不像个“模式”:一个包级变量存实例,一个 sync.Once 保证初始化只跑一次。
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 有多省心了:
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 社区,单例几乎从不被当成一个“需要专门学”的模式——语言层面已经给好了。
更 Go 的观点是:很多所谓单例,其实一个包级变量 + init() 函数(饿汉式,程序启动即创建)就够了,连 sync.Once 都省了。只有当初始化开销大、希望“用到才创建”(懒汉式)时,sync.Once 才真正上场。
C++ 则在 C++11 之后靠“魔法静态变量”(Meyers' Singleton)达到同样的并发安全:
Logger& getLogger() {
static Logger instance; // C++11 起,静态局部变量的初始化是线程安全的
return instance;
}三种语言,同一个痛点,止痛方式一个比一个轻。语言进化的一个方向,就是把过去要靠“模式”手写的东西,变成语言自带的保证。
四、有哪些坑:它凭什么被骂
单例是“模式病”的头号病灶,几个经典槽点必须拎清:
- 它是伪装的全局状态。 全局可变状态是万恶之源这句话你肯定听过。单例把全局变量包了层皮,但本质没变:任何地方都能改它,改坏了你很难定位是谁干的。
- 它让单元测试变地狱。 测试要的是“隔离”和“可替换”。而单例是硬编码的全局引用,你想在测试里塞个假的(mock)进去?难。它把依赖藏在了
GetLogger()这种静态调用背后,而不是老老实实从参数传进来——这叫“隐藏依赖”。 - “唯一”的假设经常被现实打脸。 今天你笃定“数据库连接只要一个”,明天要读写分离、要分库,就得有俩。当初焊死的单例,现在得连根拔。
实战建议:优先考虑“依赖注入 + 单一实例”,而不是“单例模式”。 也就是说——在程序启动处创建一个实例,然后把它当普通参数显式传给需要的人。这样“只有一个”由你的组装逻辑保证(你就 new 了一次),而每个使用者拿到的是一个普通对象,能测、能换、依赖关系一目了然。你既得到了“唯一”,又躲开了“全局访问”带来的所有毛病。单例真正不可替代的场景,比大多数人以为的要少得多。
五、开源里怎么用
抛开争议,单例(以及它的温和替代——包级单一实例)在真实项目里确实无处不在:
- 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 容器管理的软单例,你能清楚看到工程界这些年的态度转变:“唯一”这件事,越来越倾向于交给外层的组装/容器来保证,而不是让类自己焊死。
热身完毕。下一篇工厂方法,我们进入创建型的正题:当“到底 new 哪个类”本身都会变的时候,怎么办。