Archipelago

Lec 12垃圾回收 I(Garbage Collection I)——可达性与引用计数

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

Lec 12 垃圾回收 I(Garbage Collection I)——可达性与引用计数

Phase 3 开始。为什么会产生垃圾、如何用可达性近似"未来不再使用"、引用计数及其局限(环)

TL;DR

  • 运行时无法普遍准确预知对象未来是否还会被使用,因此 GC 以「从根集可达」作为安全的近似。
  • 引用计数在指针更新时增减计数,计数归零即可立即回收,但每次赋值都有成本,且不能回收不可达环。
  • 标记-清扫从根集遍历指针图标记可达对象,再统一回收未标记对象,能处理环但引入停顿与遍历成本。

1. 垃圾从哪来

回顾 L8/L10 的语义:每次运算、每次进入作用域/调用都在堆上分配,而我们到目前为止"只分配、不回收"。

例题(堆中的垃圾累积)

1: var f = 0;
2: { var x = 1;
3:   f = fun(y) { print(x); x = x + 1; };
4: }
5: f(1);  6: f(2);

执行后堆里有:闭包 116=(208,y,…)、被它捕获而必须存活的帧 208;以及两次调用产生的实参帧 216、224 —— 这些调用帧在 f 返回后再也用不到,是垃圾;还有被覆盖的旧整数对象(如 x 累加时旧的 108、124)。

定义(垃圾回收的核心问题)

内存最终会被"再也不会被使用"的对象填满,不回收就会耗尽。理想目标:把"将来绝不再用"的内存还给系统。但我们无法预测未来——所以用一个可计算的近似。


2. 可达性:关键近似(Reachability)

定义(可达性近似)

程序未来只可能通过从根出发能到达的指针访问对象。因此用可达 (reachable) 近似"将来会用":

  • 不可达的对象 ⟹ 一定不会再用 ⟹ 可安全回收;

  • 可达的对象 ⟹ 可能还会用 ⟹ 保守保留(即便实际上不会再用)。

这是一个保守 (conservative) 近似:不会误删将来要用的对象,但可能保留一些其实已无用的可达对象。


3. 引用计数(Reference Counting)

定义(引用计数)

近似可达性的最简单方法:给每个对象维护一个引用计数 (count)——有多少引用指向它。当计数降为 0,对象不可达,立即回收。

例题(引用计数随赋值变化)

x = 5;   // 对象5: c=1
y = 7;   // 对象7: c=1
z = x;   // 对象5: c=2(x、z 都指它)
y = z;   // 对象7: c=1→0 ⟹ 回收;对象5: c=2→3

y = z 让 y 改指对象 5:先给对象 5 计数 +1(=3),再给 y 原来指的对象 7 计数 −1(=0)⟹ 回收对象 7。

3.1 在 VM 中实现:load / store / 算术

VM 里赋值分两步:先把右值压操作数栈,再弹出存入变量。引用计数要在每条相关指令上插入增减计数的代码:

定义(带引用计数的指令语义)

load_local i:        x = 变量 i 的值对象;  x.count += 1;  push x
store_local i:       x = 变量 i 原来指的对象;  x.count -= 1;
                     y = 从栈弹出的对象;  变量 i := y;
                     if (x.count == 0) deallocate x;
add:                 y = 弹出;  y.count -= 1;
                     x = 弹出;  x.count -= 1;
                     z = new Integer(x.ival() + y.ival());  z.count = 1;  push z;
                     if (x.count == 0) deallocate x;
                     if (y.count == 0) deallocate y;

算术指令也在"创建与销毁对象":每次 add 分配一个新整数对象(count=1),并对两个操作数减计数、必要时回收——这呼应 L8 的"每运算一分配"。(实践中很多增减可被优化消除,如 store 时不必给刚压栈的 y 增计数再减。)

3.2 引用计数的两个挑战

定理(引用计数的两大局限)

  • 性能:几乎每条指令都附带计数增减开销,对许多指令而言开销显著。

  • 正确性(环):引用计数只是可达性的近似,对环 (cycles) 失效——相互引用的不可达对象计数永不归零,永不回收。 :::

::: example 例题(环导致泄漏)

x = {next: None};   // 对象 O1
y = {next: x};      // 对象 O2,O2.next → O1
x.next = y;         // O1.next → O2(形成环)
x = 0;  y = x;      // 根不再指向 O1、O2

执行后 O1、O2 互相引用,从根都不可达,但彼此的引用使各自计数仍 = 1,永远不会归零,内存泄漏。


4. 标记-清扫(Mark and Sweep)导论

定义(Mark and Sweep)

不再用计数近似,而是显式处理可达性:

  • 标记 (Mark):从根集 (root set) 出发,遍历所有可达对象并标记。

  • 清扫 (Sweep):扫描堆,回收所有未被标记(不可达)的对象。

因为直接以"可达"为判据,它能正确处理环(环若整体不可达,则标记不到,被清扫)。

定义(根集 Root Set)

标记的起点——可直接访问的指针集合:全局变量、当前栈上各帧的变量/操作数栈中的指针、当前帧指针 FP 等。"可达"即从根集沿对象内的指针(帧的父指针 p、记录字段、闭包捕获的帧等)传递闭包能到达。

在上面的 GC 例子里,从根(全局 f、当前 FP=200)出发可达:帧 200 → 闭包 116 → 它捕获的帧 208 → x 指的整数等;而调用结束后的实参帧 216/224 已无人指向 ⟹ 不可达 ⟹ 标记不到 ⟹ 清扫回收。下一讲展开 Mark 与 Sweep 的具体算法。


5. 本讲小结

  • 语义层"只分配不回收"必然耗尽内存;无法预测未来,用可达性作保守近似(不可达必无用、可达则保留)。
  • 引用计数:每对象记引用数,归零即回收;在 VM 的 load/store/算术里插入增减计数。
  • 局限:① 每指令开销大;② 对环失效(互引用的不可达对象计数不归零 → 泄漏)。
  • 标记-清扫显式算可达性:从根集标记可达对象、清扫未标记者,可正确处理环。下一讲展开。