关于C++为什么不加入垃圾回收机制解析

前言

这个标题把"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 | 回收器决定,不确定 | 能 | 运行时 + 元数据 + 停顿 | 特定应用,非标准 |

常见坑点

  1. ❌ 认为 std::shared_ptr 就是垃圾回收,用了就不会泄漏;✅ 它只是引用计数,循环引用照样泄漏,必须用 std::weak_ptr 打破环。

  2. ❌ 用 shared_ptr 管理所有对象,包括本来可以独占的;✅ 默认用 unique_ptr 或值语义,只在确实需要共享所有权时才用 shared_ptr。

  3. ❌ 以为 C++ 标准禁止任何形式的 GC 实现;✅ 标准不要求 GC,但 as-if 规则允许实现内部使用,只要不改变可观察行为。

  4. ❌ 依赖 GC 来执行析构逻辑(关文件、解锁);✅ 用 RAII,让析构在确定的作用域边界发生;GC 即使存在也不保证时机。

  5. ❌ 手动 new 之后在异常路径上忘记 delete;✅ 用 std::unique_ptr / std::make_unique,让栈展开自动清理。

  6. ❌ 从 shared_ptr 的 get() 取出裸指针再交给另一个 shared_ptr 构造;✅ 这会产生两个独立控制块,最终 double free;需要共享时直接拷贝 shared_ptr。

  7. ❌ 认为对象被 shared_ptr 持有就不会被销毁,因而在析构函数里做必须执行的业务动作;✅ 析构时机取决于引用计数,业务动作应显式调用,不要藏在析构里。

  8. ❌ 在实时线程里依赖不确定的回收停顿;✅ 用 RAII 和预分配,把释放时机控制在可预测的位置;需要 GC 时评估最坏停顿。

总结

问题 事实

|---------------|-----------------|
| C++ 标准要求 GC 吗 | 不要求,也从不把它作为核心机制 |

|---------------|----------------------|
| C++ 禁止 GC 实现吗 | 不禁止,as-if 规则下实现可自行选择 |

|--------------|---------------------------|
| 标准里有过 GC 接口吗 | C++11 起有过一组可选接口,C++23 已移除 |

|-----------|-------------------------|
| 没有 GC 靠什么 | RAII、确定性析构、智能指针、容器、区域分配 |

|---------------------|---------------|
| shared_ptr 是 GC 吗 | 不是,是引用计数,环会泄漏 |

|----------|-------------------------|
| 为什么不做成核心 | 零开销、C 互操作、析构语义、可预测性、复杂度 |

C++ 不用 GC,不是因为"回收内存不重要",而是因为它选择了另一条路:把资源释放绑定到对象生存期,用确定性的析构换取可预测的行为和通用的资源管理能力。这条路要求程序员理解所有权,代价是学习曲线;回报是不需要运行时托管,也能精确控制何时释放。需要 GC 的场景并非不能做,只是它属于库和运行时的选择,而不是语言强加给每个程序的默认配置。

相关推荐
曦樂~10 小时前
Cpp面筋
c语言·c++
道尔柯南10 小时前
C++ string 类详解:从常用接口到深拷贝,彻底掌握字符串处理
android·java·c++
来让爷抱一个11 小时前
Python 异步里混用同步库,事件循环被卡住的全过程
开发语言·python
十二13 小时前
【洛谷题解】P1083 [NOIP 2012 提高组] 借教室(二分答案 + 差分)
数据结构·c++·算法·蓝桥杯
心祭丫丫14 小时前
【每天一个 Python 小案例17】Python 实现通讯录
开发语言·人工智能·笔记·python·学习
露天赏雪14 小时前
第3篇:Sentinel熔断降级实战:大模型接口抖动时的服务兜底方案
java·开发语言·人工智能·spring boot·spring·servlet·sentinel
D e re k ZH15 小时前
C++ 从零入门(一):基础语法与程序结构
数据结构·c++·算法
yeccheng17 小时前
MySQL 索引超详解:从底层 B+ 树到面试高频考点
java·开发语言·jvm·mysql·面试
联犀17 小时前
纯 Go 的 CAD 开源库 go-cad:直接解析 DWG/DXF,现在也能导出 PDF
开发语言·golang·pdf