try ai
科普
编辑
分享
反馈
  • 标记-清除回收

标记-清除回收

SciencePedia玻尔百科
核心要点
  • 标记-清除垃圾回收将对象和指针视为一个图,通过从一组“根”出发,找出所有可达的数据,从而识别存活的内存。
  • 经典算法包含一个“标记”阶段,其成本与存活数据量成正比;以及一个“清除”阶段,其成本与总堆大小成正比。
  • 先进的并发回收器使用三色标记法和写屏障,以最小化应用程序暂停时间的方式运行,从而防止“对象丢失”问题。
  • 通过可达性来确定存活状态的原理是一种基本模式,可见于构建系统、数据库清理(vacuuming)乃至区块链状态管理等领域。

引言

自动内存管理,即垃圾回收,是现代软件开发的基石,它默默地将程序员从复杂且易于出错的手动内存清理任务中解放出来。然而,一个系统如何能在不干扰自身运行的情况下,高效地识别并回收未使用的内存,这是一个深远的计算挑战。这一挑战正是标记-清除算法的核心所在,该算法是垃圾回收领域历史最悠久、影响最深远的方法之一。本文将揭开这一优雅技术的神秘面纱,引导您从基础理论走向真实的工程复杂性。

旅程始于“原理与机制”一章,我们将在此探讨内存作为一个相互连接的对象图。您将学习到,简单的可达性概念如何定义了什么是“存活”的,什么是“垃圾”,以及标记和清除这两个阶段如何执行这一原则。我们还将揭示并发回收的复杂舞蹈,它允许垃圾回收器与主应用程序并行工作。随后,“应用与跨学科关联”一章将拓宽我们的视野,展示标记-清除的核心思想如何远远超越内存管理,影响着从编程语言设计到数据库和区块链架构的方方面面。

原理与机制

要理解计算机如何能自我清理,我们必须首先改变对内存的看法。它不仅仅是一个庞大、单调的地址列表。相反,可以把它想象成一个充满活力、相互连接的信息宇宙。正是这一关键洞见,将杂乱的内存管理问题转变为一场穿越图的优雅旅程。

指针的世界:内存即图

在现代编程语言中,我们不仅存储数字和字母,还创建“对象”——这是复杂的数据包,可以包含信息,并且至关重要的是,可以指向其他对象。一个用户资料对象可能指向一个朋友对象列表,而每个朋友对象又指向其他资料。一张发票对象可能指向一个客户对象和一个行项目对象列表。

如果我们将此可视化,一个优美的结构便浮现出来。每个对象都是一个​​节点​​(node)或​​顶点​​(vertex),构成一个巨大、蔓延的网络。从一个对象指向另一个对象的每个指针都是一条有向​​边​​(edge),即一个从一个节点指向另一个节点的箭头。

但这一切从何而起?并非每个对象都被另一个对象所指向。有些对象由程序本身直接持有——存在于调用栈的局部变量中、全局变量中,甚至在 CPU 的寄存器中。这些便是我们图中的​​根​​(roots)。它们是通往现实的锚点,是程序在任何特定时刻可以直接访问的对象。

从这个模型中,一个强大而唯一的真理原则应运而生:​​一个对象是存活的,当且仅当它可以从一个根出发,沿着一条指针路径被访问到。​​

如果一个对象是可达的,那么程序理论上就可以访问它。它是存活的。如果没有任何从根到该对象的路径,那么它就迷失在虚空中,无法访问且毫无用处。它就是垃圾。整个垃圾回收的科学都建立在这个简单、基于图论的存活性定义之上。

盛大的巡游:标记阶段

有了这个原则,任务就变得清晰了:我们需要一种方法来系统地探索这个图,以找到每一个可达的对象。这种探索是我们回收周期的第一个阶段,即​​标记阶段​​(Mark Phase)。

