try ai
科普
编辑
分享
反馈
  • 自动垃圾回收:原理、机制与应用

自动垃圾回收:原理、机制与应用

SciencePedia玻尔百科
核心要点
  • 垃圾回收器通过首先从一个根集合(root set)出发追踪所有可达的“存活”对象,然后回收不可达的“垃圾”,来识别可回收的内存。
  • 分代假说(即大多数对象“朝生夕死”)催生了高效的垃圾回收器,它能以不同于处理长寿对象的方式来处理新生代对象。
  • 自动内存管理将程序员从手动记账中解放出来,但也引入了新的设计挑战,如逻辑内存泄漏,即不再需要的对象仍然保持可达状态。
  • 现代系统通过与应用程序并行运行的并发回收,以及避免堆分配的编译器优化(如逃逸分析),实现了低延迟的垃圾回收。

引言

自动内存管理,或称垃圾回收(garbage collection),是现代高级编程语言的基石,它将开发者从手动分配和释放内存这一易错的任务中解放出来。虽然这种抽象简化了软件开发,但其背后隐藏着一个充满精妙工程设计和复杂权衡的世界。其根本挑战在于,系统如何能够在不干扰应用程序的情况下,自动区分有用数据与“垃圾”,并高效地回收资源。本文旨在揭开自动垃圾回收的神秘面纱,为程序员、编译器设计者和系统架构师提供一个全面的概述。第一章“原理与机制”将探讨核心算法,从基础的标记-清除(Mark-and-Sweep)和引用计数(Reference Counting),到高级的分代(Generational)和并发回收(Concurrent collection)技术。随后,“应用与跨学科联系”一章将审视垃圾回收对软件设计模式、编译器优化(如逃逸分析)以及高性能系统架构的深远影响。

原理与机制

要理解机器如何管理自身内存,我们必须先问一个听起来很哲学化的简单问题:什么是垃圾?在计算机程序的世界里,内存中充满了“对象”——承载信息的数据块。这些对象通过“指针”相互连接,如同关系之线,形成一张巨大而复杂的网络。一个对象只有在程序能找到通往它的路径时才是有用的,即​​存活的​​(live)。如果一个对象变成了孤岛,与程序活动数据的主大陆完全隔绝,那么它就是​​垃圾​​(garbage)。

因此,我们的任务与其说是寻找垃圾,不如说是寻找宝藏。垃圾就是所有剩余的东西。

伟大的追踪:寻找存活对象

最基础的垃圾回收方法是描绘出存活对象的网络。这个发现之旅总是从一组已知的起点开始,称为​​根集合​​(root set)。这些根是程序可以直接访问的指针——存储在 CPU 寄存器、程序执行栈(用于追踪活跃的函数调用)或作为全局变量的引用。从这些根出发,我们就可以开始伟大的追踪。

想象内存堆是一个图,其中对象是节点,指针是有向边。回收器的第一步是​​标记阶段​​(mark phase):从每个根开始,它跟随每一个指针,再从那里跟随每一个指针,如此反复,在它访问的每个对象上都做一个比喻性的“标记”。它使用类似广度优先搜索的方法遍历图,直到访问完所有从根可达的对象。当这个过程完成时,堆中所有存活的对象都已被标记。

接下来是​​清除阶段​​(sweep phase)。回收器从头到尾扫描整个堆。任何没有标记的对象都是不可达的——也就是垃圾。回收器回收这部分内存,使其可用于创建新对象。这个优雅的两步过程被称为​​标记-清除​​(Mark-and-Sweep)。它保证了任何存活的对象都不会被错误地丢弃,但这需要付出代价。在回收器工作时,程序必须完全暂停——这是一个“stop-the-world”(全局暂停)事件。此外,清除之后,可用内存常常被分割成许多不连续的小洞,就像一片瑞士奶酪。这使得分配一个大的新对象变得困难。

另一种哲学:计算你的朋友

