Skip to content

算法Algorithm

引用计数与循环垃圾

Reference counting · Strong reference count · 引用计数回收

按强引用槽的多重入度维护精确计数,用先保留后释放保护别名,执行零计数级联并解释循环为什么残留。

形式陈述 ​

本页的引用计数(RC)是具体对象回收协议。固定单线程、非移动对象、强引用槽、空值和精确布局,没有弱引用、终结器、对象复活、并发或指针伪造。所有未来仍可用的外部引用都保存于登记的根槽;函数参数和临时引用若跨越可能释放的操作,也必须纳入这些槽。原始整数对象 ID 只供检查器展示,不授予凭空构造引用的能力。

设 H 是已分配对象集,R 是根槽,Fields(H) 是所有已分配对象的强字段槽,允许一个对象有多个字段指向同一目标。操作边界处维持

rc(o)=|{s∈R∪Fields(H):value(s)=o}|.

这是按槽计的多重入度,不是“有多少不同父对象”,更不是从根可达路径数。计数为零只说明没有入引用;正计数不证明可达。[1]

分配协议先得到全空字段的新对象,再原子放入一个已存在的空根槽,初始 rc=1。字段初始化之后的每次引用赋值都走 replace(slot,new);new 必须是已有、受有效引用保护的对象或 null,目标槽必须存在且其所属对象仍合法。算法先校验,然后:若 old=new,直接结束;否则先增加 new 的计数,再将槽改成 new,最后减少 old 的计数。old 若降为零,加入待释放队列。

text
drain_one():
  if queue empty: return
  o = queue.front
  require o exists and rc(o)==0
  remove o from queue
  for each strong field slot in o:
    child = value(slot)
    delete that edge
    if child != null:
      rc(child) -= 1
      if rc(child)==0: enqueue child
  remove o from H

每次 drain_one 作为一个不可中断的回收步骤;删除字段、调整子计数、移除对象共同形成下一合法边界。队列中零计数对象尚未释放,其出边仍计入子对象入度,直到处理它时才删除。mutator 不能取得队列中对象的新引用;这样零计数对象不会被“复活”,也不会重复排队。用队列而非宿主递归释放可避免级联深度耗尽调用栈,却不会消除级联总工作。

直觉

跟踪回收从外部根向前询问“还能到哪里”;RC 持续维护“有几个槽仍握着我”。最后一个槽松手时,可以释放对象,再让它持有的槽逐一松手。这在链和树上很自然,但一个与外界断开的环会让各成员互相握住,永远没人先变零。

两个字段与一个根使C计数为三,而无根二对象环各保持一
例子与边界

两个字段也是两次持有 ​

根 r 指向对象 P;P 有两个字段 a、b,都指向 C;另有根 t 指 C。此时 rc(P)=1,rc(C)=3,虽然 C 只有一个堆中父对象。执行 r=null 后,P 降到0入队,C 仍为3,因为 P 尚未实际释放。

处理 P 时删除 P.a,使 C 从3到2;删除 P.b,使 C 从2到1;再移除 P。现在只有 t 持有 C。执行 t=null 后 C 变0入队,处理 C 才释放它。每条字段边都只删除一次,整个过程不会因共享而过早释放 C。

再设只有根 t 指 C,执行 t=t。正确算法识别 old=new,计数保持1。错误算法若先减 old 并立即释放,再增加 new,就会在已经释放的 C 上做 retain。即使错误实现延迟释放,把已经入队的零对象恢复到1而忘记撤销队列,也会在后续 drain 时出错。先保留新值再松开旧值,以及清晰的队列状态契约,必须一起成立。

环没有零计数入口 ​

创建 X、Y,根 r 指 X,字段 X.next=Y、Y.next=X。无其他引用时 rc(X)=2、rc(Y)=1。令 r=null 后,两者都为1,队列为空,不能释放任何一个;但按根可达性,二者全不可达。

