AI 资讯 知识库 一叶一世界一篇读懂垃圾回收:从引用计数到三色标记

一篇读懂垃圾回收:从引用计数到三色标记

每个用过 Java、Go、Python 的工程师都欠着垃圾回收(GC)一份人情:不用手动 free,内存不会像 C 里那样说漏就漏。但这份人情有两张完全不同的账单——Java 偶尔「stop the world」的几百毫秒停顿,与 Python 处理循环引用时的「引用计数 + 辅助回收」双轨制,背后是 GC 世界两大流派的分野。本文把垃圾回收的根本问题讲清:什么算垃圾、两大流派怎么判定它、三色标记如何在并发世界里保持正确,以及分代假说为什么统治了几乎所有现代运行时。读完后你会发现:GC 的所有设计分歧,都源于同一个问题的两种回答——「垃圾」到底该靠记账发现,还是靠遍历发现。

垃圾回收手绘信息图:可达性、两大流派、三色标记与写屏障、分代回收

一、根本问题:垃圾 = 不可达

GC 要回答的第一个问题不是「怎么回收」,而是「什么是垃圾」。直觉答案「程序不会再使用的对象」在理论上是个陷阱:判定「将来会不会用」等价于解决停机问题——不可能。所以所有实用的 GC 都退一步,用可达性作为代理标准:从「根集合」(调用栈上的局部变量、全局变量等程序当前必然能直接摸到的对象)出发,沿着引用链能走到的对象都是活的;走不到的,就是垃圾。

这个标准巧妙但保守:它回收的是「语法垃圾」(确定不可达),放过「语义垃圾」(程序逻辑上再也不会用、但引用还挂在某处的数据)——后者就是常说的「内存泄漏」,GC 帮不了你,缓存没设淘汰策略照样漏。理解了这一点,就理解了 GC 的两个流派只是回答「可达性怎么判定」的两条路。还要注意可达性定义里的「根」是动态的:方法调用进出,栈上的局部变量来了又走;同一个对象在函数 A 里可达、函数返回后可能就不可达——GC 判定的「活」是「此刻还能被摸到」,不是「永远有用」,这个语义差是很多内存泄漏讨论的起点。

二、两大流派:记账派与追踪派

引用计数是最直观的流派:每个对象随身带一个计数器,记录「有多少引用指着我」,引用建立加一、断开减一,归零即刻回收。它的美德是即时性与确定性——对象死得明明白白,适合绑定文件句柄、网络连接这类需要确定性清理的资源。但它有两个死穴:循环引用(a 引用 b、b 引用 a,计数永远不为零,双双泄漏)与开销摊在每个操作上(每次赋值都要碰计数器,多线程下还有竞争)。Python 是引用计数的代表,它的解法是把循环引用问题外包给一个辅助的追踪式回收器定期兜底——两大流派在一个运行时里共存。

追踪式(tracing)GC是另一派:不定时为单个对象记账,而是周期性地从根集合出发遍历整张引用图,走得到的标记为活,扫不进的全部清除。Java HotSpot、Go、.NET 都是这一派。它天然处理循环引用(循环只要不可达照样回收),吞吐开销可以摊到极低;代价是回收时机不确定,简单实现还会把整个程序暂停(stop-the-world)——这引出了现代 GC 的核心课题:怎么在程序继续运行的同时,正确地完成遍历。

三、三色标记与写屏障:并发世界里的正确性魔法

「边跑程序边标记」的关键是三色抽象:把对象涂成三种颜色——白色(尚未扫描,回收候选)、灰色(自己可达但引用的孩子还没扫完)、黑色(扫描完成且不指向任何白色)。算法循环:从灰色集合取一个对象,把它引用的白色对象涂灰,再把自己涂黑;灰色集合为空时,剩下的白色就是垃圾。

并发执行的难点在这里出现:标记期间,用户程序(mutator)还在改引用。推演两个具体场景就能看清风险所在。场景一:mutator 把黑色对象的一个引用改指向某白色对象——黑已扫完不会再被访问,这个新挂上的白色活对象将被当成垃圾回收,程序随后读到的就是悬空数据。场景二:mutator 把灰色对象指向白色的引用删掉——灰色还没扫到,等扫到时引用已消失,白色对象照常被回收,而这恰恰是想要的结果(它确实死了)。两个场景一对照,风险边界就清楚了:危险的只有「黑色看向白色」这一种改动。保证「黑色不指白色」的三色不变式,靠写屏障维持:在每个指针写入处注入一小段代码,把被引用的白色对象涂灰(或做其他记录)。写屏障是并发 GC 的标配开关,代价是标记期间每次指针赋值都有额外开销——这也是为什么运行时会在标记结束后立刻关闭屏障。

