如果你写过 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 里,内存生命周期全靠程序员手动维护——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(tracing GC)把问题整个反过来:
怎么证明?从一组起点(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(标记)。起点叫 GC Roots(根集),常见的根包括:
从这些根出发,GC 把对象引用图当成一张有向图,做深度或广度优先遍历,沿途给每个可达对象打上"活"的标记。
我的延伸:现代并发 GC 普遍用 三色标记法(tri-color marking) 来描述这个过程——白色 = 未访问、灰色 = 已访问但其引用还没全扫、黑色 = 完全处理完。维护"黑色不指向白色"这条三色不变式(tri-color invariant),正是让 GC 能和业务线程(mutator)同时跑而不出错的关键。视频里点到为止,但它是后面 ZGC、Shenandoah 这些低延迟收集器能存在的地基。
第二步 Sweep(清除):从头到尾扫一遍堆,凡是没被打标记的对象,统统回收。这一步名字虽然叫"清除垃圾",但本质是"确认哪些不在活人名单上"——它仍然是从活集合反推死集合,而不是主动找死对象。
我的延伸(这里藏着一个经典缺点):朴素 Mark-Sweep 的 Sweep 要走完整个堆,所以它的成本和堆大小成正比,而不是和活集合成正比。堆很大、活对象很少时,Sweep 就显得浪费——这正是后来 Copying(复制式) 和 Generational(分代) 收集器要解决的问题。视频因为篇幅主要讲 Mark-Sweep 的骨架,但理解了"扫整个堆"这个开销点,你才能看懂 JVM 为什么从 Serial/Parallel 一路演化到 G1、再到 ZGC/Shenandoah。
Mark-Sweep 有个明显的副作用:内存碎片化(fragmentation)。死对象被清掉后,堆里全是零散空洞,可能总空闲够、却找不到一块连续的大空间放下一个新对象。
解决方案是移动活对象:
而"移动对象"这件事,正是标题后半句"能挪堆"的来历——它带来了一个连锁后果:对象一旦搬家,所有指向它的指针都必须被改写。为此 GC 需要一张转发表(forwarding pointer)记录"旧地址→新地址",并且必须能精确地识别每一个指针。
我的延伸:这一条解释了一个困扰新手的疑问——为什么 Java、Go 这些语言不让你做指针运算、不像 C 那样随便玩地址?因为一旦允许你把指针塞进整数、藏进结构体任意位置,GC 就没法可靠地"找到并更新所有指针",挪堆就成了奢望。托管语言里没有真正的裸指针,这不是为了难为你,是为了让运行时能精确定位引用(precise GC)、从而敢把堆搬来搬去。引用计数 / free-list 那套体系天然不挪对象,也就天然不用管这事——它换来了简单,代价是永远卡在"碎片化 + 不能破环"里。
讲完骨架,视频通常会把话题引向一个工程上极其关键的统计规律——弱分代假说(Weak Generational Hypothesis):绝大多数对象"朝生夕死",刚分配出来很快就变成垃圾(经验上常超过 90%)。配合强分代假说:老对象很少会去指向新生对象。
我的延伸(要点破一个误区):很多人以为分代 GC 是"更聪明"的算法,其实恰恰相反——它是一种主动放弃聪明、转而下注一个经验规律的策略。既然新对象大概率是垃圾,那就把堆分成"新生代 / 老年代",频繁地只回收新生代(存活少、复制成本低),偶尔才回收老年代。这种"局部、高频、便宜"的回收,比"全局、低频、昂贵"划算得多。代价是要处理"老对象突然指向新对象"——于是引入写屏障(write barrier)把这些跨代引用记进 remembered set。Java(G1 / ZGC / Shenandoah)、.NET、V8(Orinoco)几乎都是分代或分代改良路线。Go 早期是 STW 的并发标记清除(CMS),后来也走向并发 + 分代混合,逻辑同源。
GC 不是免费的。它必须在某些时刻保证一致性,最常见的手段就是 Stop-The-World(STW):暂停所有业务线程,专心回收。STW 的长短,就是你感受到的"卡顿""掉帧""请求毛刺"。
我的延伸(破除一个老偏见):"用 GC 的语言一定卡"这个说法在 2026 年其实已经过时。现代低延迟收集器的目标是把单次停顿压到亚毫秒级且与堆大小几乎无关。ZGC(OpenJDK)和 Shenandoah 通过并发标记、并发转移、读写屏障 + 三色不变式,把大部分工作搬到业务线程并行做,把 STW 砍到极短。代价不是消失了,而是被搬到了屏障(barrier)和簿记开销里——每次读写引用都要插桩一点工作。换句话说,GC 的成本是个"挪来挪去"的东西:要么你忍受停顿,要么你接受恒定的一点点开销。世上没有免费的内存回收,但选对收集器,你可以选择"在哪儿付这笔账"。
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 的印象做架构决策,白白把性能留给误解。
Modules/gcmodule.c —— 看一个真实的"引用计数 + 环收集器"组合拳长什么样,理解 RC 为什么最终还是得请追踪 GC 出山。runtime/mgc.go 与 JVM G1 文档 —— 对照两种工业级并发分代 GC 的工程实现,把理论落到代码。追踪式垃圾回收的真正巧思不是"自动删垃圾",而是反过来只圈出活对象、让垃圾作为补集自动现身——正是这个反转,让它既能打破引用计数永远破不了的环,又能把整个堆搬来搬去,并以"成本和活集合成正比"的优雅形状,支撑起今天所有高频分配语言。
本文由「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 等场景全覆盖。
