try ai
科普
编辑
分享
反馈
  • 复制式垃圾回收

复制式垃圾回收

SciencePedia玻尔百科
核心要点
  • 复制式垃圾回收通过将所有存活对象移动到一个新空间(to-space),并将所有垃圾留在旧空间(from-space),从而回收内存。
  • Cheney 算法通过一种高效的广度优先搜索来实现这一过程,它利用 to-space 本身作为工作队列,从而避免了深度递归。
  • 该过程自然地实现了内存整理,从而支持极快的“指针碰撞”分配,并通过增强缓存局部性来提升程序速度。
  • 尽管它会使用双倍内存,但复制式 GC 的移动特性内在地防止了“释放后使用”(use-after-free)错误,并有助于挫败旁道攻击等安全威胁。

引言

在复杂的软件工程世界中,管理内存——任何运行中程序的命脉——是一项关键而持久的挑战。低效的内存管理可能导致程序变慢、崩溃和安全漏洞。复制式垃圾回收作为一种出人意料地简单而强大的策略应运而生,它并非通过一丝不苟地清理未使用内存来应对这一挑战,而是通过转移并只保留那些至关重要的部分。本文将揭开这种优雅方法的神秘面纱。首先,在“原理与机制”部分,我们将探讨双空间模型的核心哲学,详细解析 Cheney 算法的精妙运作,并权衡其显著的性能优势与内在成本。随后,在“应用与跨学科联系”部分,我们将揭示这一思想如何深刻影响性能工程、系统安全,甚至为解决看似不相关的领域中的问题提供了一个框架。让我们从理解使复制式回收如此高效的基本原理开始。

原理与机制

两个空间的故事:复制的哲学

想象一下,你的房间变得杂乱无章,玩具、书籍和衣物随处散落。你可能会花上数小时来整理,小心翼翼地将每件物品放回原位。但如果有一种更简单的方法呢?假如隔壁就有一个一模一样的空房间。你无需打扫凌乱的房间,只需走进去,捡起你真正需要的几件珍贵物品,将它们整齐地放入新房间。完成后,你只需锁上旧的、凌乱的房间的门,忘记它的存在。之后,你可以让人把它彻底拆除重建,为下一次需要重新开始时做好准备。

简而言之,这就是​​复制式垃圾回收​​背后绝妙、简单而强大的哲学。回收器并不去细致地搜寻和清理“垃圾”(不再使用的内存),而是专注于“珍宝”(仍然存活且可访问的内存)。它将其管理的计算机内存部分——即​​堆(heap)​​——划分为相等的两半。一半是活动的工作区,程序在这里创建和使用其数据结构。我们称之为 ​​from-space​​。另一半,即 ​​to-space​​,则处于休眠状态,等待着,原始而空旷。

当 from-space 变得过于拥挤,没有足够空间容纳新事物时,垃圾回收器便会启动。但它并不“清理”from-space,而是执行一次“疏散”。它识别出所有存活的、有用的数据,将它们复制到 to-space,然后宣布整个 from-space 都成为垃圾。接着,角色互换:现在容纳着整齐排列的基本数据的 to-space,成为程序继续工作的新 from-space。而旧的 from-space 则成为新的、空的 to-space,等待下一个回收周期。这是一种优雅的“更新”而非“修复”的策略。

指针之舞:运行中的 Cheney 算法

那么,回收器是如何执行这次神奇的“疏散”的呢?这个过程最著名、最优雅的编排是 ​​Cheney 算法​​。它是一个绝佳的例子,展示了如何用几条简单的规则和对可用空间的巧妙利用来解决一个复杂的任务。

这个过程必须从识别所有存活数据的起点开始。程序本身持有这些关键信息:任何它可以直接访问的数据都必须是存活的。这些访问点,存在于堆之外的 CPU 寄存器或程序调用栈上,被称为​​根(roots)​​。要让复制式回收器正常工作,它必须是​​精确的​​;它必须绝对确定这些寄存器和栈槽中的哪些值是指针,哪些只是数字。将一个整数误认为指针可能会导致回收器徒劳地追踪,试图从一个无效的内存地址“复制”数据,从而导致整个程序崩溃。同样,漏掉任何一个根指针都可能意味着整个数据结构——也许是那个保存着你所有未保存工作的结构——被遗留在旧空间并永久丢失。

