Skip to content

算法Algorithm

复制式垃圾回收

Copying garbage collection · Cheney algorithm · Semispace collection · 半空间复制收集

用 Cheney 的扫描区间与转发地址把存活对象复制到另一半空间,同时更新全部根和字段,保持共享与环。

形式陈述 ​

固定半空间模型 ​

堆有两个等大的半空间,每半容量为 H 字节。程序只在当前一半顺序分配;收集时它成为 from-space,另一半成为 to-space。收集全程暂停程序,根位置完整且可写,对象指针均为基址,没有未登记外部引用、弱引用或终结器。

对象按描述符给出大小和指针槽。新对象先完整复制原头与字段;旧对象头随后改成带标签的转发地址。转发头与描述符头必须可区分。例如原描述符地址低位为 0,利用对齐空出的低位将转发地址标记为 1;解码时恢复原新地址。整数和代码字段不作此解释。

to-space 有三个区间,scan 与 free 都落在对象边界:[base,scan) 已扫描,[scan,free) 已复制但尚未扫描,[free,limit) 未使用。中间这段对象本身就是队列,因此 Cheney 算法不另建递归遍历栈。

先定义唯一的转发操作 ​

text
forward(p):
  if p == null: return null
  require p is an object base in from-space
  if header(p) is Forward(q): return q
  d = descriptor(p)
  s = d.size
  require s <= to_limit - free
  q = free
  copy s bytes from p to q
  free = free + s
  header(p) = Forward(q)
  return q

collect(root_slots):
  scan = free = to_base
  for each root slot s:
    value(s) = forward(value(s))
  while scan < free:
    d = descriptor(scan)
    for each managed pointer field slot f of object scan:
      value(f) = forward(value(f))
    scan = scan + d.size
  make to-space the active allocation space
  next = free
  discard all contents of old from-space

本版每个 to-space 字段只扫描一次,扫描前它仍含从旧对象复制来的 from-space 地址;已扫描的根槽也不再重复扫描。同一值可出现在许多不同槽中,它们都调用 forward 并由旧头查到相同新址。根槽列表本身应去除同一物理位置的重复项,或者让实现显式允许输入已经位于 to-space 的地址;本文采用前者,避免第二次把已改写槽误认作非法 from-space 输入。

等容量半空间中,复制总量不超过收集前已使用量,而后者不超过 H,所以合法堆的本次复制能装下。完成后新请求大小 s 仍须检查 live_bytes+s≤H,不满足就报告内存不足。若使用更小的目标区,必须预先证明装得下或提供可靠失败协议;已经改写根与转发头后,不能简单“取消”并恢复旧程序。

直觉

复制式回收让活对象搬到一块连续空间,再整体丢弃旧空间。搬家时最容易出错的不是字节复制,而是同一个旧对象只能有一个新家,所有仍要使用它的引用都必须指向那里。两个计数器闭包共享的单元如果被复制两次,程序的状态共享就被拆开了。

例子与边界

四轮扫描算到 2072 ​

使用计数器快照。from-space 起点 1000;对象依次为 C(16字节)、Ei(16)、A(24)、Ep(16)、B(24)、P(24)、U(16),总量 136。根槽顺序固定为 main.b=B@1072,随后 inc.env=Ei@1016。B 指 Ep,Ei、Ep 同指 C,C 内为整数 7。

to-space 从 2000 开始。处理根后,B 复制到 2000,占 [2000,2024);Ei 复制到 2024,占 [2024,2040)。此时 scan=2000, free=2040,两个根槽已经分别改为 2000 与 2024。

扫描对象 处理的指针字段 新动作 扫描后 scan free
B@2000 env=1056 复制 Ep 到 2040,改 env 2024 2056
Ei@2024 cell=1000 复制 C 到 2056,改 cell 2040 2072
Ep@2040 cell=1000 查转发,改为已有 2056 2056 2072
C@2056 无指针字段 不解释整数 7 2072 2072

扫描结束,存活映射为

B:1072↦2000,Ei:1016↦2024,Ep:1056↦2040,C:1000↦2056.

