一段代码跑着跑着,内存里堆满了没人要的对象。你以为垃圾回收器(Garbage Collector, GC)会像清洁工一样,挨个翻找哪些是"垃圾"然后清掉。错——GC 从头到尾根本看不见垃圾,它只会数"还有谁活着"。数完活人,剩下的自动就成了尸体。这是计算机科学里最优雅、也最反直觉的设计之一。
每个写过 C/C++ 的人,都经历过那种半夜调试的崩溃:malloc 了一块内存,却忘了 free,程序跑着跑着内存涨到把系统拖垮;或者更阴险的,free 了两次,程序在完全无关的地方随机崩溃,因为堆结构被你踩烂了。手动内存管理,是程序员噩梦的源头。
于是现代语言——Java、Python、Go、JavaScript、C#——几乎都装了一个"保姆":垃圾回收器。你只管申请,不管归还,GC 会替你找出没用的内存并回收。听起来简单得像扫地,但这里藏着一个几乎所有教程都没讲透的核心真相:GC 根本不是在"找垃圾",它是在"找活人"。
Computerphile 频道那支播放量近 30 万的经典视频《Garbage Collection (Mark & Sweep)》,把这件事讲得格外干净。它没有堆术语,而是从"为什么需要自动内存管理"一路推到 mark & sweep 算法的本质。这篇解读会以这支视频为骨架,再往深里挖一层——分代假说、三色标记、写屏障、停顿代价——告诉你 GC 为什么这么设计,以及这套"反向思维"如何彻底改变了我们对"什么是垃圾"的理解。
| 项目 | 内容 |
|---|---|
| 视频标题 | Garbage Collection (Mark & Sweep) - Computerphile |
| 频道 | Computerphile(Brady Haran 出品,订阅超 260 万) |
| 时长 | 约 9 分钟 |
| 播放量 | 约 29 万次 |
| 发布日期 | 2023 年 1 月 20 日 |
| 核心主题 | 自动内存管理为什么必要,以及 mark & sweep 如何工作 |
Computerphile 是和 Numberphile、Sixty Symbols 同源的科普巨头,专攻计算机科学,视频短小但密度极高,是程序员补底层认知的首选频道之一。
这是整支视频的灵魂,但视频只点到为止,我把它彻底讲透。
普通人脑中的模型是:GC 扫描内存,逐个判断"这个对象还要不要",不要的就标记为垃圾。这个模型从根上就是错的。
真实的做法叫可达性分析(reachability analysis):GC 选定一组"根"(roots)——当前所有函数栈上的局部变量、全局/静态变量、CPU 寄存器里的引用——然后从这些根出发,沿着指针一路追踪。凡是被追踪到的对象,就是"可达的",也就是"活着的"。追踪结束之后,所有没被标记的对象,自动就是垃圾。
注意这个逻辑的精妙之处:GC 从来没有、也不需要去"看见"垃圾。它只数活人,剩下的全默认是死人。 为什么必须这样?因为"垃圾"是一个无法直接观测的属性——你没有任何办法从一个对象自身的字节里判断它是不是垃圾(它可能正被某个你以为不存在的地方引用着)。唯一能证明一个对象是垃圾的办法,是证明它不可达——而这只能通过反向追踪"谁还活着"来间接完成。
这就是为什么这条规则不可违背:不是 GC "选择"先找活人,而是它"只能"先找活人。找垃圾这件事,在逻辑上就必须倒过来做。理解了这一点,后面所有 GC 的设计——三色标记、写屏障、分代——你都会发现它们全是在服务于同一个目标:更快、更准、更不打断程序地把"活人"数清楚。
视频开头给了个特别生活化的理由:你在编译期根本无法知道程序运行时要用多少内存。处理图片,你不知道用户会打开多大的照片;写浏览器,你不知道用户会开多少标签页、每个页面有多少 DOM 节点。
这意味着内存不能像变量那样在编译期固定分配,只能"运行时动态申请"(malloc / new)。可一旦允许动态申请,就绕不开一个尖锐的问题:谁来还?
C 语言把决定权完全交给程序员:你 malloc,你负责 free。这种自由的代价是三类经典 bug,至今仍是安全漏洞的温床:
free 两次,堆管理结构被破坏。GC 的存在,就是把"什么时候还"这个最容易出错的决定,从程序员手里拿走,交给运行时自动处理。它的价值不是让你少写两行 free,而是消灭一整类 bug。(视频原话强调 GC 能"减少内存泄漏、提升可靠性与开发效率"。)
视频介绍了两种主流思路,第一种是引用计数(reference counting)。它最符合直觉:每个对象记一个整数,表示"有多少个引用指向我"。有新的引用指向它,计数 +1;某个引用失效,计数 -1。一旦计数归零,立刻回收——连等待都不用,所见即所得。
听起来完美,Python、Swift(ARC)、Objective-C 都在用。但它有一个致命伤:循环引用(reference cycle)。
设想 A 和 B 互相引用(A→B, B→A),但外部已经没有任何东西指向它们了。它们的引用计数都是 1,永远不归零,于是永远回收不掉——这就是引用计数版的"内存泄漏"。两个互相拽着对方的死人,谁也不肯先撒手。
这就是为什么纯引用计数不够用,必须配上一个兜底的循环收集器(cycle detector)——比如 Python 的 gc 模块,专门周期性地去揪出这些"死循环"。Swift 的 ARC 也要求程序员用 weak/unowned 引用来手动打断环。
延伸判断:引用计数是"局部正确、全局失败"的典型。它在没有环的图(如树、DAG)上既快又准,可一旦出现环,局部的计数逻辑就彻底失灵。这个教训后来被系统设计反复印证:很多看起来优雅的局部规则,会败在"环"这种全局结构上。
视频标题里的 Mark & Sweep(标记-清除),正是为了解决引用计数搞不定的环,而被发明出来的终极方案。它分两步,逻辑干净得近乎朴素:
注意 sweep 阶段同样不"识别"垃圾——它只是回收"没被标记的"。mark 负责确定活人名单,sweep 负责清除名单外的一切。两者合起来,完美绕开了引用计数的环问题:因为判定标准是"从根能不能走到",而不是"引用计数是否为零",所以无论对象怎么互相引用,只要从根不可达,就一并清除。
代价是:mark & sweep 要求 GC 能暂停所有应用线程(stop-the-world),因为标记过程中堆结构不能变,否则你一边标一边程序在改指针,结果就乱了。这个"停顿"是后面所有优化要对付的头号敌人。
视频没有展开,但想真正理解现代 GC(Go、Java、V8)就必须懂三色标记法(tri-color marking)。它是 1970 年代 Dijkstra 提出的并发标记框架,把对象分成三种颜色:
算法维护一个不变量:黑色对象绝不能直接指向白色对象(否则白色会被误判为垃圾)。只要这个"三色不变量"成立,GC 就能和应用程序(mutator)并发执行——程序在跑,GC 在后台标,互不阻塞。
但程序随时可能把黑色对象指向白色对象,破坏不变量。怎么办?写屏障(write barrier):在程序每次写指针时插入一小段代码,发现"黑指向白"就立刻把那个白色对象重新标灰,保证它不会被漏掉。这就是 Go GC、Java G1 能做到毫秒级停顿的核心机制。
这条延伸知识解释了一件视频没说的事:为什么现代 GC 不再需要长时间"世界停摆"。三色 + 写屏障,让"数活人"这件事可以分摊到程序运行的每一毫秒里偷偷完成。
GC 设计者观测到一个压倒性的经验规律——绝大多数对象"朝生夕死":申请后很快就没人用了(比如循环里的临时变量、一次请求的中间结果)。这被称为弱分代假说(weak generational hypothesis);对应的"强分代假说"则说:越老的对象越可能继续活着。
这条假说直接催生了分代收集(generational GC):把堆分成"年轻代"和"老年代"。GC 频繁地只扫年轻代(因为这里垃圾最密集,收益最高),很少扫老年代。结果是同样的一次 GC,回收效率高得多,停顿也短得多。
这又一次体现了那个反直觉原则的延伸:与其追求"扫描全部"的完美,不如承认"大部分目标集中在少数地方",集中火力。 HotSpot JVM、.NET、V8 都是基于分代设计。视频虽没讲分代,但 mark & sweep 的标记逻辑正是分代回收在年轻代上反复执行的那个内核。
视频反复点到 GC 的本质代价:回收时,程序的正常执行会被暂停。这段暂停叫 STW(Stop-The-World)停顿。
停顿长短,几乎就是一门 GC 工程的全部 KPI:
这背后是一组永恒的权衡:吞吐量(throughput)vs 延迟(latency)vs 内存占用(footprint)。没有银弹——你要更短的停顿,就得付出更高的 CPU 开销或更大的内存冗余。选 GC,本质是在这三个旋钮之间,为你的应用场景找最佳平衡点。
视频里有一个很容易被忽略、却极重要的提醒:用了 GC,照样会内存泄漏——只不过泄漏的原因变了。
手动时代的泄漏是"忘了 free";GC 时代的泄漏是"你以为不要了,但其实某个地方还攥着它的引用"。比如你把对象塞进一个全局的缓存 Map 里却从不清理,或者往一个静态 List 里狂加却不删——这些对象永远可达,GC 永远不敢回收,内存照样涨爆。
这就是所谓的逻辑泄漏(logical leak / unintentional object retention)。GC 能帮你消灭"忘了 free",却无法替你消灭"你不小心留了个引用"。它把 bug 从"机械操作失误"升级成了"逻辑设计失误"——后者更隐蔽,往往要靠 profiler(如 Java 的 MAT、VisualVM)才能揪出谁在偷偷持有对象。
这条认知至关重要:GC 保证"不会回收还在用的对象",但不保证"一定会回收你没在用的对象"——前提是你得真把它从所有可达的路径上摘干净。
flowchart TD
A["GC 的核心悖论<br/>无法直接看见垃圾"] --> B["反向解法<br/>只数谁还活着"]
B --> C["可达性分析<br/>从 Roots 出发追踪指针"]
C --> D1["Root 集合<br/>栈/全局/寄存器"]
C --> D2["Mark 阶段<br/>标记所有可达对象"]
D2 --> E["Sweep 阶段<br/>回收所有未标记对象"]
F["两种实现路线"] --> G["引用计数 Refcount<br/>局部直觉"]
F --> H["Tracing 追踪<br/>Mark & Sweep 全局正确"]
G --> I["致命缺陷<br/>循环引用无法归零"]
H --> J["完美绕开<br/>从根不可达即清除"]
E --> K{"现代 GC 如何提速"}
K --> L["三色标记 + 写屏障<br/>并发标记不停应用"]
K --> M["分代假说<br/>集中扫年轻代"]
K --> N["STW 停顿<br/>自动化的代价"]
style A fill:#fde2e2,stroke:#c0392b
style B fill:#d5f5e3,stroke:#27ae60
style H fill:#d6eaf8,stroke:#2980b9
1. 别再用"找垃圾"的心智模型去理解 GC。 写 Java/Go/JS 时,绝大多数内存问题的根因都能归结到一句"谁还在引用它"。养成问"这个对象现在从根还能走到吗"的习惯,比死记 GC 参数有效十倍。线上排查 Full GC 频繁、堆涨爆,第一步永远是 dump 堆,去看老年代里到底是谁不肯死——答案往往是某个缓存、监听器、或静态集合。
2. 警惕逻辑泄漏,主动断引用。 用完的对象,从集合里 remove;事件监听器要 unregister;大对象用完置 null 或尽快出作用域;缓存一定要有淘汰策略(LRU/TTL),别让它无限增长。GC 救不了从来不摘的引用。
3. 选 GC 就是选权衡。 如果你做低延迟交易系统、游戏服务器,优先看 STW 上限(ZGC/Shenandoah/Go);如果是批处理、大数据,吞吐优先(Parallel GC);微服务则 G1 默认值往往就够了。先想清楚你的应用最怕"慢"还是最怕"卡",再定 GC。
4. 引用计数场景要手动断环。 用 Swift/Obj-C 的 ARC、或 Python 的 refcount 代码时,循环引用是头号陷阱——长生命周期对象之间的相互引用,一律用 weak / unowned / weakref 打断。
5. 理解"反向定义"这个思维模式。 GC 的设计哲学——用"可达"去定义"垃圾",而不是直接找垃圾——是一种可迁移的高阶思维。当你遇到"无法直接判定 X"的问题,不妨试试"定义 X 的补集,先确定非 X"。可达性分析、布隆过滤器、事务日志,底层都是这同一种"倒过来想"的智慧。
GC 从来不认识垃圾——它只敢把"还能从根走到的人"数清楚,剩下的,自动就成了该清掉的。
本文由「51学通信」技术团队出品。51学通信由通信行业18年老兵爱卫生(微信ID: gprshome201101)维护,聚焦5G/6G核心网、3GPP标准解读、通信前沿技术深度分析。 出版作品有《5G核心网原理与实践》《IMS原理与实践》等4本通信专业书籍。知识星球「51学通信」提供深度技术答疑和行业洞察。
核心选题角度:抓住 GC 最反直觉的内核——"GC 不识别垃圾,只识别活着的对象(可达性分析)",并明确标注了视频原话 vs 我的延伸(三色标记/分代/写屏障/STW/逻辑泄漏)。
[!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 等场景全覆盖。
