垃圾回收

本页目录

垃圾回收自动管理部分对象的内存生命周期。这里的“垃圾”通常按语言与运行时可观察的引用关系判断,不能理解为收集器知道程序未来再也不会用到什么。一个被全局缓存长期引用的无用对象仍可能被保留。

理解 GC 时,要把几个维度分开:怎样识别可回收对象、是否移动对象、是否分代、工作是否并行或并发,以及编译器如何报告引用。这些可以组合,并不是互斥的几种产品。

根、对象图与保守近似

追踪式 GC 从根引用出发遍历对象图。根可能位于线程栈、寄存器、全局槽或运行时句柄中,但不是这些区域的每个 word 都必然是指针。

可达性:没有根入口的环也可以是垃圾 根引用 对象 A 对象 B 对象 X 对象 Y X 与 Y 互相引用,但从根不可达;单纯引用计数不会仅凭互相引用消失而回收它们。

精确 GC 依靠类型布局与栈映射辨认引用槽;保守 GC 可能把看起来像地址的数值也当作潜在引用,因此会误保留一些对象,并限制安全移动的方式。精确与保守描述引用识别能力,不直接决定是否分代或并发。

引用计数则在引用变化时维护计数,计数归零时回收。它不必每次从根遍历全图,但互相引用的不可达环不会自然归零,通常需要其他机制处理循环。不能把“所有 GC 都执行同一种根遍历”当作定义。

Mark-Sweep:找出存活对象,再回收未标记空间

标记阶段遍历根可达的对象;清扫阶段根据标记信息回收未标记对象的空间。实现可以按页、span 或分配元数据处理,不一定逐字节扫描整个预留地址空间。

非移动清扫不改变对象地址,便于某些外部引用与固定地址需求,但会留下大小不一的空洞。分配器的尺寸分级与空闲管理可以缓解碎片;“空闲总量足够”仍不保证能满足所有连续大对象分配。

标记成本不仅由活对象字节量决定,还取决于指针数量、对象布局与局部性。一个大字节数组和等大小的密集指针图,追踪工作可能差别很大。

复制与压缩:回收时改变对象地址

经典双半区收集器把分配区分成 from-space 与 to-space,将存活对象复制到目标区,修正引用,再交换两区角色。Cheney 算法用目标区的 scan 与 free 两个位置组织广度优先扫描:新复制对象追加到 free,scan 依次处理其引用,直到两者相遇。

forwarding 信息确保多条引用指向同一对象时只复制一次,并统一找到新地址。回收原区域表示其可重新分配,不意味着必须立刻归还操作系统。

双半区的简单形式预留一半区域作为复制目标,但不能把这个比例推广为所有移动 GC 的总体利用率。区域式、分代和压缩算法有不同空间策略。移动还要求处理内部指针、FFI 句柄与固定对象,不能只更新根就结束。

让 scan 与 free 真正走完一个对象图

用整数代表旧对象地址:10 是 A,指向 B 和 C;20 是 B,也指向 C;30 是 C,没有子引用。另有 40 与 50 构成不可达环。根只有 A。把对象大小统一为一个槽,只保留指针字段,便能把复制算法缩成下面的完整模型。

heap = {10: [20, 30], 20: [30], 30: [], 40: [50], 50: [40]}


def collect(heap, roots):
    forwarding, copied, queue = {}, [], []

    def evacuate(old):
        if old not in forwarding:
            forwarding[old] = len(copied)
            copied.append(None)  # 先占槽并建立转发,再扫描字段
            queue.append(old)
        return forwarding[old]

    new_roots = [evacuate(old) for old in roots]
    scan, trace = 0, []
    while scan < len(copied):  # len(copied) 即本模型的 free
        old = queue[scan]
        copied[scan] = [evacuate(child) for child in heap[old]]
        scan += 1
        trace.append((scan, len(copied)))
    return new_roots, copied, forwarding, trace


