<!-- title: GC不找死人是找活人：能破环也能挪堆 -->
<!-- domain: programming -->
<!-- video: https://www.youtube.com/watch?v=c32zXYAK7CI -->

# GC不找死人是找活人：能破环也能挪堆

## 导语

如果你写过 C 或 C++，多半被内存折磨过：`malloc` 配 `free`，少配一个就是泄漏，多配一个就崩，配错时序还能写出安全漏洞。于是 Java、Go、Python、C# 这些"托管语言"的出现几乎被当成一种解放——垃圾回收（Garbage Collection，GC）替你管内存，你只管 `new`，不用管 `delete`。

但绝大多数人对 GC 的理解停留在"它自动帮我删垃圾"。这恰恰是最具误导性的一个画面。Computerphile 的这支经典视频《Garbage Collection (Mark & Sweep)》用十几分钟讲清了一件反直觉的事：**追踪式 GC 从头到尾不找垃圾，它只找还活着的东西**。活人圈出来之后，剩下的才是垃圾——而且正是因为这种"反向操作"，它才干成了两件引用计数永远干不成的事：打破循环引用，以及把整个堆挪来挪去。

这篇文章就顺着这个反转重新拆解 GC，并补上视频没展开的几层：为什么追踪的成本和"活对象"成正比而不是和"总分配量"成正比？为什么分代假说不算聪明而是赌一个统计规律？为什么"GC 一定卡顿"在今天已经是个过时的偏见？

## 视频背景卡片

| 项目 | 内容 |
|------|------|
| 视频标题 | Garbage Collection (Mark & Sweep) |
| 频道 | Computerphile（英国诺丁汉大学出品，订阅约 220 万） |
| 时长 | 约 10 分钟（聚焦的原理讲解，非泛谈） |
| 播放量 | 百万级，长期稳定传播的计算机基础"教科书式"视频 |
| 核心主题 | 从 C 的手动内存出发，讲清引用计数为何失败、追踪式 GC 如何用 Mark & Sweep 解决问题 |

## 一、C 的痛：内存不是"回收"，是"记账"

视频的开场很扎实：先把你扔回 C 的世界。在 C 里，内存生命周期全靠程序员手动维护——`malloc` 申请一块，用完 `free` 释放。听起来简单，但它把一个本质上是**全局的、依赖未来的判断**压到了每一行代码上："这块内存现在还需要吗？" 这个问题的答案取决于程序未来怎么走，谁调用谁，几乎是一个带停机问题味道的不可判定难题。

于是现实中三类 bug 反复出现：**double free**（同一块释放两次，堆元数据损坏，崩溃甚至被利用做安全攻击）、**use-after-free / 悬垂指针**（释放后还有人拿着旧指针继续用，读到被改写的脏数据）、**memory leak**（忘了 `free`，内存只增不减，长时间运行最终 OOM）。这些不是程序员懒，是这个记账模型本身就和人类的心智模型对不上。

> 视频原话倾向：手动管理是问题根源，于是需要自动回收。  
> 我的延伸：核心矛盾不是"删内存难"，而是"判断该不该删"在本质上要求全局视野——这正是必须由运行时（runtime）来代劳、而不是交给人手写的原因。

## 二、引用计数：最自然的解法，却死在"环"上

视频接着抛出引用计数（Reference Counting，RC）：每个对象挂一个计数器，多一个引用 +1，少一个引用 −1，归零就立即回收。这是直觉上最顺的方案——它不需要停顿、回收时机确定、实现直观。

但 RC 有一个结构性死穴：**循环引用（cycle）**。A 引用 B、B 引用 A，两者计数都 ≥1，谁也不会归零，结果这对"互相依靠"的对象永远活着，悄无声息地泄漏。典型的例子是树结构里父节点持有子节点、子节点又回指父节点（双向链表、缓存、GUI 控件树几乎都有这个形态）。

> 我的延伸：现实里大量 RC 系统都得偷偷再配一个"环收集器"补救——**而那个补救方案，本质上就是一个追踪式 GC**。Python 的 CPython 用引用计数为主，但额外跑一个 `gc` 模块周期性扫环；Swift 的 ARC、Objective-C、C++ 的 `shared_ptr` 也都靠 `weak_ptr` 之类手段手动断环。换句话说，RC 不是 GC 的替代品，它经常只是 GC 的一个"前置优化"，环一出现就还得请追踪 GC 出山。这也是为什么"能破环"会被放在标题里——它是追踪式 GC 相对 RC 最硬核的胜出点。