在识别出根之后,这场由两个指针精心编排的“舞蹈”便在 to-space 内部开始。我们称它们为​​扫描指针(scan pointer)​​(sss)和​​空闲指针(free pointer)​​(fff)。两者都从空的 to-space 的最开始位置起步。

  1. ​​疏散根对象​​:回收器首先检查根指针。对于每个指向 from-space 中对象的根,它将该对象复制到 to-space 中 free 指针的位置。然后,它更新 free 指针,将其向前“移动”刚复制对象的大小。最后,原始的根指针被更新,指向这个新位置。

  2. ​​“我们已搬家”的便条​​:接下来是关键的技巧。复制一个对象后,回收器返回到该对象在 from-space 中的旧位置,并用一个​​转发指针(forwarding pointer)​​覆盖其头部——这是一个特殊的标记,意为:“此对象已移动,其新地址是……”。这个转发指针是该算法高效性和正确性的秘诀。它确保了如果我们遇到另一个指向同一对象的指针,我们不会第二次复制它。我们只需读取转发地址并更新该指针即可。这优雅地处理了共享数据结构甚至循环引用(即对象相互指向)的情况。

  3. ​​广度优先扫描​​:现在主循环开始。to-space 中位于 scan 和 free 指针之间的区域充当了一个工作队列。它包含了已被复制但其内部指针尚未被处理的对象。算法简单地重复以下步骤,直到 scan 指针追上 free 指针(s=fs=fs=f):

    • 它查看当前 scan 指针处的对象。
    • 对于此对象内的每个指针字段,它跟随该指针回到 from-space。
    • 它检查在那里找到的对象的头部。如果有一个转发指针,意味着该对象已经被复制。回收器用转发指针中的新地址更新该字段。
    • 如果没有转发指针,那么这是一个新发现的存活对象!回收器将其复制到当前的 free 指针位置,在其旧地址处安装一个转发指针,更新 free 指针,并将刚刚检查的字段更新为这个新地址。
    • 一旦 scan 指针处的对象中所有指针字段都已更新,scan 指针就会被向前移动,越过该对象。它的任务完成了。

当 scan 指针最终追上 free 指针时,队列为空。每个可达的对象都已被复制,并且这些对象内的每个指针都已更新。疏散完成。整个过程是一次对象图的​​广度优先搜索​​,但有一个非凡的转折:它利用 to-space 本身作为队列,避免了使用独立的数据结构,也避免了在处理非常长的数据结构时可能因栈溢出而导致程序崩溃的深度递归。

回报:整理、速度与局部性

为什么要费这么大劲复制所有东西呢?回报是巨大的,并且源于一个单一而美好的结果:​​整理(compaction)​​。在一个回收周期结束时,新的 from-space 包含了所有存活对象,它们完美地紧凑排列在内存区域的起始处,中间没有任何间隙。这个单一、连续的已用内存块之后,是一个单一、巨大的、连续的空闲内存块。

这种原始的布局对性能有着深远的影响。首先,它使得分配新内存变得惊人地快。系统不再需要管理一个由各种大小的空闲块组成的复杂数据结构(即“空闲列表”),而是可以使用​​指针碰撞分配(bump-pointer allocation)​​。要分配一个新对象,它只需检查是否有足够的空间,如果有,就返回空闲指针的当前值,并将其向前“移动”新对象的大小。这个操作的成本极低——仅仅是一次指针加法和一次比较——使得在快速路径上,分配操作只需几条机器指令。这创造了一种美妙的共生关系:垃圾回收,即回收旧内存的行为,直接促成了新内存的闪电般快速分配。

其次,整理通过增强​​缓存局部性(cache locality)​​,极大地提升了程序本身的性能。现代 CPU 的瓶颈不在于其处理速度,而在于它们从主内存获取数据的速度。为了隐藏这种延迟,它们使用小而快的内存缓存来存储最近使用的数据。当程序接下来需要的数据已经存在于缓存中时(即“缓存命中”),程序运行得最快。当一个数据结构中的对象随机散布在内存各处时,遍历该结构会迫使 CPU 不断从遥远的内存位置获取新数据,导致昂贵的“缓存未命中”率很高。

