try ai
科普
编辑
分享
反馈
  • 自动内存管理

自动内存管理

SciencePedia玻尔百科
核心要点
  • 大多数垃圾回收器的核心原理是​​可达性​​,即一个对象只有在存在从已知根集合到它的指针路径时,才被认为是存活的。
  • 垃圾回收算法大致可分为​​追踪​​(寻找存活对象)和​​引用计数​​(跟踪指针数量),两者各有其独特的权衡。
  • ​​分代假说​​——即大多数对象生命周期很短——使得高效的回收器能够将清理工作集中在存放新对象的较小的“新生代”中。
  • 自动内存管理深刻影响系统设计,涉及算法选择、硬件缓存一致性,乃至操作系统的安全协议。

引言

自动内存管理是现代高级编程语言的基石,它将开发者从手动分配和释放内存这一容易出错的任务中解放出来。但这种便利性背后隐藏着一个深刻的计算机科学挑战:一个系统如何在不理解程序员意图的情况下,自动判断哪些数据仍在使用,哪些可以丢弃?本文将揭开管理程序内存的这只“看不见的手”的神秘面纱。我们将首先探讨其基本原理和机制,深入研究可达性的核心概念以及将其付诸实践的经典策略——追踪和引用计数。随后,我们将跳出回收器本身,去见证它在算法设计、硬件架构乃至分布式和安全系统等领域中令人惊讶的重大影响,从而揭示内存管理是计算领域一个紧密关联且至关重要的方面。

原理与机制

要领略自动内存管理的魔力,我们必须首先深入机器内部,理解它眼中的世界。在这个世界里,数据没有固有的意义,只有比特模式。挑战不仅仅在于存储这些模式,更在于知道何时不再需要它们。一个系统如何在不理解我们意图的情况下,决定什么是宝贵的,什么是可丢弃的?答案不在于读懂我们的思想,而在于一个简单而深刻的原理:​​可达性​​。

对象与指针的宇宙

想象一下,你程序的内存是一个广阔、互联的宇宙。你的程序创建的每一份数据——一个数字、一段文本、一条复杂记录——都是一个“对象”,一个漂浮在堆内存虚空中的天体。这些对象并非孤立存在,它们通过​​指针​​相互连接,如同无形的丝线或引力绳索。一个用户资料对象可能有一个指向用户名字符串对象的指针,以及另一个指向好友列表对象的指针。

这张宇宙之网构成了一个数学家称之为​​有向图​​的结构:对象是节点,指针是有向边。程序本身并非在这个宇宙中漫无目的地漂浮。它有一组固定的起点,称为​​根集合​​。这些是程序无需追随其他指针即可直接访问的指针。可以将它们想象成你的大本营:当前在执行栈上使用的局部变量、全局变量和CPU寄存器。

从这些根出发,程序可以遍历图,通过追随指针从一个对象跳到另一个对象。这就引出了垃圾回收中最重要的一个原则:一个对象被认为是​​存活的​​,当且仅当存在一条从根集合出发,经过一系列指针能到达它的路径。如果你无法从这里到达那里,它就迷失在虚空中。它就是垃圾。垃圾回收器的全部目的就是识别并回收这些不可达对象所占用的内存。

两种思想:寻找存活者或计算链接数

既然我们有了指导原则,如何将其付诸实践呢?两种主要的思想流派应运而生,每一种都为区分存活与死亡提供了不同的策略。

追踪:遍历存活对象图

第一种方法是主动找出所有存活的对象。这就是​​追踪式垃圾回收​​(tracing garbage collection)的精髓。这个过程就像从你的大本营(根集合)派出探险家。他们沿着所有可能的路径(指针)行进,在他们访问的每一个对象上插上一面“存活”的旗帜。这个探索过程被称为​​标记阶段​​(mark phase)。

一旦探索完成,回收器就开始​​清除阶段​​(sweep phase)。它会对整个堆进行线性扫描,检查每一个对象。任何没有“存活”旗帜的对象,根据定义,都是不可达的垃圾,其内存将被回收。这个两步过程就是经典的​​标记-清除​​(Mark-Sweep)算法。

然而,这会留下一个混乱的后果。想象堆是一个城市。在拆除所有废弃建筑(垃圾)后,你会在被占用的建筑之间留下随机散布的空地。这被称为​​碎片化​​(fragmentation)。如果你之后需要建造一座摩天大楼(分配一个大对象),你可能会发现虽然总的空闲空间足够,但没有一块单独的空地足够大。