## 三、反转问题：GC 不问"能删谁"，问"必须留谁"

这是整支视频、也是整个 GC 理论的"题眼"。追踪式 GC（tracing GC）把问题整个反过来：

- **它不维护"谁引用谁变了"**（那是 RC 的活）；
- **它不寻找垃圾**；
- 它假设**堆里的一切默认都是垃圾**，然后只去证明"哪些必须留着"。

怎么证明？从一组起点（GC Roots，下一节讲）出发，沿着对象之间的引用做图遍历，**凡是被遍历到的，就标记为"活"**；遍历结束之后，所有没被标记的，自然就是垃圾。

这就是标题"**不找死人是找活人**"的字面含义。它从不枚举死对象，它只圈出活对象，剩下的是被定义出来的"补集"。

> 我的延伸（本篇最重要的一条）：这种"反向"不只是哲学好看，它直接决定了**成本的形状**。David Bacon 等人 2004 年那篇《A Unified Theory of Garbage Collection》把这件事讲透了——追踪式 GC 的回收成本和**存活数据量（live set）成正比**，而引用计数的成本和**程序创建过的引用总数成正比**。换句话说：假如你写一段函数式风格代码，循环里创建 1000 万个临时对象、最后只存活 100 个，追踪式 GC 的标记成本大概就是"摸 100 个对象"这个量级；而引用计数要为这 1000 万个对象做加减计数。**两者根本不在同一个量级。** 这也解释了为什么 Java、Go、OCaml、Haskell 这些高频分配的语言清一色选了追踪 GC——不是品味，是成本模型决定的。

## 四、Mark 阶段：从 Roots 出发的图遍历

具体怎么"圈活人"？第一步是 **Mark（标记）**。起点叫 **GC Roots**（根集），常见的根包括：

- 当前所有线程的**栈帧**里的局部变量；
- CPU **寄存器**里的引用；
- **全局变量 / 静态变量**；
- JNI 等本地引用、活动线程对象、被锁持有的对象等系统级引用。

从这些根出发，GC 把对象引用图当成一张有向图，做深度或广度优先遍历，沿途给每个可达对象打上"活"的标记。

> 我的延伸：现代并发 GC 普遍用 **三色标记法（tri-color marking）** 来描述这个过程——白色 = 未访问、灰色 = 已访问但其引用还没全扫、黑色 = 完全处理完。维护"**黑色不指向白色**"这条三色不变式（tri-color invariant），正是让 GC 能和业务线程（mutator）同时跑而不出错的关键。视频里点到为止，但它是后面 ZGC、Shenandoah 这些低延迟收集器能存在的地基。

## 五、Sweep 阶段：没被画到的，才是垃圾

第二步 **Sweep（清除）**：从头到尾扫一遍堆，凡是没被打标记的对象，统统回收。这一步名字虽然叫"清除垃圾"，但本质是"**确认哪些不在活人名单上**"——它仍然是从活集合反推死集合，而不是主动找死对象。

> 我的延伸（这里藏着一个经典缺点）：朴素 Mark-Sweep 的 Sweep 要**走完整个堆**，所以它的成本和**堆大小**成正比，而不是和活集合成正比。堆很大、活对象很少时，Sweep 就显得浪费——这正是后来 **Copying（复制式）** 和 **Generational（分代）** 收集器要解决的问题。视频因为篇幅主要讲 Mark-Sweep 的骨架，但理解了"扫整个堆"这个开销点，你才能看懂 JVM 为什么从 Serial/Parallel 一路演化到 G1、再到 ZGC/Shenandoah。

## 六、能挪堆：Compaction 与 Copying Collector

Mark-Sweep 有个明显的副作用：**内存碎片化（fragmentation）**。死对象被清掉后，堆里全是零散空洞，可能总空闲够、却找不到一块连续的大空间放下一个新对象。

解决方案是**移动活对象**：

- **Copying Collector（复制式）**，典型如半空间复制（semi-space）和 Cheney 算法：把堆分成两半，GC 时把活对象从"From"空间**复制**到"To"空间并紧凑排列，复制完直接把整个 From 空间清空重来；
- **Mark-Compact（标记-压缩）**：原地滑动对象，挤出空洞。

而"移动对象"这件事，正是标题后半句"**能挪堆**"的来历——它带来了一个连锁后果：**对象一旦搬家，所有指向它的指针都必须被改写**。为此 GC 需要一张转发表（forwarding pointer）记录"旧地址→新地址"，并且必须**能精确地识别每一个指针**。

