try ai
科普
编辑
分享
反馈
  • 分代假说:一种高效遗忘的原则

分代假说:一种高效遗忘的原则

SciencePedia玻尔百科
核心要点
  • 弱分代假说是一种经验性观察:程序中创建的大多数对象生命周期极短。
  • 分代垃圾回收利用了这一特性,将内存划分为用于存放新对象的“年轻代”和用于存放长寿对象的“老年代”,从而能够在规模较小的年轻代上实现快速回收。
  • 为保持效率,需要一个“写屏障”来跟踪从老年代指向年轻代对象的指针,这引入了一种性能上的权衡。
  • 管理短暂存在和持久存在的群体这一原则是一种通用模式,适用于从 GPU 内存、文件缓存到法律法规等各种不同的系统。

引言

在计算世界里,效率至上,而其中一个最持久的挑战在于管理一种有限的资源:内存。程序运行时,会持续不断地创建数据对象。为了避免被这些数字“碎屑”淹没,系统必须有一种“遗忘”的方式——即回收不再使用的对象的内存。早期实现这种“垃圾回收”的方法虽然有效,但却很粗暴,常常需要程序完全暂停,进入一种令人不快的“stop-the-world”停顿状态,以便从死数据中分拣出活数据。这一性能瓶颈迫切需要一种更智能、更精细的方法。

解决方案并非来自复杂的算法,而是源于一个关于数据本质的简单而有力的观察:弱分代假说,该假说断言大多数对象都“死得早”。这一洞见彻底改变了内存管理,催生了能够用不同策略处理短暂存在和持久存在的复杂系统。本文将深入探讨这一深刻的原则。首先,我们将剖析分代垃圾回收的精妙机制,探索内存如何划分、对象如何老化和晋升,以及为使其运转所需做的巧妙权衡。然后,我们将看到这个思想如何超越其起源,揭示其在 GPU 架构、机器学习乃至法律理论等迥然不同的领域中,作为一种普适的组织模式而存在。

原理与机制

遗忘的代价

计算领域中一个伟大却鲜为人知的挑战是“遗忘”的艺术。程序运行时,会创建对象——数据片段、结构体、变量——它们存在于计算机的内存中。但内存是有限的资源。为了给新对象腾出空间,我们必须回收那些不再需要的旧对象所占用的内存。这个过程被称为​​垃圾回收​​。

那么,如何找到垃圾呢?最直接的方法也是最粗暴的:你只需让世界停止运转。你暂停程序,从程序的活动核心——即“根(roots)”——开始,一丝不苟地追踪每一个连接,以找到所有仍然可达、仍然“存活”的对象。其余的一切都是垃圾。这就是​​标记-清除(mark-and-sweep)​​回收器的本质。它确实有效,但可能慢得令人痛苦。对于一个使用数 GB 内存的程序来说,这种“stop-the-world”暂停可能持续数百毫秒——这在计算领域是永恒般的时间,对用户来说则是一次明显而令人不快的卡顿。我们当然可以做得更好。

一个有力的线索:事物的短暂性

通往更优方法的道路始于一个简单的观察,而非一个巧妙的算法,这个观察关乎生命,也关乎程序:大多数事物都是暂时的。想一想函数内部的变量;它们只存在片刻,当函数返回时便消失了。又或者一个复杂计算的中间结果,用过一次便被丢弃。

这个经验性真理被称为​​弱分代假说(weak generational hypothesis)​​。它不是一条严格的自然法则,而是一种强大的统计趋势。我们可以通过思考一个对象的“年龄”来更正式地表述它,这个年龄可以用它在多少次垃圾回收中幸存下来衡量。该假说指出,一个对象“死亡”(变为垃圾)的概率在其年龄非常小时是最高的。换句话说,如果一个对象已经成功存活了一小段时间,它就越来越有可能存活非常长的时间。这是因为它很可能已经成为程序中某个长期数据结构的一部分。

一个符合此假说的工作负载可能会看到其绝大多数对象的生命只有短短一瞬。然而,一个违反此假说的工作负载可能会产生大量“中等寿命”的对象——这些对象不会立即死亡,但也不会永远存活。它们是内存世界里麻烦的“青少年”,也是理解垃圾回收挑战的关键。

利用线索:分而治之

如果我们知道大多数新对象几乎会立即死亡,我们就可以设计一个系统来利用这一事实。我们不再将所有内存作为一个巨大的、单一的块来管理,而是将其划分。我们创建一个特殊的、相对较小的内存区域,称为​​年轻代(young generation)​​,或​​新生代(nursery)​​。每个新对象都在这里诞生。