一个优雅的解决方案是​​整理​​(compaction)。在清除之后,回收器会让所有居民(存活对象)搬家,将它们迁移到堆的一端,形成一个连续的区域。这样一来,所有的空闲空间就合并成一个巨大的可用块。当然,这也会引入其自身的成本。标记过程的工作量与存活对象的数量成正比,但清除和整理的工作量通常与整个堆的大小成正比。此外,管理一个拥有各种不同形状和大小的建筑(异构对象)的城市,远比管理一个拥有统一、固定大小的地块的城市要复杂得多。

引用计数:统计入站道路

第二种思想采用了一种完全不同、更为局部化的方法。与其进行全局搜索,何不让每个对象自己记录有多少指针指向它?这就是​​引用计数​​(reference counting)。

可以把它想象成每个市政厅都维护一个公开的计数,记录通往该市的道路数量。当一条新路通往一个城市时,其计数器加一。当一条路被摧毁时,其计数器减一。如果一个城市的道路计数降至零,它就与世隔绝了。它是不可达的,可以立即被拆除。这个策略很有吸引力,因为它分散了内存管理的工作。没有了为进行全局搜索而导致的长暂停;回收是随着指针的改变而增量发生的。在某些系统中,编译器负责插入代码来增减这些计数,从而有效地“将内存管理编译到代码中”。

但这个简单的方案有一个著名的阿喀琉斯之踵:​​引用循环​​。想象A和B两个城镇,它们与主干道系统隔绝。然而,有一条从A到B的路,还有一条从B回到A的路。每个城镇的道路计数都是1,所以两者都不会被认为是孤立的。然而,从外部世界的角度来看,这两个城镇组成的集群整体上是不可达的垃圾。简单的引用计数无法识别这些自持的循环,因而无法回收它们。

大停顿:程序与回收器的协作

到目前为,我们谈论回收器时,仿佛它可以神奇地冻结时间。对于最简单的追踪式回收器来说,这离事实不远。它们以​​“全世界暂停”(Stop-The-World, STW)​​的方式运行,即正在运行的程序——​​修改器​​(mutator)——在回收器工作时被完全暂停。

但你不能在任意时刻都停止一个程序。它可能正处于一个精细的、多步骤操作的中间。为了管理这一点,运行时系统使用了​​安全点​​(safepoints)的概念。可以把安全点想象成编译器在程序执行高速公路上设置的指定“休息站”。一个修改器线程只有在停靠在这些站点之一时,才能为了垃圾回收而被暂停。

这些安全点的放置至关重要,由系统必须维护的两个保证所决定:

  1. ​​前进性​​(Progress):程序不能永远执行而不给回收器运行的机会。想象一个内部没有休息站的紧凑循环;一个线程可能会无限期地卡在那里,阻止垃圾回收,并最终导致整个系统耗尽内存。为防止这种情况,安全点被置于循环的“回边”(back edges)上,确保每次迭代都提供一个暂停的机会。
  2. ​​正确性​​(Correctness):想象你的程序调用了一个函数。该函数可能会分配内存,触发一个GC周期,从而整理堆并将你的对象四处移动。当函数返回时,你持有的指针现在已经失效了——它们指向的是对象曾经所在的位置!为避免灾难,函数调用之后立即需要一个安全点。这会强制程序暂停,查阅回收器新的内存映射,更新其指针,然后才能继续执行。

年轻的智慧:分代与复制收集

“全世界暂停”式的停顿,即使由安全点管理,也可能时间过长且具有破坏性。对更短暂停时间的追求,催生了垃圾回收中一个最强大且广泛使用的优化,它基于一个简单的经验观察,即​​分代假说​​(Generational Hypothesis):大多数对象生命周期很短。就像蜉蝣一样,程序创建的绝大多数对象只被使用片刻,然后立即变成垃圾。

这一洞察启发了一种“分而治之”的策略。堆被划分为​​年轻代​​(young generation,或称“新生代”,nursery)和​​老年代​​(old generation)。新对象总是分配在新生代。由于这个空间充满了短命的对象,它很快就会变得大部分是垃圾。回收器可以将其工作重点放在这里,对新生代进行频繁、快速的收集(称为“次要GC”)。而老年代充满了久经考验的长寿对象,其收集频率要低得多。

清理新生代最有效的方法是使用​​复制收集器​​(copying collector)。新生代被分成两个相等的部分,称为“半空间”(semispaces)。对象被分配在其中一个,即“源空间”(from-space)。当它被填满时,回收器识别出少数存活的对象,将它们复制到另一个空的“目标空间”(to-space),并更新所有指向它们的指针。之前可能是95%垃圾的源空间,现在完全可以被丢弃。它可以在一个单一、瞬时的操作中被清空。然后,这两个半空间的角色在下一个周期中互换。