想象一下,将一块石头扔进平静的池塘,涟漪向外扩散,触及水面的每一处。标记阶段的工作方式与此非常相似。它从所有根的集合开始,将它们“标记”为存活。然后,它查看这些根指向的所有对象,并将它们也标记起来。接着,它再查看它们指向的对象,依此类推,遍历整个指针网络,直到每个可达的对象都被访问并标记。

像广度优先搜索(BFS)或深度优先搜索(DFS)这样的算法非常适合这项工作。BFS 像池塘中的涟漪一样,逐层探索图。而 DFS 则会沿着一条指针路径尽可能深地探索,然后再回溯以探索其他分支。

遍历算法的选择揭示了一个经典的工程权衡。递归实现的 DFS 代码写起来非常简洁,但它依赖程序的调用栈来跟踪其路径。如果你有一个非常长的指针链——一个对象指向另一个对象,再指向下一个,如此持续数千层——你可能会耗尽调用栈有限的内存,导致致命的“栈溢出”。一个更稳健的解决方案是迭代实现的 DFS,它使用自己显式的栈,这个栈通常分配在广阔而灵活的堆空间中。对于内存极度受限的情况,计算机科学家甚至发明了像 Deutsch-Schorr-Waite 这样的“神奇”指针反转算法,它可以通过临时反转指针来记住返回路径,从而在几乎不使用额外内存的情况下遍历整个图。

标记阶段最深刻的特性是其效率。它所需的时间与存活对象的数量及其连接指针成正比,而不是与内存堆的总大小成正比。如果你的程序只使用了千兆字节(gigabyte)大小的堆中的一小部分,标记过程将会非常快,因为它只巡游内存中真正重要的部分。

这个阶段也可以变得“更智能”。如果系统知道每个对象的布局——例如,一个“TreeNode”对象有两个指针字段,而一个“ByteArray”则没有——它就可以精确地只读取指针,跳过大块的非指针数据。这种“精确”标记显著减少了回收器需要读取的内存量,使其速度快得多。

大扫除:清除阶段

一旦标记阶段完成,堆中的每个对象都处于两种状态之一:已标记(存活)或未标记(垃圾)。现在是第二幕:​​清除阶段​​(Sweep Phase)。

与标记阶段有针对性的巡游不同,经典的清除阶段是一场从头到尾、详尽无遗的蛮力行军,遍历整个堆。它检查每个对象,或每个潜在的对象槽。如果一个槽中存放着一个已标记的对象,标记就会被清除,为下一个周期做准备,然后清除器继续前进。如果它发现一个未标记的对象,它就知道这块内存可以被回收。

这便是传统标记-清除回收器的阿喀琉斯之踵。清除阶段的成本与堆的总大小(HHH)成正比,而不是与存活数据的数量(LLL)成正比。即使千兆字节的堆中只有几兆字节(megabytes)在使用,回收器仍必须花费时间扫描那千兆字节的每一个字节来寻找垃圾。总暂停时间可以建模为一个简单的总和:Tpause=cmL+csHT_{\text{pause}} = c_{m} L + c_{s} HTpause​=cm​L+cs​H,其中 cmc_mcm​ 和 csc_scs​ 分别是代表标记和清除成本的常数。

“回收”内存到底意味着什么?清除器将被释放的内存块添加到一个名为​​空闲列表​​(free list)的数据结构中。当程序需要分配一个新对象时,它可以迅速从这个列表中获取一个合适的块。这个空闲列表的设计是另一个充满权衡的迷人领域。

如果所有对象大小相同,空闲列表可以是一个简单的链表。释放和分配都是常数时间操作,速度极快。但在大多数真实世界的程序中,对象大小各异。这时,清除阶段就变得更加复杂。当一个块被释放时,分配器可能会检查其相邻的块是否也为空闲,从而将它们​​合并​​(coalescing)成一个更大的单一块。为了管理这些可变大小的块,它可能会使用更复杂的数据结构,如平衡二叉搜索树,来按大小跟踪可用块。这增加了清除阶段的开销,每次回收可能需要 O(log⁡F)O(\log F)O(logF) 的时间而不是 O(1)O(1)O(1), 其中 FFF 是空闲块的数量。一种常见的实用方法是为不同的大小类别(size classes)维护独立的空闲列表,这样可以为常见大小的对象实现非常快速的常数时间分配。