有没有办法避免这些干扰性的暂停呢?一种完全不同的哲学是​​引用计数​​(reference counting)。这个想法非常简单且局部化。它不进行全局搜索,而是让每个对象维护一个指向它的指针数量的计数器——即它的“引用计数”。当一个指向某对象的新指针被创建时,其计数器加一。当一个指针被销毁时,计数器减一。如果一个对象的计数器降至零,就意味着再也没有指针指向它了。它没有朋友了;它是垃圾,可以被立即回收。

这种方法提供了一个美妙的优势,即它是增量式的。垃圾一经产生就能被回收,以微小、不易察觉的步骤进行,而不是在一次长时间的暂停中一次性完成。但这种简单的美背后隐藏着一个致命的缺陷:​​循环引用​​(cycles)。想象两个对象互相指向对方,但外界没有任何东西指向它们中的任何一个。每个对象的引用计数都是一,所以系统认为它们都是存活的。它们是一个孤立的社会,靠着相互欣赏而存活,但对程序的其余部分毫无用处。

处理这些循环引用需要更复杂的技术。一种方法是定期运行一个循环检测器,对一组可疑对象执行“试验性删除”。它假设性地忽略该组内部的引用,并重新计算引用计数。如果计数降至零,那么整个循环确实是垃圾,可以被回收。

移动的艺术:复制的力量

让我们回到追踪式回收器和碎片化的问题。一个激进且强大的解决方案是​​复制回收​​(copying collection)。与其清理当前的工作空间,不如把我们需要的一切都搬到一个全新的空间里去?

在这种方案中,堆被分成两个相等的部分,或称​​半空间​​(semispaces):一个“from-space”和一个“to-space”。新对象在 from-space 中分配。当 from-space 被填满时,回收开始。回收器像之前一样追踪存活对象,但不是标记它们,而是将它们复制到空的 to-space。在复制每个对象时,它会更新所有指向该对象的指针,以反映其新位置。

一旦所有存活对象都被疏散,一件非凡的事情发生了。所有存活数据现在都整齐地打包在 to-space 的起始位置,完全没有碎片。那么 from-space 呢?它只包含垃圾和存活对象的旧副本。整个 from-space 可以瞬间被清空。然后,两个半空间的角色互换,程序继续运行。

这种方法的真正天才之处在于其性能特征。复制回收器所做的工作量与它扫描的内存总量无关,而与它复制的存活数据量成正比。如果只有很少的对象在一次回收中存活下来,这个过程就会快得惊人。我们甚至可以用一个简单的公式来模拟每次分配的摊销工作量。如果堆的总大小是 HHH,存活数据的大小是 LLL,那么成本与 2LH−2L\frac{2L}{H-2L}H−2L2L​ 成正比。这个方程揭示了一个深刻的真理:只要存活数据量 LLL 很小,成本就很低。但当 LLL 接近堆总大小的一半时,分母趋近于零,成本急剧上升。这告诉我们,复制是一种绝佳的策略,但它适用于对象生命周期较短的堆。

年轻的智慧:分代回收

我们现在有了两种强大的追踪策略:标记-清除,它能优雅地处理有许多长寿对象的堆;以及复制,它在对象“朝生夕死”时表现出色。这种二元性引出了一种深刻的综合,它基于一个关于计算机程序的简单经验观察,即​​分代假说​​(generational hypothesis):大多数对象都很年轻就消亡。

这一洞见是​​分代垃圾回收​​(generational garbage collection)的基础。堆通常被划分为一个​​新生代​​(young generation)和一个​​老年代​​(old generation)。所有新对象都在新生代中诞生。因为它们中的大多数会很快消亡,我们可以使用快速的复制回收器频繁地回收新生代。这被称为​​次要回收​​(minor collection)。其存活率(我们可以用概率 ppp 来建模)很低,这完美地发挥了复制算法的优势。

