Skip to content

模型Model

堆对象布局与分配

Heap object layout and allocation · Object descriptor · Bump-pointer allocation

用对象头、字段偏移与指针描述符规定可扫描的堆布局,并给出带溢出检查和初始化约束的顺序分配器。

形式陈述 ​

一个可扫描的对象格式 ​

本页固定 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。

关系图谱5 个相邻概念 · 1 类关系

拖动节点调整位置。

显示关系

显示:依赖

  1. 前置三跳
  2. 前置二跳
  3. 前置一跳
  4. 当前条目
  5. 后续一跳
  6. 后续二跳
  7. 后续三跳
文字版关系按与当前条目的最短距离分组