> 我的延伸：这一条解释了一个困扰新手的疑问——为什么 Java、Go 这些语言不让你做指针运算、不像 C 那样随便玩地址？因为一旦允许你把指针塞进整数、藏进结构体任意位置，GC 就没法可靠地"找到并更新所有指针"，挪堆就成了奢望。**托管语言里没有真正的裸指针**，这不是为了难为你，是为了让运行时能精确定位引用（precise GC）、从而敢把堆搬来搬去。引用计数 / free-list 那套体系天然不挪对象，也就天然不用管这事——它换来了简单，代价是永远卡在"碎片化 + 不能破环"里。

## 七、分代假说：GC 不靠聪明，靠"承认新人死得快"

讲完骨架，视频通常会把话题引向一个工程上极其关键的统计规律——**弱分代假说（Weak Generational Hypothesis）**：绝大多数对象"朝生夕死"，刚分配出来很快就变成垃圾（经验上常超过 90%）。配合**强分代假说**：老对象很少会去指向新生对象。

> 我的延伸（要点破一个误区）：很多人以为分代 GC 是"更聪明"的算法，其实恰恰相反——它是一种**主动放弃聪明、转而下注一个经验规律**的策略。既然新对象大概率是垃圾，那就把堆分成"新生代 / 老年代"，频繁地只回收新生代（存活少、复制成本低），偶尔才回收老年代。这种"局部、高频、便宜"的回收，比"全局、低频、昂贵"划算得多。代价是要处理"老对象突然指向新对象"——于是引入**写屏障（write barrier）**把这些跨代引用记进 remembered set。Java（G1 / ZGC / Shenandoah）、.NET、V8（Orinoco）几乎都是分代或分代改良路线。Go 早期是 STW 的并发标记清除（CMS），后来也走向并发 + 分代混合，逻辑同源。

## 八、代价：Stop-the-World 与并发的权衡

GC 不是免费的。它必须在某些时刻保证一致性，最常见的手段就是 **Stop-The-World（STW）**：暂停所有业务线程，专心回收。STW 的长短，就是你感受到的"卡顿""掉帧""请求毛刺"。

> 我的延伸（破除一个老偏见）："用 GC 的语言一定卡"这个说法在 2026 年其实已经过时。现代低延迟收集器的目标是把单次停顿压到**亚毫秒级且与堆大小几乎无关**。ZGC（OpenJDK）和 Shenandoah 通过并发标记、并发转移、读写屏障 + 三色不变式，把大部分工作搬到业务线程并行做，把 STW 砍到极短。代价不是消失了，而是被**搬到了屏障（barrier）和簿记开销里**——每次读写引用都要插桩一点工作。换句话说，GC 的成本是个"挪来挪去"的东西：要么你忍受停顿，要么你接受恒定的一点点开销。世上没有免费的内存回收，但选对收集器，你可以选择"在哪儿付这笔账"。

## 知识结构图

```mermaid
graph TD
  A["手动管理 C/C++<br/>malloc / free"] -->|痛点：泄漏·悬垂·重复释放| B{"内存该不该删?<br/>本质是全局未来问题"}
  B -->|交给运行时| C[自动回收]
  C --> D["引用计数 RC<br/>计数器=0 即回收"]
  C --> E["追踪式 GC Tracing"]
  D -->|结构性失败| F["循环引用环<br/>Python 靠 cycle collector 补救"]
  E --> G["反转问题：不找死，找活"]
  G --> H["GC Roots<br/>栈·寄存器·全局"]
  H --> I["Mark：图遍历染色可达对象"]
  I --> J{"Sweep / Copy"}
  J -->|Mark-Sweep| K["未标记即垃圾（但扫全堆）"]
  J -->|Copying / Compact| L["移动活对象并更新指针<br/>= 能挪堆"]
  K --> M["分代假说：新对象死得快"]
  L --> M
  M --> N["低延迟并发：ZGC / Shenandoah"]
  F -.补救本质是 GC.-> E
```

## 实战启示

**1. 高频分配不可怕，怕的是"长寿的活对象"。** 既然追踪 GC 的成本和存活集合成正比，你真正要盯的不是"创建了多少对象"，而是"有多少对象长期赖着不走"。一个缓存无限增长、把对象长期挂在静态集合上，远比循环里疯狂创建临时对象更让 GC 难受——前者把活集合做大，标记和压缩都跟着变贵。调优的第一直觉永远是：**别让活集合无限膨胀。**

