Skip to main content

Chapter 33: Garbage Collection

Python 程序里的对象通常在最后一个强引用消失时释放;这是 CPython 引用计数带来的直接效果。循环引用改变了这个判断:一组对象可以彼此持有强引用,同时已经失去外部入口。此时每个对象的引用计数仍大于零,单靠引用计数无法触发释放。

本章讨论的 Garbage Collection 指 CPython 用来处理循环引用的循环垃圾回收器。读完本章后,读者应能追踪一个容器对象从“可达”到“候选扫描”、再到“不可达集合清理”的路径,并能判断内存问题更像强引用保留、循环引用、finalizer 风险、weakref 使用错误,还是 GC 周期成本。

贯穿本章的现象很短:两个对象互相引用,外部名字被删除,程序仍需要等待一次 collection cycle 才能释放这组对象。这个例子会反复用于解释分代策略、对象跟踪、不可达判断、finalizer、weakref 和诊断顺序。

import gc
import weakref

class Node:
def __init__(self, name):
self.name = name
self.peer = None

left = Node("left")
right = Node("right")
left.peer = right
right.peer = left

watch_left = weakref.ref(left)

# 删除外部名字后,两个 Node 仍然通过 peer 彼此持有强引用。
del left, right

alive_before_collection = watch_left() is not None
collected = gc.collect()
alive_after_collection = watch_left() is not None

这段代码里,del left, right 删除的是两个名字到对象的绑定,left.peerright.peer 形成的强引用仍留在对象图里。weakref.ref 只负责观察对象生命周期,默认不延长对象存活时间。因此 alive_before_collection 可能看到对象仍然存在,gc.collect() 之后对象组被识别为不可达,alive_after_collection 才会变成 False

33.1 Cyclic GC and generational GC

CPython 的内存回收首先依赖引用计数。对象的强引用数量降到零时,解释器可以立即进入析构和释放路径。这个路径有清晰的局部性:当前对象释放时,它持有的其它对象引用会被减少;这些对象的引用计数也可能继续降到零,形成级联释放。

循环 GC 接管的是引用计数覆盖不到的部分。两个 Node 互相保存到 peer 字段时,每个对象都至少被另一个对象引用。外部名字被删除后,引用计数仍然保留在环内。环内引用无法证明这组对象仍有业务意义,因为程序已经没有从 globals、locals、frame、模块对象、容器或 C 扩展状态进入它们的强引用入口。

官方 gc 模块文档把这个机制描述为对引用计数的补充:自动 GC 可以关闭,但关闭后程序需要确认自己不会产生引用环。这个说法给出一个工程判断:普通对象寿命由引用计数主导,循环 GC 是一条周期性扫描路径,用来处理“对象之间互相保留”的剩余集合。

分代 GC 解决的是扫描成本问题。大部分临时容器很快失去引用,少部分长期对象会在多次扫描后继续存活。CPython 因此把被 GC 跟踪的对象放入 generation,并根据对象存活次数和阈值决定扫描范围。新对象先进入年轻代,存活后向更老的 generation 移动。年轻代更频繁扫描,老年代更少扫描,这让短生命周期循环较快被发现,同时把长期对象的重复遍历次数降下来。

这个策略可以用贯穿例子解释。leftright 创建后属于新近分配的可跟踪容器对象。它们互相引用后形成环。外部名字删除后,环变成孤立对象组。下一次扫描如果覆盖到它们所在的 generation,GC 才能确认这组对象没有外部入口。若它们已经晋升到更老的 generation,发现时间会受老年代扫描频率影响。

这张图只表达默认 CPython GC 的工作层级:对象创建进入年轻代,扫描后仍可达的对象进入更老 generation,不可达对象进入清理路径。它省略了引用计数立即释放路径,因为引用计数发生在对象最后一个强引用消失时;循环 GC 的对象候选集来自“仍有环内引用,引用计数尚未归零”的可跟踪容器。

版本边界会影响具体 generation 行为。Python 3.14 系列曾调整过 generation 1 与 threshold2 的语义,Python 3.14.5 文档又记录了 generation 1 与 threshold2 的恢复说明。写调优代码时,应以目标运行版本的 gc.get_threshold()gc.set_threshold() 文档为准。正文中的稳定模型是“引用计数优先,循环 GC 周期性扫描可跟踪容器,分代策略控制扫描频率”。