由于根据假说,这个新生代里绝大多数都是即将死亡的对象,这使得一种非常高效的回收策略成为可能。我们不再去寻找死掉的对象,而是反其道而行之:我们找到极少数存活的对象,并将它们移到安全地带。一旦幸存者被疏散,整个新生代就被声明为垃圾。我们不需要检查每一个死掉的对象;我们可以在一次快速的操作中将整个区域清理干净。这被称为​​复制式垃圾回收器(copying garbage collector)​​。

这种方法的美妙之处在于,一次新生代回收——即​​次要回收(minor collection)​​——的成本与存活数据的数量成正比,而不是新生代的总大小。如果在许多真实世界的应用中,新生代中 99% 的对象在下一次回收前就死掉了,那么所需的工作量就微乎其微。暂停时间变得极短,通常不到几毫秒。

这个策略还有另一个令人愉快的副作用。因为新生代被周期性地清空,我们可以使用一种非常快速的分配技术,称为​​指针碰撞分配(bump-pointer allocation)​​。我们只维护一个指向空闲空间起始位置的指针。要分配一个新对象,我们只需交出指针处的内存,并将指针向前“碰撞”移动对象的字节大小。这几乎和在程序栈上分配内存一样快——与在碎片化的单一堆中所需的复杂且较慢的​​空闲列表(free-list)​​管理形成鲜明对比。

幸存者的旅程:晋升及其风险

那些在次要回收中幸存下来的少数对象会怎样呢?它们会被晋升。但它们不会立即被送到存放长寿对象的“养老院”。一个对象可能仅仅是偶然地在一次回收中幸存下来。为了避免晋升那些只是“中等寿命”的对象,我们增加了一个中间步骤。

幸存者首先被复制到年轻代内一个特殊的区域,称为​​幸存区(survivor space)​​。如果一个对象在另一次次要回收中再次幸存,它会被复制到第二个幸存区。这两个幸存区协同工作,幸存者从一个移动到另一个。每当一个对象在这些复制中幸存一次,其内部的“年龄”计数器就会增加。只有一个对象在经历了一定次数的次要回收——达到一个​​晋升阈值(tenuring threshold)​​,比如 τ=10\tau=10τ=10——证明了它的“耐力”之后,它才最终被晋升到​​老年代(old generation)​​。这起到了一个过滤器的作用,确保老年代主要由真正长寿的对象构成。

这个过滤系统虽然精妙,但也很脆弱。其有效性取决于幸存区是否足够大,以容纳通常的幸存者群体。如果程序中的一次活动突发(例如,一个网络服务器处理许多并发请求)产生了大量的存活对象,幸存者的数量可能会压垮幸存区的容量。当这种情况发生时,回收器别无选择,只能进行​​提早晋升(premature promotion)​​:它开始将对象直接晋升到老年代,无论其年龄大小,仅仅是为了腾出空间。

这不是一个理论上的担忧。来自真实系统的性能日志可以揭示,一个微小的 8MB 幸存区要艰难地容纳来自单次次要回收的超过 25MB 的幸存数据,这迫使大量年轻对象不合时宜地涌入老年代。这会用通常是短命或中等寿命的对象污染老年代,违背了分代方案的初衷,并迫使系统进行更频繁、代价高昂的全堆回收。即使是一个小小的计算失误,比如一个 1200KB 的存活对象集试图挤进一个 1024KB 的幸存区,也足以触发这种破坏性行为。

复杂性:跨越分代鸿沟

只要我们只考虑从年轻代指向老年代的指针,我们的模型就能完美工作。但如果一个老年代对象指向一个年轻代对象呢?想象一个长期存在的缓存,安逸地待在老年代,它持有一个指向新生代中一个新建对象的引用。

当我们执行一次次要回收时,我们必须将那个年轻代对象视为存活的。但我们如何在不扫描整个庞大的老年代的情况下发现这个连接呢?这样做将消除次要回收的速度优势。

解决方案是另一项精妙但代价高昂的工程设计:​​写屏障(write barrier)​​。我们在系统中引入一条新规则:每当程序试图在内存中存储一个指针时,一小段代码——写屏障——就会运行以检查发生了什么。如果它检测到一个从老年代对象指向年轻代对象的指针,它就会在一个名为​​记忆集(remembered set)​​的特殊数据结构中记录这一事实。