roots, copied, moved, trace = collect(heap, [10, 10])
assert roots == [0, 0]  # 两个根保持别名关系
assert copied == [[1, 2], [2], []]
assert moved == {10: 0, 20: 1, 30: 2}
assert trace == [(1, 3), (2, 3), (3, 3)]
assert collect(heap, [40])[1] == [[1], [0]]  # 可达环也只复制一次
assert collect(heap, [])[1] == []
完成的动作scan / free为什么这样变化
复制根 A0 / 1已有一个副本,但其字段尚未扫描
扫描 A1 / 3首次发现 B、C,追加两个副本
扫描 B2 / 3C 已有 forwarding,复用槽 2
扫描 C3 / 3无新引用,待扫描区为空

这个顺序揭示 forwarding 的两个职责:防止重复复制,并保持对象身份。若 A 与 B 原先共享 C,回收后它们仍须共享同一个 C,不能各复制一份后悄悄改变修改的可见性。先登记转发再扫描字段,还让可达环能够终止。

模型里的新地址是数组下标,真实收集器则按对象实际大小和对齐推进字节指针,还要处理复制目标容量不足。这里假设收集期间对象图不变,没有实现并发屏障、弱引用、终结器或内部指针。

复制区同时承担存储空间和待扫描队列 A → [B, C] 已扫描 B → [C] 待扫描 C → [] 待扫描 未分配 复制目标余量 scan = 1 free = 3 这是扫描 A 之后的状态;B 再次引用 C 时,free 不增加。

两条引用找到同一个对象,为什么只复制一次?

scan 与 free 分开,是因为“已经复制”不等于“字段已经更新”。下面目标区的待扫描对象仍可能携带旧址,只有处理到它时才修正字段。A 和 B 对 C 的引用最终必须落到同一个新槽;把 D 加入根后,D/E 的循环也靠先登记 forwarding 避免重复复制。这里只把对象大小统一成一个槽,不宣称真实对象等长。

正在呈现知识画面
两条引用找到同一个对象,为什么只复制一次?

复制前先登记 forwarding,后续引用复用同一个新地址。scan 左侧已扫描,scan 与 free 之间待扫描;两者相遇时结束,共享与环都不会重复复制。

分代:少扫描长期存活对象

弱分代假设是许多程序中的大量对象很快死亡。若该假设适合工作负载,可以频繁收集小的年轻代,减少重复扫描长期存活对象。晋升阈值、代数和各代算法由实现决定,年轻代不必遵循统一的大小或次数。

只收集年轻代时,老年代的引用也可能是入口 根 老对象 A 年轻对象 B 记录 A 所在卡片或引用槽,年轻代收集时扫描 Remembered set 避免每次都扫描全部老对象;它不等于所有 GC 写屏障的目的。

卡表通常以一小段内存区域为粒度记录潜在变化,remembered set 可以采用更精细的对象或引用槽表示。它们让年轻代收集知道哪些老对象可能提供入口,而不必扫描全部老年代。

如果程序长寿命对象多、跨代写入频繁或年轻对象迅速晋升,分代的额外记录与复制成本可能减弱收益。分代是基于负载的优化,不是所有 GC 的必备阶段。

并行、并发和增量不是同义词

并行表示多个收集线程共同工作,应用可以仍然暂停;并发表示收集器与应用 mutator 同时执行;增量表示把工作拆成较小步骤。这些维度可以同时出现。

Mark-Sweep 或复制既可以用于暂停式方案,也可以在配套协议下并发执行。并发仍可能需要短暂停顿建立根视图、转换阶段或完成收尾,不能直接称作应用永不停顿。

屏障:维护具体算法要求的信息

三色抽象把对象分为白色(未发现)、灰色(已发现待扫描)和黑色(已扫描)。在简单的强三色不变量下,不允许黑对象指向未被保护的白对象;并发修改会改变图,因此需要屏障和工作队列维护正确性。

例如收集器已经扫描 A,应用随后把 B 的引用写入 A,而 B 原先的最后一条待扫描路径又被移除。如果没有对应保护,B 可能仍被应用引用却没有被标记。

插入屏障可以记录新写入的引用;删除屏障或 SATB 风格屏障可以保留被覆盖的旧引用;混合屏障组合不同动作。不同算法维护的不变量不同,不能把它们全缩写为一个 if black then gray。

