前言
这个标题把"C++ 不加入垃圾回收"当成了一个已经确定的设计决定,需要先纠正一下措辞。更准确的表述是:C++ 标准不要求实现提供垃圾回收(garbage collection, GC),也不把 GC 作为语言的核心内存管理机制;但标准并不禁止实现自行提供 GC,开发者也可以链接第三方的回收器。 标准真正保证的是对象的生存期与析构时机,至于底层内存是否由某个回收器顺带回收,只要可观察行为符合标准,实现可以自行决定。
另一个常见误解是把 std::shared_ptr 当成垃圾回收。它是引用计数(reference counting),不是追踪式回收(tracing GC):它能及时释放无人引用的对象,但遇到循环引用就会泄漏,需要 std::weak_ptr 打破环。两者的语义、开销和失败模式都不同。
本文要讲清三件事:C++ 凭什么在没有 GC 的情况下管理内存;标准文本对 GC 到底说了什么;以及为什么主流实现选择不把 GC 放进语言核心。最后会给出在需要时替代 GC 的几种工程手段。
一、RAII 与确定性析构
C++ 的内存管理建立在对象生存期之上,而不是建立在"有没有人再引用它"之上。局部对象在离开作用域时被销毁,析构函数(destructor)一定会执行;即使中途抛出异常,栈展开(stack unwinding)也会保证已构造的局部对象被逐个析构。这套机制叫 RAII(Resource Acquisition Is Initialization,资源获取即初始化)。
cpp
// 适用于 C++17
#include <cstdio>
#include <memory>
#include <mutex>
void write_report(const char* path) {
std::unique_ptr<std::FILE, int (*)(std::FILE*)> file(
std::fopen(path, "w"), &std::fclose);
if (!file) {
return; // 无需手动关闭
}
std::mutex m;
std::lock_guard<std::mutex> lock(m);
std::fputs("report\n", file.get());
// 函数返回或抛异常时,lock 先解锁,file 再关闭
}
这段代码里没有一处手写 delete 或 fclose 的收尾分支。lock_guard 的析构负责解锁,unique_ptr 的删除器负责关闭文件。析构顺序与构造顺序相反,因此锁在文件关闭之前释放。
GC 能回收的只有内存,不能回收文件描述符、互斥量、套接字、数据库连接这些非内存资源,也无法保证"什么时候"回收。而 C++ 的析构函数可以承担任意清理逻辑,这使 RAII 成为比 GC 更通用的资源管理模型。反过来,如果语言引入 GC 并让对象随时可能被回收,析构时机就变得不确定,依赖析构释放非内存资源的代码会立刻失去保证。
确定性是 C++ 的关键卖点之一。一个 std::lock_guard 在作用域结束时必然解锁,这让锁的持有时间可以被静态地推理;如果锁的释放依赖 GC,临界区的长度就取决于回收器何时运行,程序很难分析,也不适合实时系统。
二、标准文本到底怎么规定
C++ 标准从未把 GC 列为必需组件。标准规定的是对象的存储期(storage duration)、生存期(lifetime)以及析构函数何时被调用,实现只要满足这些可观察行为即可。也就是说:
- 实现可以使用 GC 来回收内存,只要不改变程序的可观察行为;
- 实现不必提供 GC;
- 程序不能假设存在 GC,也不能依赖 GC 来保证正确性。
历史上,C++11 起标准中曾保留过一组与"安全派生指针"(safely-derived pointer)相关的可选接口,包括 std::declare_reachable、std::undeclare_reachable、std::declare_no_pointers、std::get_pointer_safety 等,用于支持保守式回收器。这组接口从未被主流实现广泛支持,C++23 已经将其从标准中移除。这段历史说明:标准曾经为 GC 留过一个很窄的门,但门后的东西并没有成为语言的一部分。
标准允许实现采用 GC 的依据是"as-if"规则:只要程序的可观察行为与标准规定一致,实现内部怎么做都行。一个符合标准的编译器完全可以在底层使用回收器,但它不能因此改变析构函数的调用时机,也不能让程序观察到对象被提前回收。这就限制了 GC 在 C++ 中的发挥空间:它只能作为内存分配器的实现细节,不能成为语言层面的对象生命周期管理者。
第三方的保守式回收器(例如 Boehm-Demers-Weiser GC)确实可以与 C++ 一起使用,通常做法是替换全局的 operator new,并显式声明根集合。但这属于库层面的集成,需要处理指针识别、与标准库容器的配合、析构函数的显式调用等问题,不是"打开一个开关"就能得到 Java 那样的体验。具体接口以该库官方文档为准。
三、为什么主流实现不把 GC 放进核心
零开销原则
C++ 的设计原则之一是"你不用的东西不该为你付出代价"(zero-overhead principle)。如果语言强制带 GC,那么所有程序都要承担回收器的运行时、元数据、线程协调和停顿成本,哪怕它们根本不需要。把 GC 做成可选库,不需要的人可以完全不链接它。
与 C 的互操作和 ABI 稳定
C++ 大量代码要与 C 库、操作系统 API、已有二进制接口协作。C 的内存模型是显式的 malloc / free,指针就是地址。追踪式 GC 需要准确识别哪些字是对象引用、哪些只是整数或偏移量;而 C++ 允许把指针转成整数、做指针运算、在联合体中复用存储,这些都会让精确追踪变得困难。保守式回收器把"看起来像指针的字"都当作根,虽然能用,但会保留本可回收的对象,也可能被整数误判而拖住内存。
析构函数承担了太多职责
C++ 对象的析构不只是释放内存。它可能关闭文件、提交事务、解锁、写日志、归还连接池。GC 只关心"这块内存还有没有人引用",不关心这些副作用,也无法决定副作用应该在哪一刻发生。要让 GC 感知析构,就必须引入终结器(finalizer)一类的机制,而终结器的执行时机同样不确定,还会带来对象复活等复杂问题。
性能可预测性
GC 的停顿时间与堆大小、存活对象比例、回收策略相关,很难给出严格上界。实时控制、音频处理、游戏引擎、高频交易等场景要求确定的延迟,一次回收停顿就可能造成可感知的卡顿或错过截止时间。C++ 把内存释放的时机交给程序员和 RAII,换来的是可预测性。
语言复杂度
把 GC 加入核心意味着要定义根集合、安全点、写屏障、弱引用、终结器、与异常和线程的交互等一系列语义。这些语义一旦进入标准,就要在所有实现、所有平台上保持一致,成本极高。而现有的 RAII、智能指针、容器已经能覆盖绝大多数场景,标准委员会没有足够动力承担这笔复杂度。
四、没有 GC 时怎么管理内存
C++ 的答案是分层:默认用值语义和 RAII,需要共享所有权时用引用计数,需要打破环时用弱引用,需要批量回收时用区域分配。
cpp
// 适用于 C++17
#include <memory>
struct Node {
int value = 0;
std::shared_ptr<Node> next; // 共享所有权
std::weak_ptr<Node> parent; // 弱引用,避免父子互相持有形成环
};
void build() {
auto root = std::make_shared<Node>();
root->value = 1;
auto child = std::make_shared<Node>();
child->value = 2;
child->parent = root; // weak_ptr,不增加引用计数
root->next = child; // shared_ptr,增加引用计数
// 离开作用域时局部变量按构造逆序销毁:child 先释放自己的那份引用,
// 随后 root 销毁,root->next 释放 child 的最后一个强引用,child 才被销毁。
// child->parent 是 weak_ptr,不阻止这一过程。
}
shared_ptr 的控制块是线程安全的,引用计数的增减是原子操作;但被指向对象的访问不是线程安全的,多线程读写同一对象仍需同步。shared_ptr 也无法处理环:两个对象互相持有 shared_ptr 时,引用计数永远不归零,内存泄漏。用 weak_ptr 把其中一条边改成弱引用,环就被打破。weak_ptr 需要通过 lock() 提升为 shared_ptr 才能访问对象,提升失败说明对象已销毁。
对于大量短生命周期对象,区域分配(arena)是另一种思路:一次性分配一大块内存,对象在里面顺序构造,最后统一释放,不为每个对象单独调用 new / delete。它在解析器、编译器、游戏帧内临时对象等场景很常见,但标准库没有直接提供通用的 arena,需要自己实现或使用第三方库。
| 机制 | 回收时机 | 能否处理环 | 额外开销 | 适用场景 |
|---|
|------------|----------|-----|------|----------|
| 栈对象 / RAII | 离开作用域,确定 | 不涉及 | 几乎为零 | 绝大多数局部资源 |
|-------------------|-----------|-----|------|-------|
| std::unique_ptr | 所有者销毁时,确定 | 不涉及 | 一个指针 | 独占所有权 |
|-------------------|-----------|--------|------------|-------|
| std::shared_ptr | 最后一个引用销毁时 | 不能,会泄漏 | 控制块 + 原子计数 | 共享所有权 |
|-----------------|-------|-------|----------|------------|
| std::weak_ptr | 不拥有对象 | 用于打破环 | 控制块中的弱计数 | 观察者、缓存、父子环 |
|------|--------|-----|----------|-----------|
| 区域分配 | 整块统一释放 | 不涉及 | 需要自定义分配器 | 批量短生命周期对象 |
|--------|-----------|---|----------------|----------|
| 第三方 GC | 回收器决定,不确定 | 能 | 运行时 + 元数据 + 停顿 | 特定应用,非标准 |
常见坑点
-
❌ 认为
std::shared_ptr就是垃圾回收,用了就不会泄漏;✅ 它只是引用计数,循环引用照样泄漏,必须用std::weak_ptr打破环。 -
❌ 用
shared_ptr管理所有对象,包括本来可以独占的;✅ 默认用unique_ptr或值语义,只在确实需要共享所有权时才用shared_ptr。 -
❌ 以为 C++ 标准禁止任何形式的 GC 实现;✅ 标准不要求 GC,但 as-if 规则允许实现内部使用,只要不改变可观察行为。
-
❌ 依赖 GC 来执行析构逻辑(关文件、解锁);✅ 用 RAII,让析构在确定的作用域边界发生;GC 即使存在也不保证时机。
-
❌ 手动
new之后在异常路径上忘记delete;✅ 用std::unique_ptr/std::make_unique,让栈展开自动清理。 -
❌ 从
shared_ptr的get()取出裸指针再交给另一个shared_ptr构造;✅ 这会产生两个独立控制块,最终 double free;需要共享时直接拷贝shared_ptr。 -
❌ 认为对象被
shared_ptr持有就不会被销毁,因而在析构函数里做必须执行的业务动作;✅ 析构时机取决于引用计数,业务动作应显式调用,不要藏在析构里。 -
❌ 在实时线程里依赖不确定的回收停顿;✅ 用 RAII 和预分配,把释放时机控制在可预测的位置;需要 GC 时评估最坏停顿。
总结
| 问题 | 事实 |
|---|
|---------------|-----------------|
| C++ 标准要求 GC 吗 | 不要求,也从不把它作为核心机制 |
|---------------|----------------------|
| C++ 禁止 GC 实现吗 | 不禁止,as-if 规则下实现可自行选择 |
|--------------|---------------------------|
| 标准里有过 GC 接口吗 | C++11 起有过一组可选接口,C++23 已移除 |
|-----------|-------------------------|
| 没有 GC 靠什么 | RAII、确定性析构、智能指针、容器、区域分配 |
|---------------------|---------------|
| shared_ptr 是 GC 吗 | 不是,是引用计数,环会泄漏 |
|----------|-------------------------|
| 为什么不做成核心 | 零开销、C 互操作、析构语义、可预测性、复杂度 |
C++ 不用 GC,不是因为"回收内存不重要",而是因为它选择了另一条路:把资源释放绑定到对象生存期,用确定性的析构换取可预测的行为和通用的资源管理能力。这条路要求程序员理解所有权,代价是学习曲线;回报是不需要运行时托管,也能精确控制何时释放。需要 GC 的场景并非不能做,只是它属于库和运行时的选择,而不是语言强加给每个程序的默认配置。