Files
endorphin-tabularium-docs/content/docs/05-object-graphs-gc.md
T
codex-bot e0191e1aa3
Verify and deploy Tabularium / verify-and-deploy (push) Waiting to run
Document object reference cycles and generational garbage collection
2026-10-28 14:04:19 +08:00

10 KiB

对象关系、循环引用与分代垃圾回收

记录日期:2026 年 10 月 28 日。本文整理一次从“继承是有向无环图,那么 object 的所有关系可以有环吗?”展开的讨论,依次解释对象图、追踪式垃圾回收和分代回收。这里记录通用原理与其他运行时的例子,不代表 Endor 已确定或实现了某种内存管理方案。

继承无环,不等于整个对象图无环

是否允许成环,要看边代表什么关系。 把所有关系都画在同一张图上,会掩盖不同关系的约束。

关系 节点与边的含义 是否允许有环
类继承 类指向父类 通常禁止;单继承形成森林,多继承可形成 DAG
原型委托 对象沿原型链查找属性 JavaScript 普通对象的原型链禁止形成环
普通强引用 对象字段指向另一个对象 允许,例如父子反向引用、双向链表、社交关系
所有权或包含关系 生命周期由谁管理、谁包含谁 由模型规定;树形包含通常限制成环

继承的无环约束让“父类”构成可排序的层次,便于定义类型关系、成员查找和初始化顺序。不能简单地说“有环必然让查找无限递归”:查找算法可以检测环,但语言仍可以为了保持继承层次的语义而禁止环。不同语言的检测时机也不同,不一定都在语法或编译阶段。

普通字段引用则可以这样成环:

let parent = { name: "Parent" };
let child = { name: "Child", parent };
parent.child = child;
// parent → child → parent

parent = null;
child = null;
// 假设没有其他强引用,两个对象组成的环现在不可达。

双向链表相邻节点的 next 和 prev 也构成引用环;这不表示“沿 next 前进”一定会回到起点。讨论图是否有环时,需要明确观察哪些边。

JavaScript 对普通对象原型环的限制,可见 ECMAScript 的 OrdinarySetPrototypeOf 算法。

循环引用影响哪些操作

对图进行递归遍历时,需要用已访问集合等方式防止重复处理。序列化也必须决定怎样表示对象身份和回边。

原生 JSON.stringify() 在实际遍历到循环引用时会抛出 TypeError,因为 JSON 本身没有表示对象引用的机制。可以按业务需要输出对象 ID,再用独立的关系表记录连接;不能简单丢弃回边后仍声称完整保存了对象图。相关规则见 ECMAScript 的 SerializeJSONObject 算法。

内存管理上的关键区别是:

  • 单纯的引用计数只看入引用数量。没有外部引用的环内部仍彼此计数,无法仅靠“计数降到零”释放。
  • 追踪式 GC从根出发判断可达性。孤立环没有来自根的路径,可以被回收。
  • 引用计数也可以搭配循环检测。CPython 就以引用计数为基础,另有循环垃圾收集器,不能把它直接归为与 V8 相同的标记清除实现。见 Python 的 gc 文档。

环并不必然造成内存泄漏。反过来,即使对象关系无环,只要长期缓存等根可达对象一直引用着已经不需要的数据,追踪式 GC 也不会替程序判断它们“业务上已经没用”。

Tracing GC 如何回收孤立环

Tracing GC(追踪式垃圾回收)把从根沿强引用可达的对象视为需要保留的对象。根可以包括执行栈中仍然存活的引用、全局环境,以及宿主运行时持有的句柄;具体集合由实现决定。源代码里存在一个变量名,并不等于它在任意时刻都一定是 GC 根。

下面用概念上的对象图说明一次回收:

根 R ──→ A ⇄ B       保留:A、B 可从根到达

         C ⇄ D       回收候选:C、D 彼此引用,但根到不了

标记:从根寻找可达对象

最简单的工作队列算法如下。这里假设程序暂停、只有普通强引用,忽略弱引用和终结器处理。

marked = 空集合
worklist = 根所指对象

while worklist 非空:
    object = 取出一个对象
    if object 已在 marked 中:
        continue
    把 object 加入 marked
    把 object 的所有强引用目标加入 worklist

先记录访问状态,再继续展开引用,就能避免在 A → B → A 中无限循环。标记结束后,集合中正好包含本次模型下所有根可达对象。工程实现通常使用位图和工作队列等结构,不要求给每个对象增加一个布尔字段。

清除:回收未标记对象占用的空间

Mark-and-Sweep(标记清除)是追踪式 GC 的一种实现:完成标记后,遍历管理的堆空间,把未标记对象占用的空间变为可供分配的空闲空间。上图中 C、D 都未被标记,因此环不会妨碍回收。

清除并不保证立刻把内存归还操作系统;运行时可以先保留空间供后续分配。标记状态如何为下一轮重置也是实现细节。