这个设计有一个优美的经济学权衡。分配内存的成本不再仅仅是分配行为本身;它带有一种隐含的“GC税”,用于支付下一次清理的费用。事实证明,分配内存的摊销成本与每次收集中存活数据的比例直接相关。如果大多数对象都死亡(存活率低),复制少数幸存者的成本微不足道,分配也就极其廉价。然而,如果分代假说失效,大多数对象都存活下来,复制所有这些对象的成本将变得巨大,系统性能也会急剧下降。存活了足够多次次要回收的对象被认为是长寿的,并被“晋升”到老年代。

看不见的手:在后台进行收集

对于像实时图形或高频金融这样的应用,即使是次要GC的短暂暂停也是不可接受的。最终目标是让回收器完全在后台工作,不被察觉,不被听到。这就是​​并发垃圾回收​​(concurrent garbage collection)。

这引入了一个巨大的挑战:当修改器正在同时重连对象图时,回收器如何能绘制出存活对象的图谱?这就像试图在一个所有人都在不断搬家的城市里进行人口普查。危险在于,回收器可能会错过一个关键的连接,过早地回收一个存活的对象。

为了对此进行推理,回收器使用了​​三色不变性​​(Tricolor Invariant)。对象在概念上被涂成三种颜色之一:​​白色​​(未访问),​​灰色​​(已访问,但其子对象尚未扫描),或​​黑色​​(已访问,且其所有子对象都已扫描)。过程开始时,根是灰色的,其他一切都是白色的。回收器的工作是将所有灰色对象变为黑色,确保最后仍然是白色的任何东西都是垃圾。

并发收集中最严重的问题是,修改器创建了一个从黑色对象到白色对象的指针。回收器在处理完黑色对象后,将永远不会再看它,因此永远不会发现通往该白色对象的路径,导致其被错误地回收。

解决方案是​​写屏障​​(write barrier):一小段由编译器插入的代码,在程序每次写入指针时运行。这个屏障会检测到“黑到白”的写入并通知回收器,通常通过将白色对象涂成灰色,确保它被添加到回收器的待办事项列表中。有趣的是,对于​​不可变数据结构​​,其中对象在创建后永远不能改变,这个问题就消失了。一个黑色对象永远不可能开始指向一个白色对象,这极大地简化了并发回收器的设计。

即使有这些屏障,回收器仍必须偶尔与修改器线程同步以获取它们的根集合。如果一个线程卡在一个紧凑的循环中,拒绝通过到达安全点来合作怎么办?现代运行时采用了一个最终的、巧妙的技巧。在短暂超时后,运行时放弃等待并升级处理。它使用操作系统发送一个抢占信号,一个中断,强制不合作的线程暂停。在这种不稳定的状态下,回收器无法精确识别根,所以它进行一次​​保守扫描​​(conservative scan),将线程栈上任何看起来像指针的东西都当作存活的根。这是安全的,尽管有点浪费。有了这个机制,回收器保证能取得进展,完成程序与其看不见的自动内存管理器之间永无休止的复杂舞蹈。

应用与跨学科联系

现在我们已经探讨了垃圾回收器的内部工作原理——它对根、可达性的依赖,以及标记和清除的优雅舞蹈——你可能会想合上书本。系统处理内存,程序员处理逻辑,两者相安无事地生活在各自的世界里。但自然界很少如此整齐地划分,计算世界也是如此。自动内存管理不是一个安静、孤立的实用工具;它是一项基本原则,其影响波及现代计算的每一层。

它的存在从根本上改变了整个格局,为算法设计者提出新的难题,与硬件架构师建立起一种无声的伙伴关系,并扩展到应对全球范围的挑战。让我们踏上超越核心机制的旅程,见证这些在遥远领域中引人入胜的回响。

算法设计者的新难题

自动内存管理最直接的影响是对程序员。它承诺的是解放:不再需要手动调用malloc和free,不再有悬挂指针或重复释放。但这种解放并非是对无知的邀请。垃圾回收器让你不必管理内存,但并未免除你管理对象生命周期的责任。问题只是从“我应该何时释放这个对象?”转变为一个更抽象的问题:“这个对象何时不再需要,并因此应该变得不可达?”