复制式垃圾回收就像是你程序内存的碎片整理器。通过将相关的、存活的对象打包在一起,它使得它们更有可能共享同一个缓存行。设想一个程序正在遍历 6144 个小对象。如果这些对象是碎片化的,每次访问都可能需要获取一个新的缓存行,导致接近 100% 的未命中率。经过一次复制式 GC 将它们打包在一起后,四个对象现在可能装入一个缓存行。第一次访问未命中,但接下来的三次访问保证命中。未命中率从 1.01.01.0 骤降至 0.250.250.25,可能使程序那部分的有效内存访问速度翻两番。

纯粹的代价:空间、时间与身份

当然,在物理学和计算机科学中,没有免费的午餐。复制式回收的优雅伴随着显著的权衡。

最明显的成本是​​空间​​。简单的半空间方案要求始终保持一半的堆完全闲置。这是一个高昂的代价。此外,该方案只有在所有存活对象的总大小小于 to-space 的大小时才能成功。这意味着,如果​​存活率​​——即堆中存活部分的比例——攀升至 50% 以上,回收将因空间不足而失败。这是为什么简单的复制式回收器通常仅用于堆的特定区域(如“新生代”),在这些区域中,大多数对象被预期会迅速死亡。

第二个成本是复制所花费的​​时间​​。标记-清扫回收器的工作量通常与存活对象和指针的数量成正比,而复制式回收器的工作量则与存活数据的总大小成正比。每个存活对象的每一个字节都必须从一个地方移动到另一个地方。这意味着当存活数据量较小时,复制式回收效率最高。

最后,还有一个微妙但深刻的哲学成本:该算法挑战了​​对象身份(object identity)​​的根本概念。在许多编程语言中,每个对象都有一个在其生命周期内持续存在的唯一身份,通常以稳定的哈希码形式暴露出来。但如果一个对象的物理内存地址在每次垃圾回收时都会改变,那么它的身份意味着什么呢?基于内存地址来确定身份已不再可行。使用移动式回收器的运行时必须解决这个问题。一个常见的解决方案是根据对象的初始地址计算哈希值并将其存储在对象内部,确保这个存储的值与对象的其他数据一起被复制。另一种方法是在对象诞生时为其分配一个永久唯一的 ID 号,并将哈希码存储在一个以此 ID 为索引的独立表中。这场“身份危机”完美地诠释了高级抽象与其底层物理实现之间的张力。

为了执行这场精妙的指针之舞,整个世界必须停止。应用程序线程必须在指定的​​安全点(safe points)​​暂停,以确保回收器看到一个一致的内存快照。而且,我们所讨论的简单模型必须被扩展,以处理像指向对象中部而非其开头的指针这样的复杂情况。

尽管有这些成本,复制式垃圾回收的原则——“更新”而非“修复”的理念,整理与分配速度之间的美妙协同,以及由改善局部性带来的切实性能增益——代表了现代内存管理的基石,证明了一个简单而优雅的思想所蕴含的力量。

应用与跨学科联系

我们已经探讨了复制式垃圾回收器那优美而简洁的机制:当内存满时,你不是去寻找垃圾,而仅仅是将珍宝移至一个崭新的、洁净的家。事实证明,这个想法远不止是内存管理的一个巧妙技巧。它的影响贯穿了整个计算机科学领域,影响着我们如何构建快速、安全和健壮的系统。它甚至为我们提供了一个新的视角,用以审视那些表面上与内存毫无关系的问题。现在,让我们踏上一段旅程,看看这一优雅的原则如何与性能工程、系统安全,乃至像爬取万维网这样的抽象问题联系起来。

机器的心脏:性能与系统工程

在其核心,计算机是一台拥有有限资源的物理机器:处理周期、内存带宽和能源。一个算法的真正天才之处,通常取决于它与硬件物理现实的协作程度。事实证明,复制式回收正是这种协作的大师。

