从 手动管理 到 智能回收,跨越半个多世纪的内存管理技术演进全景图
垃圾回收从 1958 年 Lisp 首次提出概念,到如今 ZGC 实现亚毫秒级暂停,走过了六十余年的进化之路。
在 GC 概念出现之前,程序员需要亲自管理每一块内存的分配和释放。这给了我们最大的控制权,也埋下了最多的 Bug。
// C: malloc / free
int* arr = (int*)malloc(100 * sizeof(int));
free(arr); // 忘记 free → 内存泄漏
arr = NULL; // 防止悬空指针
// C++: new / delete + RAII
auto ptr = std::make_unique<int>(42); // 离开作用域自动释放
完全控制内存生命周期,零 GC 开销,适合系统编程、游戏引擎、嵌入式等对性能极致敏感的场景。
内存泄漏(忘记释放)、悬空指针(释放后仍使用)、双重释放(重复释放同一块内存)、缓冲区溢出。70% 的 CVE 安全漏洞与内存有关。
C++11 引入 unique_ptr(独占所有权)、shared_ptr(引用计数)、weak_ptr(弱引用,解决循环引用),通过 RAII(资源获取即初始化)让析构函数自动释放资源。
引用计数是最直观的自动内存管理方式——每个对象记录自己被引用的次数,计数归零时立即回收。这是增量式、实时的回收方案。
refcount = 1
refcount += 1
refcount -= 1
if refcount == 0 → free
每个 PyObject 都有 ob_refcnt 字段。赋值、传参、容器操作都触发引用计数变化。配合分代循环检测器解决循环引用。
早期 MRC 手动调用 retain/release/autorelease。ARC 由编译器自动插入引用计数代码。weak 引用打破循环。
shared_ptr 内部维护控制块(引用计数 + 弱引用计数)。weak_ptr 不增加引用计数,用于打破循环。
这是 GC 理论的三大经典支柱。它们用"追踪可达性"替代"计数",从根本上解决了循环引用问题。
从根集合(GC Roots)出发,递归遍历所有可达对象并标记,然后线性扫描堆,清除未标记的对象。
| 维度 | 标记-清除 | 标记-整理 | 复制回收 |
|---|---|---|---|
| 原理 | 标记可达对象,清除其余 | 标记后移动存活对象到一端 | 存活对象复制到新空间 |
| 碎片问题 | ❌ 有碎片 | ✅ 无碎片 | ✅ 无碎片 |
| 内存开销 | ✅ 无额外开销 | ✅ 无额外开销 | ❌ 需 2× 内存 |
| 分配速度 | 较慢(需搜索空闲块) | 快速(指针碰撞) | 极快(指针碰撞) |
| 移动对象 | 不移动 | 移动 | 复制+移动 |
| 时间复杂度 | O(存活+堆大小) | O(存活+堆大小) | O(存活对象) |
分代回收基于两个核心假说:弱分代假说(大多数对象朝生夕死)和强分代假说(越老的对象越不容易死)。它将堆划分为多代,针对不同代使用不同回收策略。
单线程 GC,简单但停顿长。适合客户端小堆场景(<100MB)。
多线程并行 GC,吞吐量优先。适合批处理、科学计算场景。
并发标记-清除,低停顿目标。但碎片问题严重,Full GC 时退化到 Serial。
Region 化设计,优先回收垃圾最多的 Region。可设定停顿目标(如 200ms)。
V8 的 GC 也采用分代设计,经历了多次迭代:
| 阶段 | 新生代 (Minor GC) | 老年代 (Major GC) | 特点 |
|---|---|---|---|
| 早期 | Cheney 半空间复制 | Mark-Sweep + Mark-Compact | 简单、STW 长 |
| 2011 | Cheney 半空间复制 | 增量标记 | 标记可分段,减少单次停顿 |
| 2018 Orinoco | Scavenger (并行) | 并发标记 + 并行整理 | 标记阶段完全并发,停顿大幅降低 |
| Oilpan (Blink) | - | 并发标记 + 并发整理 | C++ DOM 对象的 GC,标记和整理都并发 |
现代 GC 的核心追求:让用户线程几乎感觉不到 GC 的存在。并发 GC 将大部分工作与用户线程同时进行,只留极少的关键阶段需要停止世界(STW)。
ZGC (Z Garbage Collector) 是 JDK 15+ 的生产可用 GC,目标是:无论堆多大(16TB),停顿时间 < 1ms。
ZGC 在 64 位指针中嵌入元数据(4 个颜色位),表示对象状态:
Marked0 / Marked1(标记状态)、
Remapped(是否已重映射)、
Finalizable(是否有 finalize 方法)。
配合读屏障 (Load Barrier),在读取对象时自动修正指针,实现并发整理。
Go 从 1.5 开始使用三色标记-清除算法,并发执行,目标停顿 < 500µs(实际通常 < 100µs)。
初始状态,所有对象都是白色。GC 结束时,白色对象即不可达对象,将被回收。
已被标记但其子对象尚未扫描的对象。相当于 BFS/DFS 的工作队列。
已被标记且其所有子对象也已扫描完毕的对象。GC 结束时所有存活对象都是黑色。
Go 使用 Dijkstra 写屏障:当黑色对象引用白色对象时,将白色对象染成灰色。保证并发标记的正确性,即"强三色不变式"——黑色对象不能直接指向白色对象。
循环引用是内存管理中最重要的经典问题之一,也是引用计数算法的"阿喀琉斯之踵"。
当两个或多个对象互相引用,形成引用环,但没有任何外部(根)引用指向这个环中的任何对象时,就构成了循环引用。
// Python — 循环引用示例
class Node:
def __init__(self):
self.ref = None
a = Node()
b = Node()
a.ref = b # a 引用 b → b.refcount = 2
b.ref = a # b 引用 a → a.refcount = 2
del a # a.refcount → 1(b 仍引用 a)
del b # b.refcount → 1(a 仍引用 b)
# ❌ 循环引用!a 和 b 都 refcount=1,永远不归 0,内存泄漏!
// C++ shared_ptr — 同样的循环引用问题
struct Node {
std::shared_ptr<Node> ref;
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->ref = b; // b 的 use_count = 2
b->ref = a; // a 的 use_count = 2
// a, b 离开作用域后 use_count 各减为 1
// ❌ 谁也不会析构 → 内存泄漏
原理:弱引用不增加引用计数。当强引用全部释放后,弱引用自动变为空。
代表: weak_ptr (C++)、 weakref (Python)、 weak (Swift/ObjC)、 WeakReference (Java)
原理:标记-清除等算法直接从根出发遍历可达性,完全不受引用计数影响。只有从根不可达的对象才被回收,无论它们之间如何互相引用。
代表:Java、Go、C#、JavaScript (V8)
原理:Python 的做法——维护一个可能产生循环的容器对象链表,定期运行检测算法找出不可达的循环引用并回收。
代表:Python 的 gc 模块(分代循环检测)
原理:编译期通过所有权规则杜绝循环引用。每个值有唯一所有者,引用关系有向无环。
代表:Rust 的所有权 + 借用系统,循环需显式使用 Rc<RefCell<>> + Weak
| 语言 | 机制 | 具体做法 |
|---|---|---|
| Python | 引用计数 + 分代循环检测 | 日常用引用计数,gc.collect() 触发循环检测。也可用 weakref 模块避免循环。 |
| C++ | 智能指针组合 | shared_ptr(强引用)+ weak_ptr(弱引用,不增加计数)。父子关系用 unique_ptr。 |
| Java | 追踪 GC(无引用计数) | 天然不受循环引用影响。提供 WeakReference、SoftReference、PhantomReference 辅助。 |
| Go | 并发三色标记 + 写屏障 | 追踪 GC,不受循环引用影响。避免 map 中使用含指针的 key 造成隐式引用链过长。 |
| JavaScript | 标记-清除(V8 Orinoco) | 现代引擎都是追踪 GC。老旧 IE6/7 曾用引用计数+COM 导致 DOM-JS 循环泄漏,已淘汰。 |
| Swift | ARC + weak/unowned | 编译器自动插入 retain/release。闭包捕获 self 时必须用 [weak self] 打破循环。 |
| Rust | 所有权 + 借用 | 编译期杜绝循环。如需共享所有权和内部可变性,用 Rc/Arc + Weak + RefCell/Mutex。 |
| C# | 分代追踪 GC | 类 GC 模式,循环引用自动回收。提供 WeakReference<T> 和 IDisposable 模式。 |
追踪 GC (Java/Go/JS):最省心,开发者无需关心循环引用,但运行时开销不可避免。
引用计数 + 弱引用 (Python/Swift/ObjC/C++):回收及时、运行时开销可控,但需开发者用弱引用主动避免循环。
所有权系统 (Rust):编译期保证安全,零运行时开销,但学习曲线最陡。
最佳实践:即使使用追踪 GC 的语言,也应避免创建不必要的循环引用——节省 GC 工作量,让程序更快。
没有银弹——每种语言的 GC 策略都是设计哲学、应用场景和历史演进的产物。
| 语言 | GC 类型 | 核心算法 | 停顿特征 | 循环引用 | 适用场景 |
|---|---|---|---|---|---|
| C | 手动 | malloc / free | 无 GC 停顿 | 程序员自行管理 | 操作系统、嵌入式、高性能库 |
| C++ | 手动 + RAII + 智能指针 | new/delete, unique_ptr, shared_ptr/weak_ptr | 无 GC 停顿 | weak_ptr 打破循环 | 游戏引擎、数据库、高频交易 |
| Rust | 编译期所有权 | Ownership + Borrow + Lifetime | 无 GC 停顿 | 编译器禁止,需 Rc+Weak | 系统编程、Wasm、浏览器引擎 |
| Go | 并发追踪 GC | 三色标记 + 写屏障 + 并发 | < 500µs 典型 | 自动处理 | 云原生、微服务、网络编程 |
| Java (ZGC) | 并发追踪 GC | Colored Pointers + Load Barrier | < 1ms | 自动处理 | 企业应用、大数据、金融交易 |
| Java (G1) | 分代 Region GC | 并发标记 + 增量整理 | 可设定(~200ms) | 自动处理 | 通用服务端应用 |
| C# (.NET) | 分代追踪 GC | Gen0/1/2 + LOH + 并发 | 通常 < 50ms | 自动处理 | 游戏 (Unity)、企业服务、桌面 |
| Python (CPython) | 引用计数 + 分代循环检测 | RC + Cycle Detector | 几乎无(增量回收) | 自动检测 + weakref | 脚本、数据分析、AI/ML |
| JavaScript (V8) | 分代 + 并发追踪 GC | Orinoco: Scavenger + 并发 Mark-Compact | 通常 < 10ms | 自动处理 | Web 前端、Node.js 服务 |
| Swift | ARC (自动引用计数) | 编译器自动插入 retain/release | 无 GC 停顿 | weak / unowned | iOS/macOS 应用、系统编程 |
| Kotlin/Native | 引用计数 + 循环检测 | RC + Trial Deletion | 几乎无 | 自动检测 | 移动端、跨平台原生 |
| Erlang/Elixir | 进程级 GC | 每个轻量进程独立 GC,进程死亡即全回收 | 极短(进程级) | 进程死亡自动解决 | 高并发分布式系统 |
| Lua | 增量标记-清除 | Incremental Mark-Sweep | 可控制的增量步长 | 自动处理 | 游戏脚本、嵌入式脚本 |
回顾六十余年的进化史,GC 的发展始终围绕一个永恒的矛盾:回收的全面性 vs 程序的响应性。
奠基时代
从无到有:手动 → 引用计数 → 标记清除。核心矛盾:自动化 vs 性能开销。循环引用问题首次暴露。
优化时代
分代假说、复制回收、增量标记。核心矛盾:吞吐量 vs 停顿时间。Java 将 GC 推向工业级。
并发时代
CMS、G1、Go 三色标记、V8 Orinoco。核心矛盾:并发正确性 vs 实现复杂度。写屏障成为关键技术。
零停顿时代
ZGC、Shenandoah、Rust 编译期。核心矛盾:终极性能 vs 程序员心智负担。彩色指针、读屏障、所有权系统三足鼎立。
GC 的进化告诉我们:没有完美的方案,只有适合的权衡。
引用计数给了我们实时性,却留下了循环引用的坑;
追踪 GC 解决了循环引用,但引入了停顿;
分代回收将停顿大幅缩小,但跨代引用增加了复杂度;
并发 GC 将停顿压到毫秒甚至微秒级,但吞吐量有所牺牲;
Rust 的所有权系统实现了零开销,但要求程序员重新学习如何思考内存。
作为开发者,理解这些权衡,才能为自己(和团队)的项目做出最佳选择。
知道你的 GC 在做什么,比你用什么语言更重要。