一个典型的场景出现在处理数据流的程序中,比如一个解析XML文档的Web服务器。想象一个解析器,它为遇到的每个元素创建一个“上下文”对象并将其存储在一个全局列表中,打算在找到该元素的闭合标签时将其移除。但如果XML格式错误,闭合标签从未出现怎么办?这个上下文对象在语义上是无用的——对那个元素的解析已经结束了。然而,它仍然保留在全局列表中,这是一个可达性的根。垃圾回收器忠实地遵循其规则,看到一个有效的引用,并保持该对象存活。这是一个​​逻辑内存泄漏​​(logical memory leak):内存并未对系统丢失,但对应用程序而言却丢失了,它会无限累积并最终导致失败。因此,程序员的责任是确保在对象逻辑上不再需要时,切断通往它们的逻辑路径,例如,通过实现健壮的错误处理来清理这些悬空的引用。

当我们考虑算法本身的设计时,这种相互作用变得更加深刻。考虑动态规划中的一个常见任务:通过将其分解为更小的、重叠的子问题来解决一个问题。两种流行的技术是制表法和记忆化。​​制表法​​(tabulation)通常是“自下而上”的:它预先构建一个大表(一个数组)并迭代地填充它。这涉及一次大的内存分配,并在一个扁平的调用栈上进行。相比之下,​​记忆化​​(memoization)方法是“自顶向下”的:它使用递归,在需要时解决子问题,并将其结果存储在缓存中。

在一个没有垃圾回收的世界里,性能差异可能看起来不大。但在一个受管理的环境中,其后果是深远的。记忆化解决方案,以其深度的递归调用,构建了一个巨大的调用栈。由于栈是垃圾回收器的根集合,每个GC周期都必须扫描整个栈,这是一项昂贵的操作。此外,它在缓存结果时执行许多小的分配,从而更频繁地触发垃圾回收器。而制表法版本,以其单次分配和浅层栈,向GC呈现了完全不同的概况。两个渐近复杂度相同的算法,其实际性能可能天差地别,仅仅因为它们的结构与内存管理器交互的方式不同。

程序结构和GC性能之间的联系甚至延伸到编译器优化层面。一个尾递归函数,其中递归调用是最后一个动作,可以被一个聪明的编译器优化。它不会为每次调用创建新的栈帧,而是可以重用现有的栈帧——这种优化被称为​​尾调用优化​​(Tail-Call Optimization, TCO)。最明显的好处是防止深度递归的栈溢出。但还有一个更微妙的、与GC相关的优势。没有TCO,深度递归会创建一个包含 O(n)O(n)O(n) 个栈帧的栈。而使用TCO后,栈深度为 O(1)O(1)O(1)。当GC周期发生时,TCO版本扫描栈以寻找根的成本要低得多。这揭示了算法(递归)、编译器(TCO)和运行时(垃圾回收)之间美妙的三方互动,它们共同决定了代码的最终性能。

架构师的无声伙伴

垃圾回收的影响并不止于软件的边界。它深入到硅片之中,影响着我们如何设计和思考硬件本身。

考虑一个现代多核处理器。每个核心都有自己的本地缓存,和一个复杂的​​缓存一致性协议​​(例如MESI)确保所有核心对内存有一致的视图。现在,让我们运行一个复制式垃圾回收器,其工作是将存活对象从“源空间”移动到“目标空间”以整理内存。当在一个核心上运行的GC搬迁一个对象时,它会在旧位置写入一个转发指针。这个写操作不是没有代价的。如果那个内存位置被其他核心缓存了(也许是因为其他应用程序线程刚刚使用过那个对象),一致性协议必须立即行动。它向所有其他核心发送​​失效消息​​,告诉它们其缓存的副本现在已经过时。单次GC运行就可能在芯片的互连网络上引发一场此类消息的风暴,消耗带宽和能源。这是一个惊人的例子:一个软件内存管理算法对硬件的通信结构产生直接、可量化的影响。设计高性能运行时的架构师们敏锐地意识到这一点;他们甚至开发出一些策略,比如将年轻且频繁修改的对象(“新生代”)隔离到每个核心的内存区域,以最小化这种跨核一致性流量。

当我们分析并行程序的可伸缩性时,对硬件架构的影响就更加明显了。根据 Amdahl 定律,并行应用程序的加速比受其串行部分的限制。我们通常认为这个串行部分是算法固有的。但是,“全局暂停”式的垃圾回收器引入了一个新的、系统级的串行瓶颈。当GC运行时,所有应用程序线程都必须暂停。整个系统被串行化了。随着我们增加工作单元(NNN),任务的并行部分会变得更快,但在这些同步GC暂停上花费的时间可能开始占据主导地位。更糟糕的是,暂停的持续时间本身甚至可能随着 NNN 的增加而增加,因为回收器可能需要做更多的工作来同步线程。这种由GC引起的开销会给应用程序的可伸缩性设置一个硬性上限,这个上限对于只分析算法本身的人来说是看不见的。