一个在数次次要回收中存活下来的对象被认为可能是长寿的。它获得了“终身职位”并被​​晋升​​(promoted)——从新生代移到老年代。老年代充满了这些顽强的幸存者,其回收频率要低得多,通常使用标记-清除回收器,这种回收器可以处理高密度的存活对象,而不会产生复制它们的极端成本。

这个分层系统似乎为我们提供了两全其美的方案,但它引入了一个新的、微妙的问题。如果老年代中的一个对象指向新生代中的一个对象怎么办?在进行次要回收时,我们不能为了找到这些指针而扫描整个庞大的老年代。为了解决这个问题,系统必须采用​​写屏障​​(write barrier)。这是由编译器插入的一小段代码,在程序每次写入指针时运行。如果这次写入创建了一个从老对象到新对象的指针,写屏障会将此信息记录在一个称为​​记忆集​​(remembered set)的特殊列表中。在次要回收期间,回收器将这个记忆集视为其根集合的一部分,从而确保没有新生代对象仅仅因为它与外界的唯一联系来自老年代而被丢弃。

分代回收的优雅需要仔细调优。例如,如果我们过于急切地晋升对象,一个“中等寿命”的对象可能会被移到老年代,但很快就死亡,留下一个碎片空洞。解决方案是增加​​晋升阈值​​(tenuring threshold)——即一个对象在晋升前必须存活的回收次数——这样这些对象就会在仍然处于紧凑的新生代时消亡。系统还必须能应对奇怪的极端情况,比如​​对象复活​​(object resurrection)。如果一个对象的终结代码通过创建一个从老年代到新生代的指针而使其再次变得可达,那么该代码也必须执行写屏障。可达性规则必须被系统的每个部分毫无例外地遵守。

永不停歇的机器:精确式与并发回收

对于像实时图形、金融系统或繁忙的 Web 服务器这样的应用,即使是次要回收的短暂暂停也是不可接受的。最后的疆域是让回收器​​并发​​(concurrently)运行,与应用程序同时进行,且干扰最小。这就像在人们正在房间里积极工作时试图整理房间——一个极其复杂的挑战。

为了管理这种复杂性,回收器使用一个称为​​三色不变性​​(tri-color invariant)的形式化模型。对象在概念上被着色为白色(未访问)、灰色(已访问,但其子节点尚未扫描)或黑色(已访问且其所有子节点都已扫描)。为防止回收器错过任何对象,系统必须遵守一条关键规则:永远不允许黑色对象指向白色对象。如果应用程序(即“修改器”mutator)试图创建这样一个指针,​​写屏障​​(write barrier)必须拦截该操作。写屏障会“通知”回收器,通常通过将白色的目标对象涂成灰色,以确保它不会被错过。

这些在每次指针写入时都会运行的写屏障必须非常快。现代系统使用巧妙的优化。​​卡片标记​​(card marking)屏障不会记录每一次写入,而只是在某个较大固定大小的内存区域(一个“卡片”)内的指针被修改时,将该区域标记为脏。然后,回收器只需要重新扫描这些脏卡片。这可以进一步优化,让每个线程在其本地的​​存储缓冲区​​(store buffer)中批量处理其脏卡片通知,然后一次性将它们刷新给回收器,这在特定的 GC 策略下是安全的。

即使是并发回收器也需要同步的时刻。它需要在回收开始时知道程序的根在哪里。它通过要求所有应用程序线程在指定的​​安全点​​(safepoints)短暂暂停来实现这一点。但如果一个线程卡在一个长计算中而没有到达安全点怎么办?现代运行时有一个优美的升级策略:它们给线程一个时间限制,如果超时,运行时会使用操作系统信号抢先中断该线程,强制它报告其根,然后让它恢复。