可以这样理解:老年代是一个巨大的图书馆,新生代是一个“新书上架”推车。我们不想为了查看图书馆里的每本书是否引用了推车上的书名而扫描整个图书馆。所以我们制定一条规则:每当图书管理员在一本旧书中做笔记,提到推车上的一本新书时,他们也必须在一个特殊的列表——记忆集——上写下该笔记的页码。当需要处理推车时,我们只需要检查这个列表就能找到所有的交叉引用。

这使得次要回收能够保持快速,因为它们只需要查阅记忆集,而不是整个老年代。但这种安全性是有代价的:写屏障给程序中许多指针写入操作增加了一点开销,即使在没有进行垃圾回收时,也会减慢应用程序的常规执行速度。

魔鬼在细节中:工程上的权衡

故事并未就此结束。写屏障本身也带来了一系列有趣的工程权衡。维护一个能够以完美精度列出每一个老年代到年轻代指针的记忆集,可能既复杂又缓慢。

一个常见且务实的解决方案是​​卡片标记(card marking)​​。我们不记录指针的确切地址,而是将老年代的内存划分为固定大小的块,称为“卡片(cards)”,大小可能是 256 或 512 字节。写屏障的工作变得更简单:如果一个指针被写入到某个卡片内的任何位置,它只需在一个单独的“卡表(card table)”中将该卡片的条目标记为“脏(dirty)”。它不关心是哪个指针或在哪个位置。

在次要回收期间,垃圾回收器不会扫描整个老年代,但它必须扫描每个脏卡片内的所有指针,以找到实际的老年代到年轻代的引用。这比扫描所有东西快得多,但它是一种妥协。它引入了“假阳性(false positives)”。一个卡片可能包含几十个指针,但可能只有一个被更新以指向一个年轻对象。回收器仍然需要检查所有这些指针。

这种不精确性的代价可能出奇地高。在一个现实场景中,这种粗粒度的方法可能导致回收器扫描的指针数量是理想的、完全精确的系统所需扫描数量的八倍以上。这是一个经典的工程权衡:我们接受回收期间的一些草率,以换取正常程序执行期间更快的写屏障。

当假说失效时

整个分代回收大厦都建立在基础性的分代假说之上。但如果对于某个特定程序,这个假说被证明是错误的,会发生什么?如果一个应用程序产生了大量“中等寿命”的对象——这些对象总能存活足够长的时间被晋升到老年代,之后不久又死亡——系统的效率就会骤降。

这是最糟糕的情况。系统为在幸存区之间多次复制这些对象付出了代价。它为晋升它们付出了代价。然后,这些对象在老年代中迅速变成垃圾,使其膨胀,并迫使我们最想避免的事情发生:频繁且具破坏性的主要回收。

检测这种失效需要精细的监控工具:跟踪对象的年龄和生命周期,以构建应用程序内存行为的经验性图像。当检测到这种失效时,解决方案可能在于垃圾回收器之外。先进的编译器可以执行​​逃逸分析(escape analysis)​​,以确定一个对象是否曾“逃逸”创建它的方法的范围。如果没有,它可以被分配在栈上,完全绕过垃圾回收器。或者,特殊的注解可以指导运行时对特定模式的对象使用不同的内存管理策略,如​​基于区域的分配(region-based allocation)​​。

分代假说不是一条定律,而是一种强大的启发式方法。它的应用揭示了经验观察、算法设计和底层系统工程之间美妙的相互作用。我们从一个关于程序行为的简单洞察出发,构建一个复杂的多层系统来利用它,其中充满了巧妙的捷径和务实的妥协,所有这一切都是为了让我们程序运行得更快一点而进行的不懈追求。

应用与跨学科联系

在领略了分代垃圾回收的精妙机制之后,人们可能会倾向于将其视为一个巧妙但狭隘的解决方案,用于解决计算机编程中的一个特定问题,是运行时工程师的行业技巧。但这样做将只见树木,不见森林。分代假说——这个简单、近乎民间智慧的观察,即大多数事物都死得早——不仅仅是一个编程启发式方法。它是世界中一个深刻且反复出现的模式,一个时间组织原则,其回响远远超出了计算机内存的范畴。看到这一点,就是见证工程与科学思想的非凡统一,一个单一、优美的思想可以照亮一片广阔的、看似无关的问题领域。

我们对这些联系的探索始于该原则的诞生地:现代计算的核心。

现代计算的核心:生命周期的层级结构

分代假说的核心是通过区分短暂存在与持久存在来有效管理资源的策略。在计算领域,最基本的资源是内存,而管理内存所需的时间是关键的性能瓶颈。