也许最美妙和令人惊讶的相似之处不在CPU中,而在于现代存储设备中。固态硬盘(SSD)不是一个简单的块设备;它包含一个复杂的控制器,运行着一个名为闪存转换层(Flash Translation Layer, FTL)的固件层。闪存不能原地覆写;要更新一个块,驱动器必须将新数据写入一个全新的、已擦除的块,并将旧块标记为无效。随着时间的推移,驱动器会充满有效数据和无效“垃圾”的混合体。FTL必须周期性地运行自己的​​垃圾回收​​过程:它找到一个有很多无效页的块,将少数剩余的有效页复制到一个新块,然后擦除旧块,使其可用于未来的写入。

这个过程——高内部写入流量、增加的延迟——与软件GC惊人地相似。一个对此毫无察觉的操作系统会继续向SSD发送写操作,即使驱动器正处于一个代价高昂的内部GC周期中。这会导致长的I/O队列和高的应用程序延迟。然而,一个“GC感知”的操作系统可以检测到设备高延迟的开始并进行调整。它可以限制自己的回写速率,为SSD的内部进程留出空间,并保护前台应用程序免受延迟尖峰的影响。在这里,我们看到“垃圾回收”这一基本原理在系统堆栈的两个截然不同的层面上出现,而一个高性能系统就是一个让这些层相互协作的系统。

在分布式与安全世界中的回响

自动内存管理的原理是如此基础,以至于它们可以超越单台机器,应用于分布式和安全计算的宏大挑战中。

当你应用程序的“堆”不在一台计算机上,而是分散在数据中心的上千台服务器上时,会发生什么?这就是​​分布式垃圾回收​​(Distributed Garbage Collection, DGC)的领域。核心思想仍然是从根集合的可达性,但实现变成了一个复杂的分布式算法。你如何在一个存在固有消息延迟的网络中获得对象引用的一致性快照?你如何追踪从节点A上的对象到节点B上的对象再到节点C上的对象的路径,而不让整个系统停顿?解决方案涉及复杂的技术,如一致性快照(consistent cuts,用于建立一个一致的全局状态)和跨网络发送“追踪”消息的并发标记协议。其性能不仅用CPU周期来衡量,还用网络消息和全局同步暂停的持续时间来衡量。一个设计良好的DGC会将其全局暂停时间最小化,通常只限制在协调节点所需的短暂时期,而大部分追踪工作则与应用程序并发进行。

最后,对象生命周期的管理不仅仅是性能或正确性的问题;它是系统安全的基石。在一个使用​​基于能力(capability-based)的访问控制​​的安全操作系统中,“能力”是一个不可伪造的令牌,它授予对某个对象的权限。为了允许撤销这些权限,一个常见的设计使用间接层:能力指向一个“撤销者”(revoker)对象,该对象持有实际的有效性状态。要撤销访问权限,管理员只需翻转这个撤销者对象中的一个位。

但这造成了一个并发编程的噩梦。一个线程可能检查了撤销者,看到它是有效的,但在它使用其权限之前,被抢占了,而另一个线程撤销了该对象。这是一个经典的检查时-使用时(Time-of-Check to Time-of-Use, TOCTOU)漏洞。此外,当所有指向一个撤销者的能力都被销毁时会发生什么?系统必须回收撤销者对象的内存以防止泄漏。但如果它立即这样做,一个处于TOCTOU竞争中的线程可能会发现自己持有一个指向已释放内存的指针——这是一个可能被利用的释放后使用(use-after-free)漏洞。

值得注意的是,这些安全问题的解决方案正是从内存管理的世界中汲取的。为了防止TOCTOU,内核必须在提交操作之前重新检查有效性位。为了防止释放后使用,系统不能在撤销者对象变得不可达时立即回收它。相反,它必须使用一个延迟回收方案(如读-复制-更新,Read-Copy-Update),等待一个“宽限期”以确保没有任何线程可能仍然持有对它的瞬时指针。在这里,对这些关键安全对象的安全及时垃圾回收不是一项功能——它是整个系统完整性的先决条件。

从回收一个断开连接的B树节点 的简单行为,到保护一个内核和协调一个数据中心,其原理是相同的。自动内存管理远不止是一种便利。它是一个统一的概念,一个强大的透镜,通过它我们可以更好地理解现代计算中错综复杂、相互连接的机制。