33.2 Object tracking, GC root, and unreachable objects

循环 GC 只扫描支持 GC 跟踪的对象。原子对象,例如小整数和很多字符串,自身不会保存其它 Python 对象引用,进入循环引用的价值很低。容器对象、用户自定义实例、函数对象、模块字典、traceback 和许多扩展类型可能保存其它对象引用,才需要被纳入跟踪集合。

gc.is_tracked(obj) 可以观察一个对象当前是否被 GC 跟踪。这个结果受类型和实现优化影响:空 dict 或只含简单原子键值的 dict 可能暂时未被跟踪,后续写入容器值后又可能进入跟踪。稳定判断顺序是先看对象能否持有其它对象引用,再看当前实现是否为了降低 GC 开销临时取消跟踪。

import gc

samples = [0, "name", [], {}, {"items": []}]
for obj in samples:
print(type(obj).__name__, gc.is_tracked(obj))

这段代码用于观察 tracked 的 runtime 状态;跨版本常量表应以目标版本文档和实际运行结果为准。list 和自定义实例通常需要跟踪,因为它们可以保存对象引用。某些 dict 结果会随内容变化,这是 CPython 对 GC 开销的优化。诊断时看到某个对象未被跟踪,应先确认它是否真的能参与引用环,再判断是否存在类型级优化。

GC root 在本章中表示候选集合外部仍能进入对象的强引用入口。它可以来自当前 frame 的 locals、模块 globals、线程栈、C 扩展保存的引用、长期容器或其它未纳入当前扫描候选集的对象。CPython 的循环检测并不公开一个“根对象列表”让程序遍历;它通过候选集合内外的引用关系计算哪些对象仍可达。

CPython 内部 GC 设计文档把检测过程描述为一种图上计算:先选择一批候选容器对象,把每个候选对象的临时 GC 引用计数初始化为真实引用计数;再通过 tp_traverse 遍历候选对象内部引用,把候选集内部的边从临时计数中扣掉。扣完后仍有正值的对象,说明它们还有来自候选集外部的强引用入口。

用贯穿例子看,leftrightdel left, right 后只剩彼此之间的边。GC 扫描候选集时,left 的临时计数会被 right.peer 这条内部边扣掉,right 的临时计数会被 left.peer 扣掉。两个对象都没有来自候选集外部的入口,于是进入 tentatively unreachable 列表。随后 GC 从仍可达对象继续传播可达性,最终留在不可达列表里的对象组才会被清理。

这条路径解释了 gc.get_referrers() 的边界。它只能找到支持 GC 的容器引用者,且可能返回处在构造或清理过程中的对象。官方文档也把它定位为调试工具。使用它时,应先执行一次 gc.collect() 缩小历史不可达对象的干扰,再把结果当作引用图线索,继续检查哪一个容器或 frame 保存了强引用。

不可达对象的结论依赖扫描范围。年轻代扫描只检查当前 generation 的候选集合,老年代对象可能暂时留在候选集外。自由线程构建还会结合进程内存增长和分配差值控制 collection 触发条件。工程诊断中,gc.collect() 能强制推进一次全量路径,但业务程序的自动 collection 时间点仍由阈值、generation 状态和运行时构建共同决定。

33.3 Finalizer, weakref, and resurrection

finalizer 是对象进入生命末期时运行的清理回调。在 Python 代码层,常见形式是 __del__weakref.finalize;在 C API 层,对应 tp_finalize 等槽位。finalizer 的特殊之处在于它能执行 Python 代码,因此它可能访问全局变量、获取锁、触发新分配、抛出异常,甚至把正在清理的对象重新保存到外部位置。

对象复活(resurrection)指 finalizer 在对象即将销毁时重新建立外部强引用。官方 Object Life Cycle 文档说明,tp_finalize 允许增加对象引用;一旦成功,对象销毁会被推迟。这个行为让 GC 清理过程多出一条边界:不可达对象组在 finalizer 运行后可能重新变成可达对象组。

import gc

rescued = None

class Lazarus:
def __del__(self):
global rescued
rescued = self