分代回收解决的是另一个维度的问题——效率。经验观察(分代假说)是:绝大多数对象朝生夕死。于是把堆分成年轻代与老年代,绝大多数回收只扫描年轻代(Eden + 幸存者空间),活过几轮的对象晋升老年代;跨代引用(老对象引用新对象)用「记忆集」记录并作为额外的根。分代不是新算法,而是让「标记」只看一小部分的调度策略——Java 与 .NET 的 minor/major 回收周期正是它的现代形态。

年轻代内部还有一块值得一看的工程拼图:复制式回收。年轻代的回收不用「标记-清除」(清除会留碎片),而是「标记-复制」:把活对象从一个半区整体搬到另一个半区,搬完原半区整块清空——碎片问题不存在,分配新对象也只是指针递增,快得离谱;代价是浪费一半空间做缓冲,以及「活得久」的对象要被反复搬运。经典实现(Ungar 的世代复制器)用 Eden 加两个幸存者空间轮转,这套布局至今仍是 Java 年轻代的标准画法。看懂这块拼图,再回头看「为什么年轻代回收特别快」就有了完整答案:它把「大部分对象会死」的经验规律,兑换成了「只扫一小块 + 整块复制」的机械优势。

四、选型视角与工程边界

把两派放回真实运行时的光谱上:Python 用引用计数 + 辅助追踪(处理循环);Java/Go/.NET 用追踪式(并发标记 + 分代),并以「低延迟收集器」(G1 的并发阶段、Go 的亚毫秒 STW)持续压缩停顿;Rust 与 Swift(ARC)则站回确定性一侧——用所有权或自动引用计数把回收时机交还程序员。没有绝对赢家:引用计数赢在确定性、输在循环与吞吐;追踪式赢在吞吐与循环处理、输在停顿与不确定性。语言选什么 GC,本质是在「延迟可预测」与「吞吐效率」之间选站位。

这张光谱直接映射到使用者能感知的现象。Java 工程师熟知的「GC 停顿」日志(Pause Young、Pause Full)就是追踪式回收的暂停瞬间——老年代占满触发一次 Full GC,所有线程冻结、全程扫描,几百毫秒到几秒不等,这就是对延迟敏感系统要调 GC 的原因;现代低延迟收集器(G1、ZGC)的改进方向是把「整体停顿」拆成「与程序并发的小段工作」,把停顿压进毫秒级。Python 工程师则面对另一套现象:引用计数让对象的销毁时机完全确定(__del__ 立刻触发),但循环引用要靠 gc 模块的辅助回收器定期清理——gc.collect() 的存在本身就是「引用计数不够用」的自白。两门语言的工程师读同一篇 GC 原理,看到的痛点却完全不同——原理是共通的,症状是各自的。给两派列一张对照表,把散落的取舍收拢:

维度 引用计数 追踪式
回收时机 归零即收,确定 不确定,批量进行
循环引用 无法处理,需辅助回收 天然处理(可达性判定)
开销位置 摊在每次赋值 集中在回收周期
停顿 无长停顿 简单实现可长停顿,并发版已压至毫秒级
代表 Python(基础)、Swift(ARC) Java、Go、.NET

给实践者的边界提醒有三条。GC 只管内存:文件句柄、数据库连接这些非内存资源不归可达性管,必须显式关闭(try-with-resources、context manager 的存在理由)。内存泄漏换了个形态:可达但无用的语义垃圾(全局缓存只增不减、监听器忘记解绑)GC 无能为力,堆转储分析仍是必备技能。GC 参数调优的前提是理解分代:看懂「晋升过快」「老年代占满」这类告警的前提,是知道对象在分代间的旅程——否则调参只是玄学。

结语

垃圾回收的两派之争,本质是同一个问题的两种回答:可达性靠记账(引用计数)还是靠遍历(追踪式)。记账即时但怕循环,遍历彻底但要解决「边跑边数」的正确性——三色抽象与写屏障是后者的答案,分代假说是它的效率外挂。理解了这条主线,Java 的停顿调优、Python 的 gc 模块、Go 的低延迟收集器都不再是孤立的配置项,而是同一套原理在不同语言里的不同取舍。内存管理从「手动」走到「自动」用了几十年,下一步的争论焦点已经是「自动得有多可预测」——这条演化线,还会继续往下写。

参考资料

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?