这种复杂的协作之所以可能,是因为运行时和编译器之间有深厚的契约。为了做到​​精确​​(precise),GC 必须知道栈上每个指针的确切位置。编译器通过每个安全点的​​栈映射​​(stack maps)提供此信息。精确式回收器比​​保守式​​(conservative)回收器更安全、更高效,后者必须猜测内存中的某个数字是否可能是一个指针。这种契约的复杂性在处理动态大小的栈帧时尤为突出,此时指针的位置可能成为一个运行时值的函数。栈映射本身必须变成一个动态公式,这证明了我们构建的机器能够在创造者的一点帮助下自我清理,是多么卓越的工程成就。

应用与跨学科联系

在领略了垃圾回收机制的精妙之后,人们可能会留下这样的印象:它只是我们编程语言中一个非常聪明但终究是内部的管道系统,一个在幕后清理内存的看门人。但这种看法虽然并非不正确,却只见树木,不见森林。自动内存管理不仅仅是一种便利,它是一个基础支柱,整个软件设计、编译器优化和高性能系统的范式都建立在其之上。它的原则向外涟漪,塑造着我们编写代码的方式、我们的工具如何推理代码,以及我们如何征服计算前沿的挑战。现在让我们来探索这片更广阔的景观,看看“简单”的回收内存行为如何成为一门艺术、一门科学,并成为现代技术的关键推动力。

程序员的沉默伙伴:GC 如何塑造我们的代码

对于日常程序员来说,垃圾回收器是一个沉默的伙伴。它赋予了一种深远的自由:创建、连接和组合复杂数据结构的自由,而无需承担手动内存记账的痛苦负担。考虑实现一个像 B 树这样的经典复杂数据结构。在没有自动内存管理的语言中,删除一个节点需要一套一丝不苟的 free 或 delete 调用仪式,这个过程充满了危险,一步走错就可能导致悬垂指针或内存泄漏。而在像 Java 或 Python 这样的托管语言中,过程却异常简单。当删除操作中两个节点合并时,程序员只需通过从其父节点中移除对旧节点的引用来“拔掉”它。就这样。剩下的就是魔法了。垃圾回收器看到该节点现在是对象图中的一个不可达孤岛,将在未来的某个时间自动安全地回收其内存。这种解放性的抽象使得开发者能够专注于他们算法的逻辑,而不是内存分配的危险细节。

然而,这种自由也带来了一类新的、微妙的挑战。垃圾回收器是逻辑学家,不是读心者。它回收的是不可达的,而不是不需要的。这种区别是一种常见且令人烦恼的错误的根源:逻辑内存泄漏。想象一下,你编写了一个看似无害的小函数——一个闭包——并在你的程序中传递它。你可能没有意识到,这个闭包,就像一个人一样,有它诞生地的记忆。它随身携带一个环境,其中包含它从其创建作用域中需要的变量的引用。如果那个环境恰好包含了一个对一个巨大的、数兆字节大小的配置对象的引用,这个闭包就会紧紧抓住那个对象。即使闭包本身只使用了从那个大对象派生出的一小部分信息,它仅仅是可能访问该对象的潜力就让整个对象保持存活。你以为你拿着一把钥匙,实际上你却阻止了整座大厦被拆除。这就是闭包隐藏的空间复杂度,一个经典的陷阱,一个微小而长寿的对象可能无意中通过保持一个巨大的、逻辑上已死的对象可达而导致大规模的内存泄漏。

同样这种意外持有的原则也困扰着现代的事件驱动系统。在一个带有图形用户界面的应用程序中,你可能会创建一个临时对象来监听按钮点击。你将这个监听器注册到一个全局事件分发器上。如果你在不再需要它时忘记注销它,全局分发器将永远保持对它的引用。这个监听器对象就成了一个“僵尸”——它再也不会被使用,但因为它从一个全局根可达,所以永远无法被回收。这个问题在像基于 actor 模型的并发系统中被放大了。一个收到“毒丸”消息的 actor 应该优雅地终止。如果由于一个错误,它没有停止并继续处理消息,它就成了一个僵尸 actor。更糟糕的是,如果它的工作是管理传入请求的状态,它可能会继续在内部映射中累积状态,但停止处理本应清除该状态的完成消息。结果是无限扩大的资源泄漏,这是一个对象未能正确管理其生命周期并与存活世界断开连接的直接后果。在这些系统中,解决方案通常涉及稳健、协调的“排空并停止”(drain-and-stop)协议,这证明了尽管 GC 是自动的,但为其进行设计却是一种有意识的行为。

