“基础调用约定与栈帧给出一套可复算的参数、帧偏移与保存恢复轨迹;GC根映射进一步要求在移动收集后更新或重载仍要使用的指针位置。这些实现接口与本页的活跃值保持义务配合,但不能由合法图着色单独保证。”
形式陈述
根映射是程序点的函数
在教学 ABI中,表项可以写成
每个列出的槽必须能读取,移动收集时还必须能改写;槽类型是受管理堆基址或空值。返回地址和旧 FP 用来找控制位置与上一帧,本身不是受管理堆引用。已保存寄存器中若存着一个以后会恢复使用的堆指针,其保存槽却可能是根,不能因为它属于序言保存区就排除。
当前帧使用暂停时的程序点查表。沿 FP 链走到调用者时,调用者还没有继续执行,通常用被调用者保存的返回 PC定位调用者在该调用边上的根映射。编译器与运行时必须约定查表键是调用指令地址、返回地址,还是独立编号;本文采用返回 PC,不混用三种编号。
同一函数可以有多张映射。调用前后一个局部槽可能由堆指针改作整数,某个指针也可能过了最后一次使用。仅按函数名写一张“所有槽都是指针”的表,既不精确,也不能安全地支持移动。
编译阶段要交付什么
先用活跃性数据流分析确定每个安全点之后、沿所有可能控制路径仍会读取的值,再结合类型和机器位置映射确定哪些是堆指针。一个引用虽然当前函数不用,暂停调用者稍后还要用,仍属于它所在帧的活跃根。异常路径、保存寄存器恢复和运行时句柄若存在,也要纳入;本文示例没有异常。
本页采用易检查的策略:安全点之前把全部跨点活跃堆指针存到根槽;登记这些槽;安全点以后从被更新的槽重载,旧寄存器值不再使用。根槽不能与同时活跃的其他值共用位置。收集器可能改变槽里的地址,所以编译器也不能把重载当成冗余 load 删除,或把跨安全点的旧地址偷偷重新引入。[1]
“保持至少一个引用让对象活着”只解决不回收;移动回收还要求每个以后会使用的指针副本都更新,或被明确弃用并重算。这正是根映射与普通活跃性列表之间多出的一层义务。
直觉
收集器知道该从根出发,还不知道根此刻放在哪。一个源变量可能在寄存器、栈槽里,也可能已经死亡。根映射把某个可暂停程序点与具体位置关联起来;安全点是编译代码和运行时约定可以在该处解析并更新这些位置的时刻。
安全点不表示“附近没有指针”,而是指针的位置和恢复规则足够明确。本页采用单线程、停止世界、精确基址指针模型,允许分配慢路径和显式 gc_poll 触发收集。收集器运行在独立的运行时栈上,入口保存当前用户帧的 FP 与程序点;下面的“两层帧”只计用户程序的 main、inc。
例子与边界
插点后的 inc 代码
闭包转换得到 inc_code(E,d) 后,在写入与返回之间插入收集点。下面把机器槽的保存、重载写明;fp 是当前用户帧基址,gc_poll 返回后所有旧地址临时量都视为失效。
inc_code(E, d):
C_before = load E.cell
n = load C_before.value
store C_before.value = n + d
store [fp-16] = E
// C_before 与旧 E 在此后不再被使用
gc_poll(current_pc = inc.poll)
E_after = load [fp-16]
C_after = load E_after.cell
return load C_after.value
main 在调用 inc 之前把仍要使用的 peek 闭包 B 存在自己的 [fp−16]。调用后执行 b_after=load [fp−16],再用 b_after.code(b_after.env,0) 调用它。不能一直握着调用前寄存器里的 B 旧址。
此处 C_before 过了最后使用,无须单独登记。若把最后一行改成 load C_before.value,则该临时量会跨安全点活跃,必须另有根槽或重定位规则。仅在说明文字中说“会重载”,却让实际 IR 继续读旧 C,是错误实现。
两层帧如何交出两个根
沿用帧地址 main.FP=8192、inc.FP=8144。暂停点是 inc.poll;inc 的 [FP]=8192 指向上一帧,[FP+8]=main.after_inc 给出调用者查表键。映射如下。
| 查表键 | 相对位置 | 绝对根槽 | 收集前值 | 复制收集后值 |
|---|---|---|---|---|
| main.after_inc | FP−16 | 8176 | B=1072 | B′=2000 |
| inc.poll | FP−16 | 8128 | Ei=1016 | Ei′=2024 |
堆中的 B→Ep→C 与 Ei→C 继续由对象描述符扫描,C 迁移到 2056。收集完成后 inc 从 8128 重载环境,再读新 C,返回 7;main 从 8176 重载 B,再读同一个新 C,得到 7。调用点的普通整数 x=100 不被改写,所以完整结果仍为 (7,7,100)。
一个完整枚举过程是:取得当前用户 FP 与 PC;按 PC 查映射并产出实际槽地址;读保存的调用者 FP 与返回 PC;重复直到约定的栈底;记录完整帧链后,本例把各帧槽按最外层到当前层重新排列,再交给收集器,所以 main.b 先于 inc.env;根顺序只影响新地址布局,不影响语义。随后加入登记的全局及外部句柄槽。相同物理槽去重,两个不同槽即便值相同也保留。未知 PC、损坏的 FP 链或越界槽应中止收集并报错,不得默默当作零根。
漏调用者根会发生什么
若只登记 inc 的 8128,复制收集只发现 Ei 和 C。B、Ep 在旧空间中没有转发地址,随后被整体丢弃。inc 仍能返回 7,这会让只测当前调用结果的测试误以为成功;main 接着从 8176 读到旧值 1072,再调用 b 时,才访问已失效的旧空间。
这就是漏续延根的具体后果:未完成的外层计算保存在暂停帧中,它未来的读取责任不会因为当前 PC 在另一个函数而消失。模型化测试应在旧空间弃用后拒绝旧地址解引用;若仍允许读取旧内存残留,反例可能偶然表现为“还能用”。
另一种缺陷是在表里多登记整数槽。对不移动的保守回收,这可能只是多保留;对本页精确移动回收,若整数恰好像旧地址,收集器可能把整数改成新地址,改变程序输出。精确表不仅要不漏真指针,也不能把普通数值误标成可改写指针。
推论与应用
成本与模型边界
给定映射,枚举 f 个活动帧、r 个根槽需要 O(f+r) 时间,前提是表查询、FP 恢复都是常数成本;排序查表要另计查询代价。按安全点存储的元数据大小与各点位置列表总长成正比。构造活跃集合的静态数据流求解成本是另一项,不能混进收集器的根扫描时间。
固定偏移、物理 FP 链只是本教学 ABI 的便利。真实优化可能省略 FP、复制指针、使用内部派生地址或把对象拆成多个标量;运行时需要相应展开与重定位信息。若持有 p+8,对象搬到 p′ 后应变为 p′+8,只记录一个裸地址而不知所属对象可能不够。[1]
多线程安全点还包含“让各线程到达可解析状态”的协调协议;本文只有一个 mutator,不据此声称完成了停止所有线程。异步信号、外部调用和不遵守根协议的原生代码也在当前模型外。
迁移题:把 C_before 留在一个调用者保存寄存器里,并要求返回后继续用它。两种正确修法是增加一个映射到该指针副本的可改写保存槽,或仍按主例在安全点后从 E_after 重算 C_after。只登记 E 却继续使用旧寄存器不是第三种办法。
参考资料
[1] LLVM,Garbage Collection Safepoints in LLVM, “Overview & Core Concepts”、“Explicit Representation”、“Base & Derived Pointers”,2026-10-08 核查。用于核对所有活副本的重定位义务;本文没有生成或测试 LLVM intrinsic。
[2] Andrew Myers,Cornell CS 4120/5120,Memory Management and Garbage Collection,§5.2 “Computed GC information”,讨论由程序计数器查找栈指针信息,且一个过程可能需要多份表项。