标记清扫会在全根跟踪中回收这个环;普通 RC 不会。自环 X.next=X 也会在最后一个根离开后保持 rc(X)=1。若 X 还指向一个尾链 X→Z→W,Z、W 虽不在环上,也可能被环的出边拖住,所以“残留只包括环上的节点”同样不准确。

本算法完整回收无根的有限无环组件:有限 DAG 必有一个入度零的顶点,删掉它及出边后剩下仍是 DAG,归纳直到清空。若对象还受另一个未释放组件的边持有,这个条件不成立。循环检测、trial deletion、混合 tracing 都是新增协议,不能只把 rc>0 改判为“也许垃圾”就直接释放。

推论与应用

局部安全性与失败状态 ​

replace 在合法边界删除一条旧入边、增加一条新入边,相应计数做相同变动;drain 删除一个零入度对象及其全部出边,同样维护等式。因此一个被释放对象在释放前没有任何强入槽,合法程序也没有其他来源可再次取得它。这个安全证明依赖全部有效引用已登记;漏掉寄存器临时量,会使真实仍可用的对象计数降零。

必须在修改前拒绝不存在的槽、悬空 new、负计数和计数上界溢出。若定宽计数会达到最大值,可以报告资源失败或使用已证明的饱和/不再回收策略;不能回绕到零。checker 使用显式可选 max_count,在赋值前检查增量,失败不改变图。checker 的 replace 是受信任的堆测试接口:它检查槽、已分配身份及正计数,却不持有源语言的不可伪造能力,不能仅据 rc>0 拒绝重新接入一个无根环。测试调用者必须保证 new 来自当前合法持有的引用;它不是供不可信 mutator 直接提交任意 ID 的生产 API。数学模型假定工作队列可容纳至多 |H| 项;物理内存不足则进入不可继续的运行时失败,未完成队列不构成“已经全部回收”的证据。

这里没有隐藏的原子多线程保证。并发 retain/release 不仅需要原子加减,还要保证取得新引用时对象尚未被别的线程释放;本页不能替代 hazard pointer、锁或其他生命期协议。

工作、暂停与日志 ​

不触发释放的替换只做常数次槽访问和计数更新。一次耗尽队列可能释放 v 个对象、遍历 e 个强字段,工作 O(v+e),队列最坏 O(v);最后一次根清空的延迟因此可能与整条大链成正比。分批 drain(k) 只按对象数限额时,一个巨大对象的字段扫描仍可很长;要给按槽暂停上界,状态还需保存对象内部扫描游标。

检查器为教学另有 validate(),重新数全图入度核对 rc,时间 O(|R|+|H|+|Fields|)。它用于测试边界,不是每次 replace 的核心费用。每个事件只记录被改槽或计数,不复制整个堆;若保存 e 个事件,日志另占 O(e),关闭日志后不保留历史。

迁移任务:P.a、P.b 都指 C,删除 P.a 后 C 的计数只减1;再把 P.b 改指 D,要先使 D 加1、再使 C 减1。若没有根 t,C 此时变零,但只有 drain 才物理删除。把 X、Y 的环用合法持有的 X 引用打断 Y.next=null 后,随后释放最后根会级联清空;已经完全失去根后,普通 mutator 无法凭显示出来的 ID 再去“手动断环”。

运行时维护终点分别运行本 RC 模型与分代 tracing 模型;两者的根、计数和回收状态不混用;分代算法的记忆集只登记老→幼槽,不记录本页的精确多重入度。

参考资料

[1] Andrew Myers,Cornell CS 4120/5120,Memory Management and Garbage Collection,讲义页首2018-05-03,§3、页3,含引用计数、级联释放、循环限制,以及脚注1的先减后增别名危险。本页采用更强的边界精确计数模型,不把文献中的延迟计数优化当作已实现。

[2] David F. Bacon、Perry Cheng、V. T. Rajan,A Unified Theory of Garbage Collection,OOPSLA 2004,作者论文。这里只列延伸阅读;本页独立算法与成本核验不依赖该链接的可访问性。

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

拖动节点调整位置。

显示关系

显示:依赖

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