obj = Lazarus()
watch = id(obj)
del obj
gc.collect()

print(rescued is not None, id(rescued) == watch)

这段代码说明 __del__ 可以把对象保存到 rescued。在 CPython 中,普通对象引用计数归零时也可能立即运行 finalizer;循环引用中的 finalizer 则由循环 GC 在处理 cyclic isolate 时运行。两种入口的共同点是:finalizer 运行期间对象还处在可执行方法和访问属性的状态,finalizer 代码有能力改变后续生命周期。

PEP 442 之后,带 __del__ 的循环对象在现代 CPython 中通常也能被安全处理。这个改动降低了“只要有 __del__ 就必然进入 gc.garbage”这类旧经验的适用性。更稳定的判断是检查 finalizer 是否重新建立强引用、是否依赖已经变化的全局状态、是否让清理顺序对业务正确性产生要求。

weakref 的作用边界更清楚:弱引用观察对象,但不增加对象强引用数量。官方 weakref 文档说明,当对象只剩弱引用时,垃圾回收可以销毁对象,并让弱引用返回 None。因此 weakref 适合缓存、反向索引、旁路元数据和生命周期通知;资源关闭仍应由 context manager、显式 close() 或业务所有者负责,目标对象在下一行代码中的存活状态也需要重新取值确认。

import gc
import weakref

class Payload:
pass

obj = Payload()
seen = weakref.ref(obj)
box = weakref.WeakValueDictionary()
box["payload"] = obj

del obj
gc.collect()

print(seen() is None)
print(list(box.keys()))

这段代码里,WeakValueDictionary 保存的是弱引用。对象失去强引用后,GC 可以回收对象,弱值字典里的条目会随之清理。若把同样对象放进普通 dict,dict 的 value 会成为强引用,对象生命周期会被 dict 延长。排查缓存导致的内存增长时,第一步就是区分“缓存是否拥有对象”与“缓存是否只观察对象”。

weakref.finalize 比裸 __del__ 更适合表达外部资源清理。它把清理函数注册到对象生命周期上,并保证 finalizer 对象存活到目标对象被回收。使用时要检查一个条件:清理函数、参数和闭包应只保存清理所需的外部资源信息,目标对象自身应留在 finalizer 的拥有关系之外。若 finalizer 的参数保存了目标对象,finalizer 自身会把对象维持为可达,清理永远等不到触发点。

33.4 Collection cycle and runtime cost

collection cycle 的输入是当前被选择的候选 generation,输出是被确认的可达对象、不可达对象、uncollectable 对象,以及若干统计信息。它的成本主要来自四个来源:遍历候选容器、执行 tp_traversetp_clear、运行 finalizer 或 weakref callback、维护 generation 列表和统计状态。

贯穿例子中的两个 Node 很小,collection 成本几乎看不出来。真实程序中,候选集合可能包含大量 list、dict、frame、traceback、闭包、实例和扩展对象。每个可跟踪容器都需要通过类型提供的遍历逻辑报告自己引用了哪些对象。候选集合越大,单次扫描的暂停越明显;对象图越复杂,追踪可达性需要处理的边越多。

import gc
import time

start = time.perf_counter()
collected = gc.collect()
elapsed = time.perf_counter() - start

print({"collected": collected, "seconds": elapsed})
print(gc.get_count())
print(gc.get_stats())

这段代码回答三个不同问题:gc.collect() 返回本次发现的 collectable 与 uncollectable 数量之和,gc.get_count() 显示当前 generation 计数,gc.get_stats() 汇总解释器启动以来每个 generation 的 collection 次数和对象数量。它们提供的是 GC 侧统计,进程 RSS、Python 对象总数和 allocator 是否把内存还给操作系统需要分层解释。

暂停时间通常来自扫描和回调,内存峰值通常来自对象图仍被强引用、不可达循环尚未进入扫描、finalizer 复活对象、debug 模式保存不可达对象,或者 allocator 保留已释放内存页。调优时应先确认是哪一种成本。直接降低阈值会增加扫描频率;直接提高阈值会延后循环释放。两种操作都只改变触发节奏,错误引用图仍要回到对象所有权和强引用入口上修复。