并发的舞蹈:在不停止世界的情况下进行标记

我们所描述的简单标记-清除算法最大的问题是“大暂停”。为了让回收器获得内存图的一致视图,它必须完全停止应用程序——即“stop-the-world”暂停。对于桌面应用程序来说,几百毫秒的暂停可能只是屏幕一闪。但对于高频交易系统或每秒处理数千个请求的Web服务器来说,这简直是永恒。

这就引出了一个巨大的挑战:垃圾回收器能否在应用程序(即​​修改器​​,mutator)仍在运行并改变指针图的同时,并发地完成其工作?答案是肯定的,但这需要一场小心而优雅的舞蹈。

为了推理这场舞蹈,我们使用​​三色标记法​​(tri-color marking)这一抽象。在任何时刻,每个对象都处于以下三种颜色之一:

  • ​​白色:​​ 初始状态。回收器尚未见过的对象。在标记结束时,任何仍然是白色的对象都是垃圾。
  • ​​灰色:​​ 回收器已经见过,但其子对象(它指向的对象)尚未全部被扫描的对象。灰色集合是回收器的“待办事项”列表。
  • ​​黑色:​​ 回收器已经见过,并且其所有子对象都已被扫描的对象。回收器已经完成了对这个对象的处理。

标记过程就像一道颜色波席卷整个图:根节点开始时是灰色的,然后回收器重复地选取一个灰色对象,将其白色的子对象涂成灰色,最后将自己涂成黑色。

当修改器进行干预时,危险就出现了。想象一下以下灾难性的序列:

  1. 回收器已经完成了对一个黑色对象 B 的扫描。
  2. 存在一个灰色对象 G,它持有指向一个白色对象 W 的唯一指针。
  3. 现在修改器改变了图:它将指向 W 的指针从 G 移到了 B。程序现在有了一个从 B 到 W 的指针。
  4. 然后修改器删除了从 G 到 W 的指针。

回收器既然已经处理完 B,就永远不会重新扫描它去发现指向 W 的新指针。而且由于从 G 出发的指针已经消失,W 也将永远不会被发现。它将保持白色,并被错误地清除掉,尽管它实际上是可达的。这就是可怕的“对象丢失”问题。

并发标记的全部正确性都取决于维持一个简单的不变式:​​黑色对象永远不能指向白色对象。​​

为了强制执行这个不变式,我们使用​​写屏障​​(write barriers)。这些是由编译器自动插入的微小代码片段,每当程序向内存中写入一个指针时就会执行。这个屏障就像一个哨兵,在发生潜在危险的写入时通知回收器。当写屏障检测到从一个黑色对象 x 指向一个白色对象 y 的指针被创建时,它可以通过以下两种方式之一来纠正这种情况:

  1. ​​增量更新屏障 (Incremental Update Barrier):​​ 屏障可以将目标对象 y 涂成灰色。这就像告诉回收器:“我知道你已经处理完 x 了,但我刚把这个新的白色对象 y 连接到它上面。请把 y 加入你的待办事项列表。” 这条边变成了 black→gray\text{black} \to \text{gray}black→gray,这是安全的。

  2. ​​初始快照屏障 (Snapshot-at-the-Beginning Barrier):​​ 屏障可以转而将源对象 x 再次涂成灰色,将其放回待办事项列表中以便重新扫描。这就像说:“等一下,回收器!这个黑色对象 x 变了。你需要回来再看看它。” 这条边变成了 gray→white\text{gray} \to \text{white}gray→white,这也是安全的。

