“对象按描述符给出大小和指针槽。新对象先完整复制原头与字段;旧对象头随后改成带标签的转发地址。转发头与描述符头必须可区分。例如原描述符地址低位为 0,利用对齐空出的低位将转发地址标记为 1;解…”
形式陈述
一个可扫描的对象格式
本页固定 64 位机器字、8 字节对齐,只允许对象起始地址作为受管理指针。对象地址 p 指向一个字的头部,字段 0 在 p+8,字段 1 在 p+16,依此类推。描述符是存放在静态、不可移动区域的记录,头部保存其地址;描述符给出总字节数及“哪些字段是受管理指针”的位图。
把对象的连续机器字看成一个数组,字段编号决定相对偏移。所有字段都占一个字。空受管理指针记作 null,扫描时跳过;代码地址、描述符地址和整数都不属于受管理堆指针。代码地址可以被间接调用,却不让回收器进入代码区。为避免歧义,本文的 Cell 只保存整数,若后来需要引用型 Cell,必须使用另一个描述符。
| 类型 | 头部之后的字段 | 大小 | 被追踪字段偏移 |
|---|---|---|---|
| Cell | value:整数 | 16 | 无 |
| Env | cell:堆指针 | 16 | +8 |
| Closure | code:代码地址;env:堆指针 | 24 | +16 |
| Pair | first、second:堆指针 | 24 | +8、+16 |
对一般 m 字段记录,大小为 8(1+m)。若允许字节串或混合宽度字段,须再按各字段对齐和尾部填充计算,不能继续硬套这个式子。变长数组还需记录长度并检查大小乘法溢出;本页只实现固定描述符记录。
这套格式不要求每个对象拥有独立位图。所有 Env 共用一个描述符,所以扫描不同的 Env 时仍采用同样的字段偏移。标记清扫可在旁表保存标记位;复制收集还会使用带标签的转发头,两者都必须与正常描述符头区分。
顺序分配器的输入与状态
分配区是半开区间 [base,limit),状态 next 表示第一个未用字节;三者都按 8 对齐。输入为可信描述符 d 及已求值的初始字段。请求大小 s=d.size 必须为正且按 8 对齐,字段数量与类型必须匹配描述符。
不先计算 next+s 再检查,因为定宽加法可能绕回。先验证 base≤next≤limit,再检查 s≤limit−next。若满足,预留 [next,next+s) 并把 next 增加 s;否则在允许的安全点收集,随后按更新后的 next,limit 重查。收集后仍不满足就返回明确的内存不足,不返回一个越界地址,也不无限重试同一失败请求。
预留成功不等于对象立即可以被扫描。本文采用短的不可收集中段:写合法描述符、把指针字段置空、填入初值、把结果发布到已登记根槽,然后才允许下一个安全点。这个段内不调用其他可能分配的函数。若实现必须在初始化途中收集,就需要更复杂的“部分对象”扫描协议,本页不假设它自动存在。
初始字段本身也可能是堆指针。请求空间不足而收集时,这些尚未写入新对象的值必须已经在根槽中,收集后从根槽重读,再写入对象。只保护新对象而漏保护它的输入,同样会把旧地址装进刚分配的对象。
直觉
回收器看到的是一段段机器字。它必须知道每个对象有多大,以及哪些字是要继续追踪的堆指针。把整数 1000 当成地址会保留无关对象;把真正的引用当成整数则可能释放仍被使用的数据。对象布局因而同时是生成代码、分配器和回收器之间的契约。
例子与边界
从符号对象到具体字节
把两个共享计数器闭包顺序放入从 1000 开始的分配区。描述符不在此区计费。按 Cell, Env, Closure, Env, Closure, Pair 分配,得到:
| 对象 | 地址区间 | 字段内容 |
|---|---|---|
| C | [1000,1016) | value=4,调用 inc 后为 7 |
| Ei | [1016,1032) | cell=1000 |
| A | [1032,1056) | code=inc_code,env=1016 |
| Ep | [1056,1072) | cell=1000 |
| B | [1072,1096) | code=peek_code,env=1056 |
| P | [1096,1120) | first=1032,second=1072 |
例如读取 B.env.cell.value,先从 1072+16=1088 取 1056,再从 1056+8=1064 取 1000,最后从 1000+8=1008 取 7。不要把字段槽地址 1088 当成闭包地址,也不要在两个环境里各建一个值为 4 的 Cell。
再分配一个随后不再使用的 Cell(999),它为 U@[1120,1136),此时共分配 136 字节。若区间上界是 1136,再请求 16 字节会失败容量检查。把 next 从 1136 直接改回 1000 是错误的:旧对象仍可能被根引用,必须先由收集器确定并保留存活部分。
失败条件与迁移
若错误地把 Closure 的 code 偏移 +8 标为受管理指针,精确回收器可能把代码地址当作无效对象基址而报错;若同时漏掉 +16,则环境可能被漏追踪。把两个位交换,不是多保留一点内存的保守误差,而是直接破坏安全。
若 p 是对象基址,内部地址 p+8 只允许用于当前字段访问,不能作为跨安全点保存的受管理值。本限制让迁移映射直接作用于基址。支持内部或派生指针需要附带所属对象和偏移,见安全点与根映射。
迁移题:新增 Triple,三个字段依次为“整数、堆指针、堆指针”。答案是大小 32 字节,位图 0,1,1,受追踪偏移 +16、+24。若初始整数恰好等于某对象地址,扫描结果仍不能变化。再令剩余空间为 24 字节,分配必须在写任何新字段前报告容量不足。
推论与应用
不变量与成本
在相邻安全点之间,已发布对象区间互不重叠、大小与描述符一致;每个被描述为指针的槽保存合法对象基址或空值。首次分配前性质为空真。一次成功预留只使用旧 next 之后的未占区间,并保持对齐;初始化协议保证发布时可扫描,因此归纳保持三项性质。
容量充足时,边界检查和移动 next 为常数工作,写入 m 个字段为 O(m)。如果描述成“分配始终 O(1)”却同时要求清零任意大小对象,就少算了初始化。收集成本、向操作系统扩展空间的成本和清零新页的成本,都不能藏在这个快速路径里。
头部和填充属于真实空间开销;本例 6 个对象头合计 48 字节,全部对象共 120 字节。指针位图描述符按类型共享,另有固定元数据开销。即使环境只装一个指针,它仍需 16 字节,不能只把源程序中的三个整数相加当成堆大小。
参考资料
[1] Andrew Myers,Cornell CS 4120/5120,Memory Management and Garbage Collection,讲义页首标注 2018-05-03,§1.1 “Linear allocation”、§1.2 “Freelist allocation”、§5.2 “Computed GC information”。路径年份不作讲义日期;本文偏移、描述符和地址为自定教学模型。
[2] LLVM,Garbage Collection with LLVM, “Identifying GC roots on the stack” 中的根槽与初始化讨论,2026-10-08 核查。用于说明初始化与根位置是接口义务,不表示上述分配器采用 LLVM API。