gc.callbacks 可以记录 collection 的 start 与 stop 事件。回调的 info 会包含 generation、collected 和 uncollectable 等字段。这个入口适合做低侵入统计,例如记录每次 GC 花费的时间和对应 generation。回调函数应保持短小,因为它运行在 GC 周期边界,额外分配和复杂逻辑会把观测成本混进被观测对象。

import gc
import time

marks = {}

def record_gc(phase, info):
generation = info["generation"]
if phase == "start":
marks[generation] = time.perf_counter()
return
started = marks.pop(generation, None)
if started is None:
return
duration = time.perf_counter() - started
print({
"generation": generation,
"collected": info["collected"],
"uncollectable": info["uncollectable"],
"seconds": duration,
})

gc.callbacks.append(record_gc)

这段代码的价值在于把“GC 导致卡顿”转成可观察事件:哪个 generation 被扫描、扫描耗时多少、回收了多少对象、是否存在 uncollectable。对象为什么存在仍需要另外追踪引用图。若统计显示频繁 full collection 但 collected 数量很低,应转向检查分配模式、阈值设置和长期容器;若 collected 数量高且 pause 明显,应检查是否存在大量短命循环或 traceback/frame 保留。

完整 collection 还可能清理若干内置类型的 free list。官方 gc.collect() 文档说明,full collection 或最高 generation collection 会清理多种内置类型维护的 free list,但某些对象未必全部释放。这也是内存诊断里常见的误差来源:对象生命周期结束、free list 清理、pymalloc arena 释放、操作系统 RSS 下降是不同层级的事件。下一章会展开 allocator 层,本章只把它作为 GC 统计边界处理。

33.5 GC diagnosis checklist

GC 诊断的第一步是固定现象:对象数量增长、RSS 增长、延迟尖峰、gc.garbage 非空、weakref 未失效,还是 finalizer 行为异常。不同现象对应不同入口。对象数量增长先查强引用链;RSS 增长还要考虑 allocator;延迟尖峰先看 collection 时间;gc.garbage 非空先查 uncollectable 类型;weakref 未失效先查是否仍有强引用。

第二步是区分强引用保留和循环引用。强引用保留通常有一个外部容器、全局缓存、frame、任务队列或闭包仍能进入对象。循环引用则表现为外部名字删除后对象组仍互相持有。gc.collect() 后如果 weakref 仍能返回对象,优先找强引用入口;gc.collect() 后 weakref 变为 None,说明对象此前只是等待循环 GC。

第三步是检查 tracking 状态。参与循环的对象必须能被 GC 看见:纯原子对象不会形成需要 GC 处理的环;部分容器会因内容简单而临时未跟踪;C 扩展类型若能保存 Python 对象引用,应正确声明 GC 支持并实现遍历逻辑。对 Python 代码而言,gc.is_tracked() 和对象类型能帮助判断 GC 是否有机会扫描到目标对象。

第四步是收窄引用图。可按以下顺序使用工具:先执行一次 gc.collect() 清掉历史不可达循环,再用 weakref 判断目标是否仍活着,再用 gc.get_referrers(target) 找直接引用者,再沿着引用者向外追踪到业务所有者。get_referrers 的结果只作为线索,因为 frame、临时对象和调试器本身也会改变引用图。

第五步是审查 finalizer 和 weakref。__del__、weakref callback、weakref.finalize 注册的函数都可能在生命周期边界运行。检查重点包括:finalizer 是否把对象保存到全局或缓存,weakref finalizer 的参数是否持有目标对象,callback 是否分配大量对象,异常是否只打印到 stderr 后被吞掉,资源关闭是否依赖不可控的 GC 时间点。

第六步是解释统计。gc.get_stats()collections 增长表示 collection 发生过;collected 增长表示有对象被回收;uncollectable 增长表示存在无法释放的不可达对象。阈值调优应放在最后,因为阈值只改变“何时扫描”,引用图决定“能否释放”。若引用图仍有外部强引用,任何阈值都不会让对象消失。

下面这段最小排查代码把几个观察点连在一起。它适合定位“对象在删除名字之后为什么还活着”的问题。

import gc
import weakref

class Item:
pass

obj = Item()
observe = weakref.ref(obj)
holder = {"obj": obj}