这些屏障不仅仅是理论上的构造。它们对于现实世界中的功能至关重要,例如动态代码热交换(hot-swapping),在这种情况下,一个正在运行的程序可能会更新一个虚方法表(一个黑色对象)以指向一个新版本的方法(一个白色对象),如果没有写屏障来保护它,这种情况将是灾难性的。

进步的代价:不完美与权衡

这场并发之舞功能强大,但并非没有代价。它引入了自身微妙的成本和妥协。

其中一个成本是​​浮动垃圾​​(floating garbage)。并发回收器通常基于周期开始时的堆“快照”工作。如果一个对象在快照时是存活的,但在标记阶段仍在进行时变得不可达,它仍将根据旧快照被标记为存活。它要到下一个GC周期才会被回收。这个未被回收的对象被称为浮动垃圾。浮动垃圾的数量是一种权衡:我们接受一些暂时的内存浪费,以换取低延迟的回收。模型显示,浮动垃圾的比例随着标记周期的持续时间以及对象“死亡”速率的增加而增加,这一关系可以用表达式 1−exp⁡(−μΔt)1 - \exp(-\mu \Delta t)1−exp(−μΔt) 优雅地捕捉。

另一个隐藏的危险在于一种名为​​终结器​​(finalizers)或析构函数(destructors)的语言特性。当一个带有终结器的对象变得不可达时,它不能被立即回收。相反,它被放入一个特殊的队列中,其终结器代码会被运行。这可能导致不可预测且可能无限的暂停。想象一下,对象A的终结器在清理过程中使得对象B变得不可达,而B也有一个终结器。运行B的终结器又使得C变得可终结,以此类推。这会产生一个终结工作的“菊花链”(daisy chain)。这样一个链的预期长度可以通过几何级数来建模,揭示出如果一个终结器触发另一个终结器的概率哪怕很小(为 ppp),预期的总终结时间也可能急剧增长,与 11−p\frac{1}{1-p}1−p1​ 成正比。这鲜明地提醒我们,语言层面的特性可能与底层的内存管理机制产生深刻而出人意料的交互。

从一个简单的图中可达性原则出发,我们穿越了一片由算法、权衡和巧妙工程构成的风景。标记-清除回收器不是一个单一、庞大的实体,而是一个思想家族,是人们在应用性能与保持内存清洁这一沉默而必要的工作之间寻求完美平衡的持续探索的证明。

应用与跨学科关联

在理解了标记-清除算法优雅的两步舞之后,人们可能会倾向于将其归档为针对计算机内存中一个特定问题的巧妙但狭隘的解决方案。但这样做将只见树木,不见森林。其核心原则——即通过追溯某个事物与一组基本“根”的连接来确定其是否“存活”——是整个计算机科学中最强大、最常出现的模式之一。它是许多机器中的幽灵。

在本章中,我们将踏上一段寻找这个幽灵的旅程。我们将从它的原生栖息地——编程语言的运行时引擎——开始,看看简单的标记-清除思想是如何被设计成构建高性能系统的复杂工具。然后,我们将走向更远的领域,发现它在构建系统、数据库乃至区块链技术前沿令人惊讶的投影。你将看到,我们所学的不仅仅是关于内存;它是关于结构、依赖关系以及“存活性”本身的本质。

机器之心:构建高性能运行时

让我们从需求最明显的地方开始。一个程序运行,创建对象,使用它们,然后丢弃它们。垃圾回收器的工作就是当好清洁工。但它应该如何清理呢?标记-清除方法只是一种策略。一个主要的替代方案是“复制”回收器,它将所有存活的对象从一个内存区域 evacuated(疏散)到另一个区域,使得旧区域只剩下垃圾,可以瞬间被清空。

