From e0191e1aa3ac43ec49a5d015fa0a09f599250ac5 Mon Sep 17 00:00:00 2001 From: Codex Bot Date: Wed, 28 Oct 2026 14:04:19 +0800 Subject: [PATCH] Document object reference cycles and generational garbage collection --- content/catalog.json | 4 + content/docs/05-object-graphs-gc.md | 148 ++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 content/docs/05-object-graphs-gc.md diff --git a/content/catalog.json b/content/catalog.json index 439f9a2..707946a 100644 --- a/content/catalog.json +++ b/content/catalog.json @@ -41,6 +41,10 @@ "description": "从程序入口走向用户与 AI 的对象操作。", "draft": true }, + "05-object-graphs-gc": { + "group": "运行时基础", + "description": "从对象引用环到标记清除、分代回收与跨代引用,记录内存管理讨论。" + }, "pretext/00-overview": { "description": "理解高性能文本布局的原理,沿着 12 章教程阅读源码。", "featured": true diff --git a/content/docs/05-object-graphs-gc.md b/content/docs/05-object-graphs-gc.md new file mode 100644 index 0000000..bf7442b --- /dev/null +++ b/content/docs/05-object-graphs-gc.md @@ -0,0 +1,148 @@ +# 对象关系、循环引用与分代垃圾回收 + +记录日期:2026 年 10 月 28 日。本文整理一次从“继承是有向无环图,那么 object 的所有关系可以有环吗?”展开的讨论,依次解释对象图、追踪式垃圾回收和分代回收。这里记录通用原理与其他运行时的例子,不代表 Endor 已确定或实现了某种内存管理方案。 + +## 继承无环,不等于整个对象图无环 + +**是否允许成环,要看边代表什么关系。** 把所有关系都画在同一张图上,会掩盖不同关系的约束。 + +| 关系 | 节点与边的含义 | 是否允许有环 | +| --- | --- | --- | +| 类继承 | 类指向父类 | 通常禁止;单继承形成森林,多继承可形成 DAG | +| 原型委托 | 对象沿原型链查找属性 | JavaScript 普通对象的原型链禁止形成环 | +| 普通强引用 | 对象字段指向另一个对象 | 允许,例如父子反向引用、双向链表、社交关系 | +| 所有权或包含关系 | 生命周期由谁管理、谁包含谁 | 由模型规定;树形包含通常限制成环 | + +继承的无环约束让“父类”构成可排序的层次,便于定义类型关系、成员查找和初始化顺序。不能简单地说“有环必然让查找无限递归”:查找算法可以检测环,但语言仍可以为了保持继承层次的语义而禁止环。不同语言的检测时机也不同,不一定都在语法或编译阶段。 + +普通字段引用则可以这样成环: + +```js +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 算法](https://tc39.es/ecma262/multipage/ordinary-and-exotic-objects-behaviours.html#sec-ordinarysetprototypeof)。 + +## 循环引用影响哪些操作 + +对图进行递归遍历时,需要用已访问集合等方式防止重复处理。序列化也必须决定怎样表示对象身份和回边。 + +原生 `JSON.stringify()` 在实际遍历到循环引用时会抛出 `TypeError`,因为 JSON 本身没有表示对象引用的机制。可以按业务需要输出对象 ID,再用独立的关系表记录连接;不能简单丢弃回边后仍声称完整保存了对象图。相关规则见 [ECMAScript 的 SerializeJSONObject 算法](https://tc39.es/ecma262/multipage/structured-data.html#sec-serializejsonobject)。 + +内存管理上的关键区别是: + +- **单纯的引用计数**只看入引用数量。没有外部引用的环内部仍彼此计数,无法仅靠“计数降到零”释放。 +- **追踪式 GC**从根出发判断可达性。孤立环没有来自根的路径,可以被回收。 +- 引用计数也可以搭配循环检测。CPython 就以引用计数为基础,另有循环垃圾收集器,不能把它直接归为与 V8 相同的标记清除实现。见 [Python 的 gc 文档](https://docs.python.org/3/library/gc.html)。 + +环并不必然造成内存泄漏。反过来,即使对象关系无环,只要长期缓存等根可达对象一直引用着已经不需要的数据,追踪式 GC 也不会替程序判断它们“业务上已经没用”。 + +## Tracing GC 如何回收孤立环 + +Tracing GC(追踪式垃圾回收)把从根沿强引用可达的对象视为需要保留的对象。根可以包括执行栈中仍然存活的引用、全局环境,以及宿主运行时持有的句柄;具体集合由实现决定。源代码里存在一个变量名,并不等于它在任意时刻都一定是 GC 根。 + +下面用概念上的对象图说明一次回收: + +```text +根 R ──→ A ⇄ B 保留:A、B 可从根到达 + + C ⇄ D 回收候选:C、D 彼此引用,但根到不了 +``` + +### 标记:从根寻找可达对象 + +最简单的工作队列算法如下。这里假设程序暂停、只有普通强引用,忽略弱引用和终结器处理。 + +```text +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 概览](https://v8.dev/blog/trash-talk)和[标记与清除实现文档](https://github.com/v8/v8/blob/main/docs/heap/marking-and-sweeping.md)。 + +## 分代回收为什么更快 + +分代回收利用弱分代假说:在许多程序中,大量对象分配后很快就变得不可达。它是关于工作负载的经验规律,不是所有程序都满足的定理。“活得短”指对象很快不再可达,而不是函数一返回,对象就必然死亡;返回值、闭包和缓存都可能延长对象的生命周期。 + +运行时把对象按年龄等信息分组,让年轻代更频繁地回收,以减少每次检查整个堆的开销。 + +| 方面 | 年轻代 | 老年代 | +| --- | --- | --- | +| 常见对象 | 刚分配、尚未晋升的对象 | 已晋升的长期存活对象;部分对象可能直接分配到相关空间 | +| 常见存活特征 | 大量对象很快不可达 | 经筛选后存活率往往较高 | +| 回收思路 | 经常进行较小范围的回收 | 较低频率地进行更大范围的回收 | +| 可能的算法 | 复制、标记清除等 | 标记清除、选择性整理等 | + +老年代并不意味着“对象数量少”;长期缓存和不断增长的数据结构都可能使它很大。Minor GC 通常只处理年轻代,Major / Full GC 的具体覆盖范围取决于收集器;不能理解成 Major GC 必然只看老年代。 + +### 复制回收:只搬运存活对象 + +经典半空间模型为年轻代准备两个空间。一次回收时,把含有待处理对象的空间称为 From,把接收存活对象的空间称为 To: + +```text +回收前 From: [活 A][死][死][活 B][死] To: [空闲] +回收后 From: [整体可复用] To: [活 A][活 B][连续空闲] +``` + +收集器从根和跨代引用入口出发,把存活对象复制到 To 或晋升到老年代,并修正引用。转发信息保证同一个对象不会被重复复制,也保留共享引用和环。之后两个空间轮换角色;文献在分配前后使用 From / To 名称的时机可能不同。 + +当存活对象少时,需要搬运的数据也少,而且目标空间天然紧凑。不过要付出备用空间、引用扫描和复制的代价。分代不等于复制:V8 除 Scavenger 外也提供年轻代 Minor Mark-Sweep 路径。见 [V8 GC 文档](https://github.com/v8/v8/blob/main/docs/heap/garbage-collection.md)。 + +### 晋升:年龄和空间策略共同决定 + +年轻对象经过回收仍存活,可以按收集器策略晋升到老年代。年龄、目标空间容量、对象大小或页面存活率都可能影响决定。 + +“熬过两次回收”可以作为某些实现的教学例子,但不是分代 GC 的定义;“To 空间超过 25% 就晋升”也不应作为所有 V8 版本和所有收集器的固定规则。堆大小和阈值需要结合具体版本、配置与工作负载理解。 + +## 只回收年轻代,如何处理老年代到年轻代的引用 + +假设根到年轻对象的唯一路径经过老对象: + +```text +根 ──→ 老对象 O ──→ 年轻对象 Y +``` + +如果 Minor GC 既不扫描整个老年代,也不知道 O → Y 这条边,就可能漏掉 Y。常见解决办法是把跨代引用信息记录下来: + +1. 程序执行 `oldObject.child = youngObject` 等引用写入时,**写屏障**执行附加的记录逻辑。 +2. **记忆集**记录需要重新检查的引用位置,或通过卡表等结构记录包含这些位置的区域。它是收集器维护的元数据,不要求是老年代里的一块固定“小区域”。 +3. Minor GC 从根与这些跨代引用入口追踪年轻对象,无需每次完整扫描老年代。扫描入口时还需要检查记录是否仍指向年轻代。 + +为保证正确性,初始化、晋升等过程产生的新跨代引用也必须由实现维护。写屏障还可能服务于并发或增量标记,不只用于分代记录。V8 的相关说明见[标记与清除文档中的写屏障部分](https://github.com/v8/v8/blob/main/docs/heap/marking-and-sweeping.md)。 + +记忆集允许保守保留对象:即使 O 已不可达,只要其相关记录本轮仍被作为入口处理,Y 就可能在这次 Minor GC 中暂时存活,等后续更大范围的回收再清理。因此,“不可达对象可以回收”不意味着每次局部回收都会回收所有不可达对象。 + +## 对 Endor 后续设计的启发 + +本次讨论留下的设计问题是:应分别规定继承边、普通引用边和所有权边的约束,再决定运行时怎样管理对象生命周期。 + +- 若普通对象允许互相引用,遍历和持久化需要处理环与共享对象身份。 +- 若采用引用计数,需要决定如何处理循环,例如弱引用、显式断环或附加的循环检测。 +- 若采用追踪式 GC,需要定义根、可扫描的引用字段,以及弱引用等特殊关系的处理方式。 +- 若采用分代策略,需要维护跨代引用,并通过实际对象存活率与暂停时间测量判断收益。 + +分代回收通常能减少平均回收工作,但不保证没有暂停,也不保证对所有负载都最快。并行、并发和增量执行是另外的优化维度。这些问题应作为后续方案评估项,不能据此推断 Endor 已采用 V8 的方案。