Lec 12垃圾回收 I(Garbage Collection I)——可达性与引用计数
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→3y = 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/算术里插入增减计数。
- 局限:① 每指令开销大;② 对环失效(互引用的不可达对象计数不归零 → 泄漏)。
- 标记-清扫显式算可达性:从根集标记可达对象、清扫未标记者,可正确处理环。下一讲展开。