哪一个更好?这是一个权衡问题,一个经典的工程难题。标记-清除回收器的工作量与存活对象的数量及它们之间的指针成正比。而复制回收器除了要做所有这些工作之外,还要额外付出物理移动每一个存活对象的代价。如果你有大量小而短命的对象,复制的成本可能相当可观。一个简单的模型显示,复制回收器和标记-清除回收器之间的性能比率直接取决于存活数据的总大小。复制付出了额外的代价,这个代价随着它需要移动的数据量的增加而增长。这个选择并非学术性的;它决定了整个系统的性能特征。

这种性能成本不仅仅是一个抽象的数字;它具有物理现实。在移动设备和庞大数据中心的世界里,每一次计算都会消耗能量。我们可以创建一个详细的设备模型,为每个CPU周期、每个从内存读取或写入的字节,以及每次处理器不得不等待主存数据(即“缓存未命中”)分配一个以焦耳为单位的能量成本。当我们通过这个镜头分析我们的垃圾回收器时,我们发现标记-清除和复制之间的选择不仅仅关乎速度,还关乎电池寿命。一种涉及更多内存写入的策略,比如复制回收器,可能会消耗明显更多的电力。抽象的算法在我们的电费账单和手中手机的温度上留下了具体的足迹。

此外,垃圾回收器并非在和平的真空中运行。它生活在一个繁忙的城市里:操作系统。如果你的程序的内存,即它的“堆”,变得比你的计算机物理内存(RAM)还大,会发生什么?操作系统开始玩一种“杯中猜球”的游戏,将内存块,即“页面”,在速度慢得多的硬盘之间来回交换。现在想象一下,我们的“stop-the-world”标记-清除回收器开始工作。当它尽职地跟踪指针并触及每个存活对象时,它可能会试图访问一个位于当前存放在磁盘上的页面上的对象。整个系统陷入停顿。发生了一次“页错误”(page fault),所有人都等待着数据被缓慢地取回。如果许多页面都在磁盘上,回收器可能会引发一场“页错误风暴”,我们原以为受CPU限制的GC暂停时间,现在被机械磁盘I/O的冰川般速度所主导。理解这种交互至关重要;它告诉我们,一个行为良好的GC必须是操作系统的好公民。

面对这些复杂性,现代系统很少以其教科书形式使用简单的标记-清除算法。它已经进化了。它通常被动地使用:系统分配内存,直到找不到空闲位置,然后才触发一次完整的标记-清除和整理(compaction)周期来清理和碎片整理堆,然后再试一次。“根集合”的定义也变得更加复杂。它不仅仅是我们代码中的变量;它可以包括由其他语言(如C++)编写的、与我们程序交互的本地代码所使用的对象句柄。其中一些句柄是“强”根,必须保持对象存活,而另一些则是“弱”根,如果对象在其他方面不可达,就可以被打破。

最复杂的系统使用混合的、“分代”的回收器。这个思想,被称为分代假说(generational hypothesis),即大多数对象都死得早。因此,堆被分为一个“新生代”和一个“老年代”。垃圾在新生代中被频繁而快速地回收,通常使用复制回收器。存活足够长的对象被“晋升”到老年代,老年代的回收频率较低,通常使用并发标记-清除算法。这种混合方法旨在集各家之所长:大多数回收的暂停时间短,同时仍能清理整个堆。标记-清除算法成为处理构成程序状态稳定骨干的长寿对象的可靠主力。

超越内存:塑造编程本身

到目前为止,我们已经将垃圾回收视为一个清洁工、一个性能工程师和一个系统组件。但它最深刻的角色是解放者。它从根本上改变了编程语言中可以表达的内容。

考虑一下程序通常是如何被调用的。当函数 A 调用函数 B 时,系统为 B 创建一个“激活记录”(或栈帧),并将其放在一个名为调用栈的内存区域中 A 的记录之上。当 B 返回时,它的帧被弹出并销毁。这是一种严格的后进先出(LIFO)原则。栈是一个监狱;函数的状态不能比其执行时间更长。

