01 单例(创建型)

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

作者 David更新于

01 单例模式 · Singleton(创建型)

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

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

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

现实里的单例随处可见:

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

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

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

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

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

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

三、怎么实现:Go 的 sync.Once

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

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 —— 经典但容易写错的双重检查锁
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)达到同样的并发安全:

C++11 —— 函数内 static,标准保证线程安全初始化
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 哪个类”本身都会变的时候,怎么办。

本页目录