编程中最深刻的观察之一是​​分代假说(generational hypothesis)​​:大多数对象都是“朝生夕死”的。想想函数中的所有临时变量,或网络服务器中短暂存在的数据包。复制式回收器在处理这种情况时效率极高。它无需花费时间检查每一个死掉的对象,其成本仅与它必须复制的存活对象成正比。当绝大多数对象都死亡时,这是一个巨大的胜利。这一洞见直接引出了​​分代垃圾回收器(generational garbage collectors)​​,这是像 Java 虚拟机和 .NET 这类现代高性能运行时的中坚力量。“新生代”(young generation),或称“nursery”,是一小块内存区域,新对象在这里诞生,并使用复制算法进行频繁回收。我们甚至可以建立精确的数学模型来调整系统。例如,通过了解新生代中对象的典型存活率(qqq),我们可以确定为达到期望的内存占用率(ρ\rhoρ)所需的“to-space”的最佳大小,这一关系被一个从第一性原理推导出的简单公式优雅地捕捉到。这是工程,而非猜测。

这种与硬件的共舞更加深入,直达操作系统和虚拟内存层面。现代计算机不把内存看作一个巨大的字节列表,而是看作“页”的集合。当程序触及一个新页时,操作系统可能需要做一些工作,甚至可能需要从磁盘加载它,这是一个缓慢的过程。复制式回收器在运行时,会读取分散在 from-space 各个页中的存活对象,并将它们连续地写入 to-space 的一组新页中。这种​​整理(compaction)​​行为有一个美妙的副作用:回收之后,一起使用的相关对象通常在物理上最终会紧挨在一起。这改善了空间局部性,使得后续访问对于 CPU 缓存来说快得多。回收本身会引起页面活动的爆发,但一个紧凑、有序的堆所带来的长期好处通常是值得的。

故事延伸至多核处理器的世界。在一台拥有许多 CPU(或称“核”)的机器上,每个核通常在其私有缓存中保留一份数据的本地副本。当我们的垃圾回收器在一个核上运行时,决定移动一个其他几个核正在查看的对象时,会发生什么?在该对象被移动且其原始位置被修改(例如,为了安装一个转发指针)的瞬间,硬件的缓存一致性协议必须立即行动起来。它向所有其他核发送“失效”消息,告知它们其副本已过时。一次大规模的复制式回收可能会触发此类消息的“一致性风暴”,淹没芯片的互连总线。理解了这一点,系统设计者可以设计出巧妙的策略,比如为每个核分配其自己的私有新生代。由于大多数对象是由单个线程创建和使用的,这种“新生代隔离”极大地减少了核间共享的对象的数量,平息了风暴,使机器能够以其全部潜力运行。

最后,在我们这个移动和电池供电设备的时代,每个操作都有能源成本。每个 CPU 周期,每次从内存读取或写入的字节,尤其是每次缓存未命中,都在消耗电池。在这里,复制式回收器的工作负载特性再次成为关键。一次回收的能源成本与存活对象的数量直接相关。在存活率低(即大量垃圾)的场景中,一个只读取和写入少量存活数据的复制式回收器,可能比一个必须遍历整个堆的标记-清扫回收器在能源效率上高得多。这使得复制式回收成为构建可持续和长续航移动应用工具箱中的一个重要工具。

守护者:安全性与健壮性

也许复制式回收最令人惊讶和强大的应用之一在于计算机安全领域。移动对象的行为本身,看似一个破坏性的副作用,实际上是一种强大的防御机制。

最直接的好处是彻底消除了整整一类危险的软件错误。在像 C 和 C++ 这样的语言中,程序员可能会释放一块内存,但意外地保留了指向它的指针。之后使用这个“悬挂指针”会导致​​释放后使用(use-after-free)​​漏洞,这是一个臭名昭著的崩溃和安全利用来源。在一个带有复制式回收器的托管语言中,这种错误在纯托管代码中根本不可能发生。当一个对象变得不可达时,它被留在 from-space。回收之后,整个内存区域被视为无效。程序持有的所有有效引用都已被运行时自动更新,指向 to-space 中的新的、安全的位置。程序无法持有一个指向无效对象的有效引用。垃圾回收器扮演着一个沉默、时刻警惕的守护者。