考虑一个实时视频游戏引擎的复杂世界。在每一帧——仅仅是秒的片段——中,无数临时对象诞生:爆炸效果的粒子、物理模拟的计算、单个音效的数据。这些对象大多数只需要几微秒,并在帧结束时成为“垃圾”。然而,少数对象可能代表一个持久的变化,比如玩家物品栏中的一个新项目,必须存活下来。工程师们,手握分代假说,面临一个经典的权衡。他们可以为所有分配使用一个超高效的、帧局部的“竞技场(arena)”,这个空间在每帧结束时被简单地清空。对于 99% 的早死对象来说,这快得令人难以置信。代价是什么?任何需要存活的对象都必须被费力地复制到一个更持久、全局管理的堆中。另一种选择是从一开始就在全局堆上分配所有东西。定量分析揭示了一个“盈亏平衡”概率:一个精确的点,在该点上,对象的存活率使得一种策略比另一种更具成本效益。分代假说为这种经济选择提供了框架。

这个原则从简单的内存延伸到现代硬件的复杂层级结构。以驱动从科学模拟到机器学习等一切的强大图形处理单元(GPU)为例。GPU 拥有自己的超高速板载内存,通过一个相对较慢的数据总线——PCIe——连接到计算机的主内存(主机内存)。当运行一个计算时,我们可能会创建长寿对象,如 3D 模型的核心纹理,以及一场短暂的中间数据缓冲区的“风暴”。将宝贵的、快速的 GPU 内存用可能存活很长时间的对象填满有意义吗?没有。分代原则引导我们走向一个更好的设计:将快速的 GPU 内存视为短暂缓冲区的“年轻代”,将较慢的主机内存视为“老年代”。长寿的纹理可以直接放在老年代。短暂的缓冲区在年轻代(GPU 内存)中创建,只有少数存活足够长的对象才会被“晋升”过慢速总线到老年代(主机内存)。通过调整 GPU 上这些“次要回收”的频率,我们可以最大限度地减少跨越慢速 PCIe 总线的流量,这是一个可以用惊人的数学精度进行优化的设计选择。

更智能的运行时:与程序员的对话

分代假说不仅优化了机器;它还在程序员使用的语言和执行它的运行时系统之间建立了一种微妙的对话。运行时可以观察对象创建的模式,并利用它们做出更智能的决策。

一个美丽的例子是字符串驻留(string interning)。在许多编程语言中,为了节省内存,相同的字符串字面量(例如,文本 "hello")只存储一次。这个单一的、规范的字符串被称为驻留字符串。根据其本质,这些驻留字符串注定要在程序的整个生命周期内存在。那么,当我们遵循默认规则,在年轻代中分配一个新的驻留字符串时会发生什么?运行时被迫像对待任何其他年轻对象一样对待它。它将在每次次要回收中被扫描和复制,直到最终被晋升到老年代。这是纯粹的、浪费的工作。对成本——重复复制和跟踪老年代指向它的指针的开销——的定量分析表明,这是极其昂贵的。解决方案,在分代原则的指导下,是创建一个特殊的分配路径:“提前晋升(pretenuring)”。像驻留字符串这样已知的长寿对象可以完全绕过年轻代,直接在老年代中分配,从而为系统节省了大量的无用簿记工作。

当我们考虑像不可变性(immutability)这样的语言特性时,这种选择性提前晋升的想法变得更加强大。一个不可变对象一旦创建,就永远不能被改变。这对我们的垃圾回收器有着深远的影响。分代 GC 最昂贵的部分之一是写屏障,这个哨兵必须监视每一次旧对象的修改,以查看它是否现在指向一个年轻对象。但是一个不可变对象,一旦它在老年代,就永远不能被修改以指向任何新的东西。它不再是写屏障流量的来源,这是一个显著的节省。这是否意味着所有不可变对象都应该被提前晋升?完全不是。许多不可变对象是为临时计算而创建的,并且死得非常早。将它们放在老年代将是灾难性的,会用短命的垃圾污染它。因此,一个真正智能的运行时不会使用一刀切的规则。它使用启发式或编译器分析来选择性地只提前晋升那些可能长寿的不可变对象,从而实现两全其美。