标记清除可能留下不连续的空闲块。Mark-Compact(标记整理)通过移动存活对象并更新引用来减少碎片;移动和整理不一定覆盖每个页面,也不一定每轮执行。V8 的说明可参见 Orinoco 概览和标记与清除实现文档。

分代回收为什么更快

分代回收利用弱分代假说:在许多程序中,大量对象分配后很快就变得不可达。它是关于工作负载的经验规律,不是所有程序都满足的定理。“活得短”指对象很快不再可达,而不是函数一返回,对象就必然死亡;返回值、闭包和缓存都可能延长对象的生命周期。

运行时把对象按年龄等信息分组,让年轻代更频繁地回收,以减少每次检查整个堆的开销。

方面 年轻代 老年代
常见对象 刚分配、尚未晋升的对象 已晋升的长期存活对象;部分对象可能直接分配到相关空间
常见存活特征 大量对象很快不可达 经筛选后存活率往往较高
回收思路 经常进行较小范围的回收 较低频率地进行更大范围的回收
可能的算法 复制、标记清除等 标记清除、选择性整理等

老年代并不意味着“对象数量少”;长期缓存和不断增长的数据结构都可能使它很大。Minor GC 通常只处理年轻代,Major / Full GC 的具体覆盖范围取决于收集器;不能理解成 Major GC 必然只看老年代。

复制回收:只搬运存活对象

经典半空间模型为年轻代准备两个空间。一次回收时,把含有待处理对象的空间称为 From,把接收存活对象的空间称为 To:

回收前 From: [活 A][死][死][活 B][死]    To: [空闲]
回收后 From: [整体可复用]                To: [活 A][活 B][连续空闲]

收集器从根和跨代引用入口出发,把存活对象复制到 To 或晋升到老年代,并修正引用。转发信息保证同一个对象不会被重复复制,也保留共享引用和环。之后两个空间轮换角色;文献在分配前后使用 From / To 名称的时机可能不同。

当存活对象少时,需要搬运的数据也少,而且目标空间天然紧凑。不过要付出备用空间、引用扫描和复制的代价。分代不等于复制:V8 除 Scavenger 外也提供年轻代 Minor Mark-Sweep 路径。见 V8 GC 文档。

晋升:年龄和空间策略共同决定

年轻对象经过回收仍存活,可以按收集器策略晋升到老年代。年龄、目标空间容量、对象大小或页面存活率都可能影响决定。

“熬过两次回收”可以作为某些实现的教学例子,但不是分代 GC 的定义;“To 空间超过 25% 就晋升”也不应作为所有 V8 版本和所有收集器的固定规则。堆大小和阈值需要结合具体版本、配置与工作负载理解。

只回收年轻代,如何处理老年代到年轻代的引用

假设根到年轻对象的唯一路径经过老对象:

根 ──→ 老对象 O ──→ 年轻对象 Y

如果 Minor GC 既不扫描整个老年代,也不知道 O → Y 这条边,就可能漏掉 Y。常见解决办法是把跨代引用信息记录下来:

  1. 程序执行 oldObject.child = youngObject 等引用写入时,写屏障执行附加的记录逻辑。
  2. 记忆集记录需要重新检查的引用位置,或通过卡表等结构记录包含这些位置的区域。它是收集器维护的元数据,不要求是老年代里的一块固定“小区域”。
  3. Minor GC 从根与这些跨代引用入口追踪年轻对象,无需每次完整扫描老年代。扫描入口时还需要检查记录是否仍指向年轻代。

为保证正确性,初始化、晋升等过程产生的新跨代引用也必须由实现维护。写屏障还可能服务于并发或增量标记,不只用于分代记录。V8 的相关说明见标记与清除文档中的写屏障部分。

记忆集允许保守保留对象:即使 O 已不可达,只要其相关记录本轮仍被作为入口处理,Y 就可能在这次 Minor GC 中暂时存活,等后续更大范围的回收再清理。因此,“不可达对象可以回收”不意味着每次局部回收都会回收所有不可达对象。

对 Endor 后续设计的启发

本次讨论留下的设计问题是:应分别规定继承边、普通引用边和所有权边的约束,再决定运行时怎样管理对象生命周期。

  • 若普通对象允许互相引用,遍历和持久化需要处理环与共享对象身份。
  • 若采用引用计数,需要决定如何处理循环,例如弱引用、显式断环或附加的循环检测。
  • 若采用追踪式 GC,需要定义根、可扫描的引用字段,以及弱引用等特殊关系的处理方式。
  • 若采用分代策略,需要维护跨代引用,并通过实际对象存活率与暂停时间测量判断收益。

分代回收通常能减少平均回收工作,但不保证没有暂停,也不保证对所有负载都最快。并行、并发和增量执行是另外的优化维度。这些问题应作为后续方案评估项,不能据此推断 Endor 已采用 V8 的方案。