当然,世界并非总是纯粹托管的。当我们的安全、托管代码需要与用 C 或 C++ 编写的“狂野西部”般的原生代码交互时会发生什么?这就是外部函数接口(Foreign Function Interface, FFI)的挑战。如果我们天真地将一个指向托管对象的原始指针传递给一个原生函数,我们就重新引入了释放后使用的风险。如果在原生代码执行期间 GC 运行,对象将会移动,原生代码的指针将变成一个悬挂指针。运行时设计者发明了几种优美的解决方案来安全地弥合这一差距:

  • ​​封送(Marshalling):​​ 运行时在原生内存中创建该对象的完整副本,传递一个指向该副本的指针,然后在原生调用结束后将任何更改复制回来。原生代码从不直接接触托管堆。
  • ​​固定(Pinning):​​ 运行时临时“固定”该对象,指示垃圾回收器在原生调用期间不要移动它。
  • ​​句柄(Handles):​​ 原生代码不是获得一个直接指针,而是获得一个句柄——一个指向稳定位置的间接指针,而 GC 确实知道这个位置。当对象移动时,GC 会更新句柄内部的指针,但句柄本身的地址保持不变。原生代码总是通过这个稳定的句柄来找到对象的当前位置。

除了防止简单的内存错误,复制式回收器的移动特性还有助于挫败更微妙的攻击。在一些​​旁道攻击(side-channel attacks)​​中,对手不是通过直接读取数据来获取秘密,而是通过观察其内存地址。复制式回收器充当了一个定期的“​​地址清洗器​​”。通过将所有存活对象移动到新位置,它打破了对象身份与其地址之间的关联。这一点,特别是当与每次回收时随机化 to-space 的基地址(一种针对堆的地址空间布局随机化形式)相结合时,使得攻击者极难跟踪对象并利用基于地址的信息泄漏。一个最初用于清理内存的机制,变成了一个用于扰乱内存以对抗攻击者的工具。

作为思想的算法:抽象联系

一个伟大思想的最终考验是其超越原始背景的能力。复制式回收的 from-space/to-space 模型就是这样一个思想。它的核心是一种执行图的广度优先遍历的方法,干净地将“已访问”与“待处理”分离开来。

考虑一下​​硬实时系统(hard real-time systems)​​的严苛世界,例如飞行控制软件或机器人技术,在这些领域,错过最后期限可能是灾难性的。长时间、不可预测的垃圾回收暂停是不可接受的。在这里,被称为 Baker 算法的复制式回收变体提供了一个解决方案。它以增量方式执行回收,将小的、固定大小的 GC 工作步骤与主应用程序的工作交错进行。通过仔细计算应用程序为 GC 创造新工作的速率,可以确定保证 GC 始终能跟上并按时完成所需的最小回收工作速率。这将垃圾回收从一个不可预测延迟的来源,转变为一个可调度、可预测的任务。

这种类比延伸到完全不同的领域。想象一个巨大的、碎片化的​​数据库文件​​。分散的、存活的记录就像 from-space 中的对象。为了提高性能,我们可以执行一次整理运行。这个过程可以完美地建模为一次复制式回收:我们读取包含存活记录的页(from-space),将它们连续地写入磁盘的一个新的、干净的段(to-space),并更新所有数据库索引(根)以指向新的记录位置。原理是相同的,只是介质从内存变成了磁盘。

最后,让我们考虑最抽象的应用:​​爬取万维网​​。整个网络,拥有数万亿的页面和超链接,可以被看作一个巨大的“from-space”。网络爬虫从一组种子 URL(它的“根”)开始。它获取这些页面并保存它们;这类似于将根复制到“to-space”。然后,它开始扫描其保存的页面(to-space 的前沿)。对于它找到的每个超链接,它会检查链接的页面是否已被访问。如果没有,它就获取该页面,将其添加到 to-space 队列的末尾。这正是 Cheney 的广度优先搜索算法在全球范围内的上演。“扫描指针”在爬虫下载的页面中前进,而“空闲指针”则标记着新页面被添加的队列末端。

从缓存行和能量包的微观舞蹈,到整个互联网的宏观探索,复制式回收这个简单而优雅的思想揭示了自己是计算中的一个基本模式。它证明了在科学中,最美的解决方案往往是那些为复杂世界带来简洁、秩序和统一的方案。