Archipelago

Lec 13垃圾回收 II(GC II)——标记-清扫与复制式回收

讲义完成更新于 2026-09-16

Lec 13 垃圾回收 II(GC II)——标记-清扫与复制式回收

接 L12。展开 Mark-and-Sweep 的实现,引入复制式回收 (copying collector)、转发地址、语义垃圾与权衡

TL;DR

  • 标记-清扫从根集完整遍历可达图,再扫描所有分配对象回收未标记者,能正确处理环但会保留碎片。
  • 复制式回收将可达对象搬到另一半空间,用转发地址修复引用,同时完成回收与压紧,但牺牲一半可用内存。
  • GC 只能识别不可达对象;可达但程序不会再访问的「语义垃圾」仍需要程序员或更强的分析解决。

1. 标记-清扫(Mark and Sweep)

回顾:引用计数对环失效。Mark-and-Sweep 直接处理可达性——标记阶段从根集遍历所有可达对象,清扫阶段回收所有未标记(不可达)对象。

1.1 标记阶段(Mark)

定义(根集 Root Set)

标记的起点。在 MITScript(如多数脚本语言)里,根集就是当前的帧栈(所有栈帧),外加其他全局指针(如全局 None)。从根集出发,沿对象内指针做可达闭包遍历。

定理(标记必须跟随每一个指针)

遍历时务必跟随所有指针:操作数栈 → 值、局部变量 → 值、记录 → 值(字段)、帧的父指针 p、闭包捕获的帧等。注意:记录等对象常由多个小对象组成(如哈希表由多个小块构成)——遍历时只需访问/标记主对象,但回收时必须把其所有从属小对象一并释放。

1.2 清扫阶段(Sweep)

定义(清扫与"如何找到不可达对象")

目标:释放一切不可达对象。但不可达对象正因为"无人指向"而难以枚举,两种办法:

  • 维护一个**"所有曾分配对象"的链表**,清扫时遍历该表,释放未标记者;

  • 用便于枚举的方式分配对象(见复制式回收)。

清扫后清除所有标记位,供下次 GC 使用。

1.3 优化:侵入式头部(Intrusive Header)

定理(用对象头里的 next 指针替代独立链表)

不必为"分配链表"维护与对象分离的结构。像引用计数加 count 头那样,给每个对象加一个头部 + next 指针,把所有对象串成侵入式链表。好处:可一次性释放整个节点;把 GC 的内存开销减半(无需独立的链表节点)。


2. 复制式回收(Copying Collector)

定义(半空间复制式回收)

把堆分成两个区域 A、B(半空间 semispaces):

  • 先把对象紧凑地 (tightly packed) 分配进 A(紧凑分配免去追踪空闲块的开销,分配只需移动一个指针);

  • 当 A 满,遍历所有可达对象,把它们复制到 B;遍历时把指向旧对象的指针改为指向其在 B 中的副本 (twin);

  • 整体一次性释放 A;

  • 交换 A、B 角色,重复。 :::

优点:分配极快(指针碰撞 bump pointer);复制时顺带压紧 (compaction),改善缓存局部性;不可达对象"免费"消失(不复制即可)。缺点:只有一半内存可用;复制本身可能昂贵。

2.1 对象重定位问题(Object Relocation)

复制的核心难题:对象从旧址移到新址后,所有指向旧对象的指针都要更新。两种思路:

把指针都换成"间接表的索引";对象移动时只改表中一处。问题:① 运行期开销高(每次解引用多一跳);② 削弱复制式回收的局部性优势;③ 间接表本身又要做资源管理。

定义(思路二:转发地址 Forwarding Address)

复制一个对象到新区后,在**旧位置原地留下一个"转发地址"**指向新副本。遍历中再遇到指向该旧对象的指针时,跟随转发地址得到新址并更新该指针。这样每个对象只复制一次,且无需全局间接表。

例题(转发地址的工作过程)

旧区有 A、B、C,A 与 C 都含"指向 B"的指针。复制时:

1) 复制 B 到新区得 B',在旧 B 处写下"转发到 B'";
2) 处理 A 中"指向 B"的指针 → 跟随旧 B 的转发地址 → 改为指向 B';
3) 处理 C 中"指向 B"的指针 → 同样跟随转发地址 → 指向 B'。

B 只被复制一次,A、C 的指针都正确重定向到 B'。


3. 标记不是精确的:语义垃圾(Semantic Garbage)

定理(可达性 ≠ "将来会用")

理想目标是"回收一切将来绝不再用的内存",但 GC 只能近似为"回收不可达内存",并不精确。存在语义垃圾 (semantic garbage):仍然可达、但程序实际上再也不会用的对象——GC 无法回收它们。

例题(语义垃圾)

程序在初始化时把一个大数据结构存进全局变量、用于启动;启动后再也不碰它。该数据仍从全局根可达,故不会被回收——但它就是垃圾。要验证"程序此后绝不再访问它"通常是不可判定/极难的,所以 GC 选择保守保留。


4. 各方案的权衡(Tradeoffs)

定理(GC 方案权衡)

  • 引用计数:开销高,但分摊在整个执行过程中(无明显停顿);不处理环。

  • 标记-清扫 / 复制式:总开销更低,但集中爆发,可能造成可察觉的应用停顿 (stalls)。

  • 复制式:分配极快、压紧改善缓存局部性,但复制对象可能很昂贵,且浪费一半空间。 :::

::: definition 定义(GC 设计的三个关键维度)

  • 运行期开销:GC 给正常程序执行增加的成本;

  • 空间效率:GC 需要多少额外空间?回收对象有多快?

  • GC 延迟:能否避免长时间停顿?

三者相互权衡,没有"万能最优",依应用场景(吞吐 vs. 延迟 vs. 内存)取舍。


5. 本讲小结

  • Mark-Sweep:根集(MITScript 即帧栈 + 全局指针)→ 标记可达(跟随每个指针,记录只标主对象但回收其全部从属小对象)→ 清扫(遍历分配链表释放未标记者);侵入式 next 头部可省独立链表、内存开销减半。
  • 复制式回收:双半空间,紧凑分配;满则把可达对象复制到另一半并重定向指针、整体释放旧半、交换。
  • 重定位用转发地址(旧址留指向新副本的转发指针,遇旧指针即跟随更新)优于全局间接表。
  • 标记不精确:语义垃圾(可达但永不再用)无法回收。
  • 权衡:引用计数开销分摊但不处理环;标记/复制开销集中可致停顿;复制分配快、局部性好但费空间。三维度:运行期开销 / 空间效率 / 延迟。