但如果我们想要捕获一个“续体”(continuation)——程序在某个点的状态快照——并能够在以后返回到它,即使原始函数已经返回了呢?这打破了 LIFO 规则。栈不再足够。解决方案?我们将激活记录分配在堆上,而不是栈上,就像任何其他对象一样。现在,函数的状态可以根据需要存活任意长的时间。但当它不再需要时,由谁来清理它呢?垃圾回收器!通过将栈帧变成堆对象,我们赋予了它们无限的生命周期,由我们熟悉的标记-清除逻辑来管理。函数调用链变成了堆上对象的链表,而一个续体只是指向该链的一个根指针。突然之间,一个全新的、富有表现力的控制流世界向程序员敞开了大门,而这一切都归功于通过可达性管理对象生命周期的简单思想。

其他机器中的幽灵:普适的可达性

这个思想——即某物的价值取决于它是否能从一组基本根追溯到——太过强大,以至于无法局限于内存管理。一旦你学会了看清它,你就会开始在各处看到它。

想象一个软件构建系统。你有源文件、已编译的目标文件、库和最终的可执行文件。这构成了一个依赖图:可执行文件依赖于库,库依赖于目标文件,目标文件由源文件编译而来。现在,你更改了一个源文件。你需要重新编译它,并且你还想清理掉任何不再被任何程序需要的旧目标文件。你怎么知道哪些是过时的?你可以把这看作一个垃圾回收问题!。可执行文件是“根集合”。你可以通过从可执行文件出发,跟踪所有依赖关系来执行一个“标记”阶段。任何未被标记的目标文件都是“垃圾”——它不被任何程序需要——可以被安全删除。这个构建系统正在运行一个垃圾回收器来清理构件。

让我们看另一个更深刻的类比:现代数据库。一个高性能数据库必须同时处理许多事务。它使用一种称为多版本并发控制(MVCC)的技术,其中更新一条数据并不会覆盖它,而是创建一个新版本。这允许一个事务读取一个从它开始时起就一致的数据库“快照”,而不受并发写入者的影响。但这产生了一个问题:数据库中充满了旧的、死掉的数据版本。它们是如何被清理的?通过一个通常称为VACUUM的过程。这个过程实际上就是一个垃圾回收器。它识别哪些版本对任何活动事务都不可见,并回收它们的存储空间。VACUUM过程就是“清除”阶段。确保事务看到一致快照的机制,类似于并发垃圾回收器用来确保回收器看到一致内存快照的“写屏障”。这是一个令人惊叹的趋同进化例子:两个完全不同的领域——编程语言运行时和数据库系统——独立地发现了在并发环境中管理状态的相同基本模式。

我们的最后一站是前沿领域:区块链。在像比特币这样的加密货币中,系统的状态是所有未花费的交易输出(UTXO)的集合。当一个新区块被添加到链上时,一些UTXO被花费(成为垃圾),同时新的UTXO被创建。一个节点需要维护这个集合来验证未来的交易。这看起来很简单:只需删除已花费UTXO的记录。但有一个陷阱。区块链可能会发生“重组”(reorganizations),即链上的最后几个区块被一个新的、有效的替代链所取代。在一个现在被孤立的区块中被花费的输出突然又变回未花费状态!

你如何为这个历史可重写的世界设计一个垃圾回收器?你必须重新定义根集合。“存活”数据的集合不仅仅是当前的UTXO。它是当前的UTXO加上任何在近期可能 подвергнуться重组的区块中被花费的UTXO。根集合必须包括现在和近期过去的“撤销历史”。只有这样,一个并发的标记-清除过程才能安全地识别并回收那些真正古老、可证明已死的交易记录。可达性的简单原则依然成立,但它必须应用于一个连过去都不完全固定的世界。

从管理字节到开启新的编程风格,从编译代码到保障全球金融账本的安全,标记-清除的优雅之舞证明了其价值。它是一个基本的思想,一个美丽的智力机器,一旦被理解,就给了我们一个新的镜头,透过它我们可以看到数字世界隐藏的结构。