**2. 选语言前先想清楚成本模型。** 如果你做的是高频小对象、不可变数据（函数式风格、流处理、编译器），追踪 GC 的"成本∝活集合"特性对你极其友好，Java/Go/OCaml 都合适；如果你的场景要求确定时序、零停顿（嵌入式、实时控制、游戏引擎核心），那 RC（Rust 的所有权、Swift ARC、C++）甚至手动管理可能更可控——但你要承担断环和碎片化的代价。**没有银弹，只有"你愿意把成本付在哪一边"。**

**3. 理解写屏障，才能读懂 GC 日志。** 当你看到分代 / 低延迟 GC 的开销来自 barrier，就明白为什么有些"看似无害"的代码（大量修改老对象字段、疯狂触发跨代引用）会让回收变贵。优化方向不是少 new，而是减少引用图的剧烈变动。

**4. 破除"GC 必卡"的旧观念，敢用现代收集器。** 在合适的堆规模下，ZGC / Shenandoah 能做到亚毫秒级停顿。很多团队还在用十年前对 GC 的印象做架构决策，白白把性能留给误解。

## 延伸学习路径

1. **《A Unified Theory of Garbage Collection》**（Bacon, Cheng, Rajan, 2004）——把追踪与引用计数写成对偶（duality）的经典论文，看完会彻底理解"成本∝活集合 vs 成本∝引用总数"。
2. **《The Garbage Collection Handbook: The Art of Automatic Memory Management》**（Jones, Hosking, Moss）——GC 领域的"圣经"，从 Mark-Sweep 到并发、实时收集器全覆盖。
3. **TRIColor / 三色标记与并发收集器** —— 搜 "tri-color marking invariant" 与 "Shenandoah / ZGC concurrent tracing"，理解并发 GC 如何不破坏正确性。
4. **CPython 源码 `Modules/gcmodule.c`** —— 看一个真实的"引用计数 + 环收集器"组合拳长什么样，理解 RC 为什么最终还是得请追踪 GC 出山。
5. **Go runtime `runtime/mgc.go`** 与 **JVM G1 文档** —— 对照两种工业级并发分代 GC 的工程实现，把理论落到代码。

## 一句话总结

追踪式垃圾回收的真正巧思不是"自动删垃圾"，而是**反过来只圈出活对象、让垃圾作为补集自动现身**——正是这个反转，让它既能打破引用计数永远破不了的环，又能把整个堆搬来搬去，并以"成本和活集合成正比"的优雅形状，支撑起今天所有高频分配语言。

## 参考来源

1. [Garbage Collection (Mark & Sweep) — Computerphile](https://www.youtube.com/watch?v=c32zXYAK7CI) — 本文分析的核心视频
2. [A Unified Theory of Garbage Collection (Bacon et al., 2004)](https://www.cs.virginia.edu/~cs415/papers/oopsla08.pdf) — 追踪与引用计数对偶性的理论来源
3. [The Garbage Collection Handbook — Jones, Hosking, Moss](https://gchandbook.org/) — GC 全谱系权威教材
5. [CPython gc module 文档](https://docs.python.org/3/library/gc.html) — 引用计数 + 环收集器的真实工程实现参考

---

> 本文由「51学通信」技术团队出品。51学通信由通信行业18年老兵**爱卫生**（微信ID: gprshome201101）维护，聚焦5G/6G核心网、3GPP标准解读、通信前沿技术深度分析。
> 出版作品有《5G核心网原理与实践》《IMS原理与实践》等4本通信专业书籍。知识星球「51学通信」提供深度技术答疑和行业洞察。

> [!tip] 51学通信知识星球
> **星主：爱卫生**（微信：gprshome201101）——公众号「51学通信」创办者，19 年通信行业运维与讲师经验，著有《GPRS网络信令实例详解》《EPC原理与实践》《5G核心网原理与实践》三本纸质书（各大电商平台有售）。
> **星球定位**：深耕 5G 核心网、IMS、NFV/AI、云原生等 ICT 领域，主打运维岗位与信令分析。
> **当前规模**：5200 篇主题 / 990 个回答，198 元/年。
> **加入福利**：加入星球后请加爱卫生微信，拉您进知识星球年度陪伴与答疑群——不限量回复您的各种问题，另有群内分享。
> **AI 加持**：51学通信知识星球已作为 skill 接入 WorkBuddy，使用 deepseek-4-flash 在海量星球帖子中精准找贴、找视频、找 FAQ 和案例，做总结、找文档和 PPT 等场景全覆盖。

![51学通信知识星球海报-扫码加入](https://wiki.500nian.com/images/zhixingqiu_qrcode.png)