del obj
gc.collect()
print("after first collect", observe() is not None)

holder.clear()
gc.collect()
print("after holder clear", observe() is not None)

第一次 gc.collect() 后对象仍活着,因为 holder 仍保存强引用。清空 holder 后,强引用入口消失,第二次 collection 后 weakref 返回 None。这个例子没有循环也能说明诊断顺序:先确认外部强引用,再谈循环 GC。许多内存问题看起来像 GC 失效,实际原因是业务容器仍拥有对象。

把这个顺序迁移到更大程序时,可以使用以下判断框架:先问目标对象有没有外部强引用入口;再问它是否属于 GC 可跟踪对象;再问它是否和其它对象形成孤立环;再问 finalizer 是否改变生命周期;最后才问 collection 频率和 pause 成本。这个顺序能把“释放不及时”“释放不了”“释放时有副作用”“释放成本过高”拆成四类不同问题。

最小自检任务

阅读下面代码,判断三个问题:alive_before 为什么可能为 Truealive_after 为什么应为 Falsecache 为什么没有阻止对象回收。

import gc
import weakref

class Node:
def __init__(self, name):
self.name = name
self.peer = None

left = Node("left")
right = Node("right")
left.peer = right
right.peer = left

watch = weakref.ref(left)
cache = weakref.WeakValueDictionary()
cache["left"] = left

del left, right
alive_before = watch() is not None
gc.collect()
alive_after = watch() is not None
cache_size = len(cache)

答案要点

alive_before 可能为 True,因为 leftright 删除的是外部名字绑定,两个对象之间的 peer 字段仍然形成强引用环。引用计数无法让环内对象直接归零,所以在自动或手动 collection 发生前,weakref 仍可能取回对象。

alive_after 应为 False,因为 gc.collect() 扫描到这组可跟踪容器对象后,会通过对象图判断它们已经没有候选集合外部的强引用入口。两个对象构成孤立环,进入不可达处理路径,随后被释放。

cache 没有阻止对象回收,因为 WeakValueDictionary 保存的是弱引用。弱引用不会增加目标对象的强引用数量;目标对象被回收后,对应条目会从弱值字典中清理。若这里换成普通 dict,dict 的 value 会成为强引用,watch() 在 collection 后仍可能返回对象。

这个任务的检查顺序是:先看外部强引用入口,再看对象之间是否形成环,再看 weakref 是否拥有对象,最后用 collection 结果确认对象是否只是等待循环 GC。

本章知识点总结

  • 引用计数:CPython 首先用引用计数处理普通对象释放,强引用数量归零时可以立即进入析构路径。
  • 循环引用:对象组彼此保存强引用时,外部名字删除后引用计数仍可能大于零,需要循环 GC 扫描。
  • 分代策略:generation 根据对象存活次数调整扫描频率,用更频繁的年轻代扫描处理短命对象。
  • 版本边界:generation 与 threshold 的细节受 Python 版本影响,调优代码应对齐目标版本文档。
  • 跟踪状态:GC 只扫描可跟踪对象,容器和用户实例通常可跟踪,原子对象通常无需跟踪。
  • 外部入口:GC root 可以理解为候选集合外部进入对象的强引用入口,包括 frame、globals、容器和 C 扩展状态。
  • 不可达判断:CPython 通过临时引用计数扣除候选集内部边,再传播可达性,剩余对象进入不可达集合。
  • finalizer__del__weakref.finalize 和 C 层 tp_finalize 都会在生命周期边界运行,并可能改变对象后续状态。
  • 对象复活:finalizer 可以重新建立外部强引用,使即将清理的对象重新变成可达对象。
  • 弱引用:weakref 观察对象生命周期但不延长对象存活,适合缓存和旁路索引。
  • 周期成本:collection 成本来自候选容器遍历、引用图计算、清理槽位、finalizer、weakref callback 和 generation 维护。
  • 统计边界gc.get_stats()gc.get_count()gc.callbacks 解释 GC 事件,进程 RSS 与 allocator 状态需要分层判断。
  • 诊断顺序:先查强引用入口,再查 tracking 状态,再查孤立环,再查 finalizer 与 weakref,最后处理阈值和暂停成本。