分代屏障主要记录跨代引用,并发标记屏障保护追踪正确性,移动收集器的读屏障还可能处理 forwarding。选择读或写屏障首先取决于算法需要,读写频率影响成本,但不能单独决定正确方案。

Go 的官方 GC 指南描述其并发、非移动的追踪收集器;运行时写屏障源码说明混合屏障的目的。它不能作为“年轻代复制、老年代清扫”的通用分代例子。Go GC 指南、Go 写屏障实现说明

黑对象新增引用,谁负责发现那个白对象?

这不是让所有屏障共用一条规则,而是固定一种增量更新模型来观察不变量。A 已经扫描后再增加 A→B,若 B 仍白,就需要留下新的追踪任务。先建立这条边、再删掉 G→B,可以让原有灰路径消失;开启插入屏障时,B 自己已在灰集合中,所以信息不会随旧路径一起丢掉。

正在呈现知识画面
黑对象新增引用,谁负责发现那个白对象?

插入屏障在黑 A 获得白 B 时把 B 标灰。即使原来的 G→B 随后消失,B 仍会被扫描;没有屏障则可能留下根可达但未标记的对象。

编译器必须提供什么

支持为什么需要
引用布局与栈映射区分引用和普通数据,找到存活引用
安全点或相应暂停协议在可解释的执行状态下扫描或更新引用
屏障记录修改并维护收集算法不变量
移动后的引用更新规则防止继续使用旧地址
与 FFI、异常展开及优化协作避免引用被隐藏或恢复成过期状态

安全点不必只存在于函数调用或循环头,实现还可以支持受限制的异步暂停机制。编译器消除某个屏障也必须证明符合具体算法;“对象刚分配”不是所有屏障都可以省略的统一理由。LLVM 将这些协作需求作为 GC 集成接口的一部分。LLVM GC 文档

成本与生命周期速查

增大堆预算常能减少收集频率,却增加驻留内存;降低暂停可能把更多工作摊到 mutator 屏障和并发 CPU 上;移动改善紧凑性,却增加复制与引用修正。评估应同时观察分配率、存活集、GC CPU、暂停分布和峰值内存。

GC 也不替代文件、锁或连接的及时释放。Finalizer 的运行时机通常不适合承担必须准时完成的业务清理,应使用语言提供的显式生命周期机制。

用一个受限预算模型分开“频率”和“一次工作量”

假设每轮回收后的存活集固定为 100 MiB,下一轮在堆占用达到 200 MiB 时触发,应用持续以 50 MiB/s 分配。可分配余量 100 MiB,理想化轮间隔就是 2 秒。若阈值改成 300 MiB,余量为 200 MiB,间隔变成 4 秒;这并没有直接证明一次暂停缩短。

from fractions import Fraction
live, allocation_rate = 100, 50  # MiB,MiB/s
intervals = [Fraction(limit - live, allocation_rate) for limit in (200, 300)]
assert intervals == [2, 4]
# 再假设每轮追踪同一存活图固定消耗 0.1 CPU 秒
cpu_per_second = [Fraction(1, 10) / interval for interval in intervals]
assert cpu_per_second == [Fraction(1, 20), Fraction(1, 40)]

上述摊销工作量从每秒 0.05 CPU 秒降到 0.025 CPU 秒,是“固定每轮成本、只改变频率”的模型结论。真实运行时会提前启动并发标记,根与存活集会变化,也可能发生分配辅助、晋升或内存限制压力,因此不能直接把这两个数当作产品性能预测。

如果暂停仍很长,应进一步辨认根扫描、复制存活对象还是收尾阶段占主导;如果总 CPU 高而暂停短,则可能是频繁收集或大量并发工作。把每轮耗时、每秒轮数和应用吞吐放在一起,才能判断增加堆预算解决的是哪一种成本。Go GC 成本模型

参考

Keywords: GC, tracing, reference counting, root map, mark-sweep, copying, generational, remembered set, barrier, safepoint