只复制 72 字节,A、P、U 从未遇到,旧空间整体弃用时一并消失。Ei 的 +8 字段与 Ep 的 +8 字段都保存 2056;闭包的代码地址原样保留。根槽从“旧址仍在”变为“新址可用”,正是对象移动而行为不变的接口。

scan 到 free 是隐式工作队列

环与错误变体 ​

给一个对象 X 的指针字段写 X 自身,并让根指 X。第一次 forward 建立 X′ 并立刻在 X 写转发头;扫描 X′ 的字段时再次遇 X,直接得到 X′,于是新对象仍自指。若等扫描全部子对象后才安装转发地址,会在自环上无限递归或反复复制。

两个环境若分别复制 C 且不查转发,会得到 C₁、C₂。复制刚结束时两者都为 7,单次 peek 甚至可能看不出错误;再经 inc 把 C₁ 改成 10,peek 从 C₂ 仍读 7,才暴露共享丢失。测试应继续执行可变状态操作,不能只比较 GC 刚结束时的数值。

另一个错误只改根而不改堆字段。B′ 仍指旧 Ep,旧空间复用后就悬空。反过来,只改堆字段而漏改 main 的根槽,也会在返回调用者时读取旧 B。已死亡的位模式可以留在不再读取的槽里;以后还要用的指针副本必须改写或弃用后从已改写位置重载。

推论与应用

三个不变量保证不会拆散对象图 ​

第一,每个带转发头的旧对象恰好对应一个已分配的新对象;只有首次访问才推进 free,之后都返回同一地址。因此映射 f 是单射,不同旧对象不会挤在同一区间,同一个旧对象不会产生两个副本。

第二,[base,scan) 内全部受管理指针已指向对应的新对象,[scan,free) 中的对象内容已经复制,字段待修正。扫描一个对象时,对其每个非空字段执行 forward,既修正边,又把尚未遇见的目标加入队尾。处理完后推进 scan,两个区间的含义保持。

第三,所有改写后的根指向对应新对象,任何已经发现对象的未完成出边都在待扫描区中等待处理。每轮至少完成一个正大小对象,每个旧对象至多复制一次,有限堆保证终止。scan=free 时没有待修正字段,所以对任一活字段 o.field=p,新堆满足 f(o).field=f(p)。再结合根对应与普通整数、代码字段不变,图结构与共享可变状态得以保留。

成本与迁移 ​

设根槽数为 r、存活对象总字节为 L、其中指针字段数为 E_L。若复制一个字节计常数,收集工作为 O(r+L+E_L),在定宽字段模型下可写 O(r+L)。不扫描全部死亡对象是它与原址清扫的重要区别。额外的遍历队列为常数个指针,但保留两个半空间本身需要 2H 容量,不能据此称总额外空间为常数。

复制结束后有效空闲区连续,外部碎片得到消除;搬动全部活数据也带来带宽和暂停成本。活集接近 H 时,每次只腾出少量空间,频繁收集可能很贵。单次线性成本不等于每次分配都只付常数代价。

迁移题:在主例再加一个不同根槽 alias=B。它应与 main.b 都变为 2000,复制量仍为 72 字节。再把 root_slots 的顺序倒过来,新地址顺序可能变化,但活对象数量、整数结果和“两个环境指向同一个 C”的关系必须不变;不能把一种遍历顺序的地址当作语义保证。

分代回收把本页复制算法限制在幼代,但必须额外登记每个老→幼入口槽,扫描并改写它们。只换成较小的幼代空间而忽略跨代槽,会在根仍能经老对象到达幼对象时误删活对象;提升产生的新跨代边同样需要登记。

参考资料

[1] C. J. Cheney,A Nonrecursive List Compacting Algorithm,CACM 13(11),1970,677–678,非递归复制收集的原始算法。

[2] Andrew Myers,Cornell CS 4120/5120,Memory Management and Garbage Collection,§8 “Copying collection”,尤其页 6–7 的扫描边界与转发讨论。本文以此核对算法,使用自己的根槽与地址算例。

关系图谱14 个相邻概念 · 3 类关系

拖动节点调整位置。

显示关系

显示:依赖

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