编译器的策略:避免产生垃圾的艺术

如果内存管理的第一法则是清理你的垃圾,那么第零法则就是首先不要制造垃圾。这是现代优化编译器的口头禅。最高效的垃圾回收就是没有垃圾回收。编译器,作为程序员高度感知和逻辑化的助手,采用一种称为​​逃逸分析​​(escape analysis)的强大技术来实现这一点。

编译器扮演侦探的角色,分析函数内创建的每个对象的生命周期。它问一个简单的问题:“这个对象,或任何对它的引用,是否曾‘逃逸’出这个函数调用的范围?”一个对象可能通过返回给调用者、存储在全局变量中或传递给另一个线程而逃逸。如果编译器能证明一个对象永不逃逸——即它的整个生命周期都包含在函数执行之内——它就可以执行一个神奇的优化。它不是在堆上分配对象(这是一个相对较慢的过程,会为垃圾回收器创造未来的工作),而是在函数的栈帧上分配它。栈分配速度极快,并且当函数返回时,清理是即时和自动的。不涉及任何 GC。

程序员可能会通过常见的模式无意中阻碍这种优化。一个经典的例子是“自我注册”,即一个新创建的对象将对自身的引用添加到一个全局注册表中。从编译器的角度来看,这个对象立即逃逸到了一个全局作用域,迫使它被分配在堆上。一个了解这一点的聪明开发者可以重构代码。他们可能不注册对象引用,而是注册一个简单的、不可变的标识符。现在,全局注册表不再指向该对象,打破了逃逸路径,并允许编译器在对象不以其他方式逃逸的前提下,潜在地在栈上分配该对象。

当情况模棱两可时,现代编译器的真正天才之处就显现出来了。如果一个对象逃逸了,但只在一个非常罕见的代码路径上?一个天真的分析将不得不保守地每次都将对象放在堆上。但一个先进的、基于性能剖析引导的编译器可以进行博弈。它观察到,在典型的运行中,某个分支的执行频率只有 1%1\%1%。编译器于是可以生成推测性地在栈上分配对象的代码,为 99%99\%99% 的常见情况进行优化。然后在罕见的、会发生逃逸的路径前插入一个小的运行时守卫。如果该路径被执行,守卫会触发一次“去优化”(deoptimization):一个特殊的代码序列,在逃逸发生前,根据其在栈上的字段,在堆上快速地具象化该对象。这个策略在常见路径上赢得了巨大的性能,同时在所有路径上保持了正确性,展示了静态分析和动态运行时行为之间美妙的协同作用。

系统架构师的前沿:将性能推向极限

当程序员和编译器努力减少 GC 压力时,系统架构师则在解决一个更难的问题:让垃圾回收器本身更快、更高效、侵入性更小。在高性能服务器、交互式应用和大规模数据处理的世界里,天真的“stop-the-world”(全局暂停)方法——即在回收周期中完全冻结应用程序——是完全不可接受的。