这条通往更高智能的道路通向何方?也许是一个能够学习的系统。想象一个集成了机器学习模型的垃圾回收器。通过观察对象创建瞬间的特征——它在哪里被分配、它的类型是什么、它的大小——模型可以预测它的生命周期。被预测为短命的对象进入年轻代。被预测为长寿的对象被提前晋升直接进入老年代。对这样一个假设系统的成本效益分析,即使考虑到不可避免的预测错误,也表明这可以显著优于标准的、非预测性的回收器。这代表了计算机科学两个不同领域——系统编程和机器学习——的迷人融合,所有这一切都是为了更完美地实现分代假说。

一种普适的组织模式

在这里,我们进行最后也是最激动人心的一次飞跃。我们发现分代假说不仅仅是关于编程的。它是一种管理任何涉及创建、过时和清理成本的系统的基本策略。

想一想计算机文件系统的缓存。为了加速访问,最近使用的硬盘数据块被保存在内存中。当请求一个新的块时,它被带入缓存。核心问题是:我们应该驱逐哪个块来腾出空间?我们可以用分代假说来建模这个问题。我们可以将缓存划分为一个小的“新生代”用于新访问的块,和一个较大的“老年代”用于存活下来的块。一个新块进入新生代。如果它在新生代期间被再次访问,它就证明了自己的价值,并被“晋升”到老年代。新生代可以被积极地清理掉那些只被访问一次就再也没有被访问过的块——那些“死得早”的块。老年代,现在充满了已证明其效用的块,可以用更传统的策略如“最近最少使用(Least Recently Used)”来管理。这种直接模仿分代 GC 的设计,是最大化有限缓存空间效用的强大策略。

这种联系甚至可以更令人惊讶。让我们回到写屏障,这个机制允许在年轻代回收期间忽略老年代。它的工作是监视一个非常特定的事件:从一个旧对象到一个年轻对象的指针写入。这种高效、有针对性的哨兵思想能用于其他目的吗?考虑一下计算机安全的世界。一种被称为“指针喷射(pointer spraying)”的恶意技术涉及攻击者从内存中许多不同位置快速写入指向目标对象的指针,以增加利用漏洞的概率。我们如何在不减慢程序速度的情况下实时检测到这一点?我们可以利用写屏障!除了其 GC 职责外,我们可以让它向一个概率性数据结构报告每一次写入,比如一个 Count-Min Sketch,它被设计用于以最小的内存和恒定时间更新来计算流中的高频事件。写屏障变成了一个实时入侵检测系统,在可疑的写入模式发生时标记它们。这个为服务分代假说而构建的工具,被重新用作系统安全的守护者。

最后,让我们考虑一个来自与计算机科学尽可能遥远的领域的类比:法理学。一个国家的法律体系可以被建模为一个巨大的、相互连接的图。法律引用其他法律,形成一个依赖网络。这个图的“根”是基础性文件,如宪法,以及在近期法院案件中被积极引用的法律。任何通过从这些根开始追踪引用链而可达的法律都可以被认为是“存活的”。但是那些不再被引用,属于循环、自引用链条,并且与任何相关事物都没有连接的法律呢?它们是法律“垃圾”——过时的法规,它们使法律文库变得杂乱并造成混淆。

一个社会如何进行“法律清理”?它面临着与垃圾回收器完全相同的约束:过程必须是正确的(不能废除一个实际有效的法律),它不应该扰乱法律系统的日常运作(低“暂停时间”),并且它必须能够处理复杂的依赖循环。一个提议简单地停止整个法律系统来进行“stop-the-world”审计,就像停止谷歌来清理其服务器一样不可行。源自计算机科学的解决方案是一个增量的、并发的追踪回收器。理论上,人们可以从根开始,一点一点地追踪法律图,而无需停止法庭。为了处理在此过程中发生的新立法和法院裁决,需要一个“写屏障”的类似物——一个确保任何从“已扫描”法律到“未扫描”法律的新创建的引用都被正确记录的程序。在这个漫长但不具破坏性的过程结束时,任何未被标记为存活的法律都可以被自信地识别为过时的,并被建议废除。这不是一个法律改革的提议,而是一个共享逻辑结构的深刻例证。管理过时数据的问题,无论这些数据是计算机中的比特还是法律图书馆中的法规,都屈服于同样优雅、强大的原则。

从游戏引擎到 GPU,从语言设计到机器学习,从文件系统到安全,甚至到法律的抽象结构,分代假说都提供了一个镜头。它向我们展示了如何将注意力集中在动荡、不断变化的“年轻”群体上,同时有效地管理稳定、持久的“年老”群体。它证明了一个事实,即在寻求真理和优雅设计的过程中,最专业的洞见往往最终被证明是最普适的。