现代运行时系统的核心挑战是实现低延迟垃圾回收。想象一个回收器需要扫描 72 MB72\,\mathrm{MB}72MB 的线程栈以寻找根。在一台内存吞吐量为 8 GB/s8\,\mathrm{GB/s}8GB/s 的机器上,一个简单的 stop-the-world 扫描大约需要 9 ms9\,\mathrm{ms}9ms。对于一个每秒处理数千个请求的 Web 服务器或一个流畅的图形应用程序来说,9 ms9\,\mathrm{ms}9ms 的冻结是永恒。解决方案是一场复杂的并发之舞。现代回收器使用​​协作式安全点握手​​。GC 不会强制停止线程,而是请求它们暂停。每个线程继续运行,直到到达一个方便的“安全点”,在那里它做最少的工作(比如保存其寄存器状态),然后短暂地等待所有其他线程跟上。这个同步暂停非常短,通常不到一毫秒。在这个短暂的停止期间,回收器安装写屏障等机制,然后让应用程序线程恢复。GC 的大部分工作,如标记对象图,都是在应用程序运行时并发执行的。正是这种工程奇迹,使得像 Java 虚拟机这样的运行时能够提供以微秒而非毫秒计的暂停时间,从而使高级托管语言甚至适用于最苛刻的低延迟领域。

垃圾回收的原则是如此基础,以至于它们在一些意想不到的地方找到了应用,比如在 GPU 计算这个高风险的世界里。在一个使用 GPU 进行通用计算的系统中,数据必须在 CPU 的主机内存和 GPU 的设备内存之间通过相对狭窄的 PCI Express (PCIe) 总线来回穿梭。在这里,架构师可以应用分代假说:一些数据,如大的纹理,是长寿的,应该放在 GPU 上的“老年代”。其他数据,如并行计算的中间缓冲区,通常是短寿的,属于“新生代”。当新生代发生回收时,存活的对象必须被疏散到老年代,而老年代可能在主机的内存中,这会产生昂贵的 PCIe 传输成本。这就构成了一个有趣的优化问题:我们应该多久进行一次次要回收?回收太频繁意味着我们可能在疏散那些本应很快死去的对象,浪费了带宽。回收太不频繁意味着新生代会变大,可能导致一次巨大的、突发性的传输,使总线饱和。通过对对象存活率和可用带宽进行建模,系统设计者可以推导出一个最佳的回收间隔,从而最小化数据传输并最大化系统吞吐量。这是一个将 GC 理论应用于管理硬件资源约束的优美例子。

GC 的影响甚至延伸到对象在内存中的布局。在面向对象的语言中,一个虚方法调用通常是通过在“虚方法表”(vtable)中查找函数地址来实现的。每个对象都有一个隐藏的指针 vptr,指向其类的 vtable。如果这些 vtable 被当作堆上普通的可移动对象,那么每当 vtable 被移动时,复制回收器就需要找到并更新整个系统中每一个 vptr。更糟糕的是,写屏障可能会被不必要地触发。一个更优雅的解决方案是将 vtable 放置在一个特殊的、不可移动的、甚至是只读的内存区域。通过使 vptr 的目标不可变且固定,该指针可以被视为非 GC 指针。它永远不需要被回收器更新,对它的写入也不需要被屏障拦截。这个简单的设计选择为 GC 消除了大量工作,展示了语言的对象模型和其内存管理策略之间深度的协同设计。

一个无形秩序的宇宙

从程序员的键盘,到编译器的优化,再到系统架构师的宏伟设计,自动垃圾回收的原则提供了一条统一的线索。这个领域不断提醒我们,看似简单的清理任务,实际上是一个在动态信息宇宙中追踪可达性、生命周期和连接性的深层问题。

或许,对于最隐蔽的内存问题——逻辑泄漏——最直观的类比并非来自计算机,而是来自人类组织。想象一个官僚机构,为了可审计性不断增加新规则。每条新规则都被添加到一个全局注册表中,并且永远不会被移除,因为“我们将来可能需要查看它”。每条规则的维护都有成本。随着时间的推移,这个组织被不断增长、线性累积的规则所窒息,其中大部分已经过时,但却因被“全局注册表”保持可达而存在。这个系统表现出了内存泄漏。这正是我们软件中发生的事情。垃圾回收使我们摆脱了手动释放内存的束缚,但并未免除我们思考的责任。它挑战我们去设计具有清晰生命周期和干净所有权的系统,以确保当一个对象的使命完成时,它被真正地放手,让那优雅的、自动化的机器恢复内存宇宙的秩序。