周末你办了一场热闹派对,散场后保洁阿姨(垃圾回收器)开始清场:她会清走那些“没人认识、也没被任何人拉住”的客人。可你发现,好几拨明明该走的人还在沙发上赖着——不是他们不想走,而是有人一直攥着他们的手。谁是那个“拉住不放”的人?你埋头翻了三天代码也没找到。这时你请来一位侦探:侦探的工作不是替你赶人,而是把在场的每一个人和“谁认识谁”的关系,画成一张巨大的关系图。谁的手被攥着、攥住他的人又属于哪个圈子,一眼便知。objgraph 就是 Python 世界里的这位侦探——它专门绘制对象间的引用关系图,帮你定位那些“该回收却没被回收”的对象。本文会用“派对清场”的视角,带你从安装、画图、统计类型,一路走到排查真实内存泄漏案例。

岗位关联

学会 objgraph,你就能把“内存泄漏排查”从玄学变成有图有真相的科学,快速定位该释放却赖着不走的对象——这是后端、数据、性能优化工程师的实用硬技能。

目录
  1. 可视化演示
  2. 认识objgraph
  3. 基本用法
  4. 引用关系图
  5. 类型统计图
  6. 全局缓存泄漏
  7. 循环引用排查
  8. 实战案例
  9. AI 应用场景
  10. Python 演示
  11. 常见误区
  12. 面试题

可视化演示

展示Python对象引用关系图、类型统计与增长分析、以及循环引用检测与gc回收的完整流程。

本节配有交互动效(进入视口后自动加载,约 3KB)。

对象引用关系图 show_backrefs显示反向引用,追溯谁引用了目标 show_refs显示正向引用,查看目标引用了哪些对象 backrefs→root 引用链追溯是排查泄漏的第一步

认识objgraph

你请来的侦探上门了。他打开随身工具箱:一把放大镜(查看对象)、一卷红线(表示引用)、一块关系板(画图)。箱子里还有一张说明书,第一页写着:安装工具。这个工具箱就是 objgraph,而“安装工具”就是一行 pip install objgraph。侦探工具再好,不装进门就只能摆在商店橱窗里,所以第一步永远是把它请进项目环境。

objgraph 是一个第三方可视化调试库,它不属于 Python 标准库,需要手动安装。它的核心能力是把“对象与对象之间的引用关系”翻译成一张看得见的图。在 Python 里,对象天然互相指向,但这种关系藏在内存里,平时不可见;objgraph 让它们显形。你可以把它想象成给内存拍 X 光片:看不到不等于没有,只是缺一台合适的仪器。

安装也很简单:终端执行 pip install objgraph;想要真正渲染出 PNG 图片,还需要系统安装 Graphviz 渲染引擎(例如 Ubuntu 上执行 apt install graphviz)。objgraph 适用场景很明确:程序内存持续上涨、某个类型的对象数量异常、缓存清理后内存不回落。它最常出现在三种人手里:排查线上内存泄漏的后端工程师、优化长时间运行任务的数据工程师、写单元测试防止泄漏回头的测试开发。

工具箱已经摆在桌上,但侦探不会立刻破案,他还得先翻翻手册:每个按钮怎么按。下一节,我们就翻到手册第二章“基本用法”。

M(t)=nres(t)×sˉobj\displaystyle M(t) = n_{\text{res}}(t) \times \bar{s}_{\text{obj}}
M(t) 表示 t 时刻进程占用的内存,等于“驻留(未被回收)的对象个数”乘以“每个对象的平均大小”。泄漏的本质是 n_res 随时间只增不减。
  1. 内存总量 M(t)t 时刻进程持有的总内存,是我们要盯住的最终指标。M(t)\displaystyle M(t)
  2. 驻留对象数 n_res(t)t 时刻仍存活且未被回收的对象数量,内存泄漏时它会单调上涨。nres(t)\displaystyle n_{\text{res}}(t)
  3. 平均对象大小 s̄_obj每个对象平均占用的字节数,不同类型差异很大,所以统计时要按类型分开看。sˉobj\displaystyle \bar{s}_{\text{obj}}

基本用法

侦探手册第二章,是入门三招:数人头、点名字、画红线。数人头用 count(),点名字用 by_type(),画红线用 show_refs() 和 show_backrefs()。三招学完,你就掌握了侦探的全部基本功:先看现场有多少人,再找出其中几个代表,最后把人物关系画上关系板。

这三类函数构成了 objgraph 的全部工作流:先统计“有多少”,再拿到“具体是谁”,最后画出“谁和谁相连”。排查内存泄漏时,顺序通常是:计数发现异常 → 取实例逐个检查 → 画引用图追根溯源。这套流程和侦探破案的节奏完全一致:先确认案发,再锁定嫌疑人,最后还原关系网。

具体来说:objgraph.count(“Task“) 返回当前进程中 Task 类型实例的存活数量;objgraph.by_type(”Task“) 返回这些实例的列表,可以逐个用 id() 检查是否同一个;objgraph.show_refs(roots, max_depth, filename) 以 roots 为起点向外画”引用了谁“;objgraph.show_backrefs(obj, max_depth, filename) 反过来画”谁引用了 obj”;还有 show_most_common_types(limit) 输出类型排行,show_growth() 输出两次采样间增长最多的类型。filename 参数可以直接把图存成 PNG 文件,方便保存取证。

三招学会,侦探可以正式展开关系板了。但先别急着抓人,我们得先分清:顺着红线看(show_refs)和逆着红线找(show_backrefs),哪个才是查泄漏的关键动作。

G=(V,E),E={(a,b)∣a 持有指向 b 的引用}\displaystyle G = (V, E), \quad E = \{(a, b) \mid a \text{ 持有指向 } b \text{ 的引用}\}
引用关系图是一个有向图:顶点 V 是程序中所有存活对象,边 E 是对象间的引用。objgraph 画出来的每一根线,都对应公式里的一条边 (a, b)。
  1. 顶点集合 V程序中所有存活对象,就像派对上的每个客人,是图上的一个个点。V={所有存活对象}\displaystyle V = \{ \text{所有存活对象} \}
  2. 边集合 E所有引用关系的集合,每条边代表“一个对象指向另一个对象”。E={(a,b)}\displaystyle E = \{(a, b)\}
  3. 有向条件 (a, b)a 持有指向 b 的引用,箭头从 a 画到 b,方向不能反。a→b\displaystyle a \to b

引用关系图

侦探关系板上,每一根红线都代表“一只手拉住另一只手”。show_refs 是“顺藤摸瓜”:从某个人出发,看他拉着谁;show_backrefs 是“倒查真凶”:从某个不该留下的人出发,看是谁在拉他。查泄漏,十次有九次要用倒查,因为病根永远在被引用者的上游。

为什么倒查更重要?因为“泄漏”的严格定义就是:一个本该无人引用的对象,身上却挂着一条不该存在的引用。顺着引用向外看,你看到的是对象正常使用的东西;逆着引用往回找,才能看到那个“多管闲事”的持有者。就像查“为什么客人没走”,问他“你要去哪”没用,得问“谁拉着你”。

使用上,show_refs(obj, max_depth=3) 会画出 obj 引用出去的对象树;show_backrefs(obj, max_depth=3) 画出所有直接或间接引用 obj 的对象。输出图里,矩形象的通常是模块或类,椭圆形象的是实例,箭头方向代表“谁指向谁”。排查泄漏的典型动作是:选一个泄漏嫌疑对象,调 show_backrefs,顺着引用链一层层往上,直到看到全局容器或常驻对象——那通常就是病根。

红线一路追到头,你会看到那只“不该出现的手”。但在一张动辄上百节点的图里,怎么快速锁定哪个类型的对象在暴增?这时需要先做一次“人口普查”。

backrefs(x)={o∈V∣(o,x)∈E}\displaystyle \text{backrefs}(x) = \{ o \in V \mid (o, x) \in E \}
backrefs(x) 是“反向引用集合”:所有直接引用 x 的对象。show_backrefs 画的正是这个集合,它是追查“谁在拉着 x 不让走”的数学表达。
  1. 反向引用 backrefs(x)所有直接引用对象 x 的其他对象,也就是“谁在拉 x”。backrefs(x)\displaystyle \text{backrefs}(x)
  2. 候选集合 o∈V从所有存活对象中逐个考察,谁可能是那个“拉手的人”。o∈V\displaystyle o \in V
  3. 边条件 (o, x)∈E只有在存在“o 指向 x”这条边时,o 才被计入反向引用集合。(o,x)∈E\displaystyle (o, x) \in E

类型统计图

侦探破案不画全城关系图,那是低效的。他先让助手做人口普查:今天现场有哪些人群,各有多少人,哪个人群比上周暴增了几倍。objgraph 的类型统计函数就是这台“普查机器”:它不看单个对象,只看类型总量,帮你快速圈定重点区域。

大脑记不住几万个对象,但能记住几个类型。objgraph.show_most_common_types(limit=5) 直接把当前存活数量前五的类型打印出来;如果某个业务类型莫名其妙排进前三,它十有八九是泄漏嫌疑人。这就像派对保洁发现“穿着红色马甲的人”比上周多了三倍——先盯住这个群体,再从里面找代表细查。

三个函数配合使用:count(类型名) 精确计数,适合写脚本断言;show_most_common_types(limit=N) 打印全局排行,适合快速扫描;show_growth() 对比“上次调用”与“本次调用”的增量,适合压测时观察哪个类型在持续增长。标准的内存画像流程是“三拍法”:跑稳定业务前拍一张基线 → 持续压测一段时间 → 再拍一张,对比两次排行,增量最大的类型就是主攻方向。

普查锁定了暴增的人群,侦探终于能圈定重点区域。下一节,我们来看最经典的“案发现场”:一本从不清理的嘉宾名册——全局容器缓存。

count(tj)=∑i=1N[type(oi)=tj],pj=count(tj)N\displaystyle \text{count}(t_j) = \sum_{i=1}^{N} [\text{type}(o_i) = t_j], \qquad p_j = \frac{\text{count}(t_j)}{N}
count(t_j) 统计类型为 t_j 的对象个数,p_j 是它在所有对象中的占比。占比异常升高,说明该类型正在积压——这是泄漏的早期信号。
  1. 计数 count(t_j)遍历全部 N 个对象,数出类型为 t_j 的有几个。count(tj)=∑i=1N[type(oi)=tj]\displaystyle \text{count}(t_j) = \sum_{i=1}^{N} [\text{type}(o_i) = t_j]
  2. 判别式 [type(o_i)=t_j]对每个对象 o_i 判断类型是否等于 t_j,成立记 1,否则记 0,最后求和。[type(oi)=tj]\displaystyle [\text{type}(o_i) = t_j]
  3. 占比 p_j该类对象数除以总对象数 N,占比持续升高说明该类型只增不减。pj=count(tj)N\displaystyle p_j = \frac{\text{count}(t_j)}{N}

全局缓存泄漏

派对门口有一本《嘉宾名册》,每位客人进门时,门卫都会把名字登记上去。按规则,离场时要划掉名字。但名册的代码是“只登记、不划掉”。于是很多早已离开的客人,名册上依然写着名字——名册本身成了拉住他们的那只手。这就是最常见的泄漏模式:全局容器缓存。

在 Python 里,“名册”就是模块级的列表和字典、类属性缓存、装饰器注册表、连接池等长生命周期容器。它们本身一直存活,而它们持有的元素从不释放,于是“元素应该被回收”彻底变成不可能。保洁阿姨再勤快也没用,因为名册牢牢握着每个名字,从根上就是可达的。

典型成因主要有三种:模块级 CACHE 字典不断新增 key 永不删除;类属性列表在每次请求里 append 对象;单例对象把业务实例存进自己的属性。定位方法分三步:先用 by_type(“嫌疑类型”) 拿到实例列表;再对其中一个实例调 show_backrefs;顺着引用链往最顶层找“全局级容器”。修复策略也有三种:从容器里删除不再需要的引用;改用 weakref.WeakValueDictionary 或 WeakSet,让容器不增加引用计数;或者给缓存加 TTL 和容量上限,超过就淘汰。

把名册上多余的名字划掉,客人才能真正离开。但如果名册上根本没名字,却有人走不了呢?那就是更隐蔽的循环引用:两个客人互相搀扶,谁都不认识他们。

Ncache(t)=Ncache(t−1)+λ\displaystyle N_{\text{cache}}(t) = N_{\text{cache}}(t-1) + \lambda
缓存大小的递推式:处理完第 t 个请求后,缓存大小等于上一次的大小加上本次新增的条目数 λ。只要 λ > 0 且从不清理,缓存就会无限膨胀。
  1. 当前大小 N_cache(t)处理完第 t 个请求后,缓存里驻留的对象数量。Ncache(t)\displaystyle N_{\text{cache}}(t)
  2. 上一时刻 N_cache(t-1)处理第 t-1 个请求后的大小,t 时刻在其基础上累加。Ncache(t−1)\displaystyle N_{\text{cache}}(t-1)
  3. 增长斜率 λ每个请求平均新增加入缓存的条目数,λ > 0 说明缓存只进不出。λ>0\displaystyle \lambda > 0

循环引用排查

派对角落里,有两位客人互相搀扶着,周围没一个人认识他们。按门卫的规矩“有人拉着就不能赶”,他们彼此拉着,所以永远满足“被拉住”的条件。但谁都明白:他们跟外界毫无联系,早该一起离开。引用计数这门卫,确实抓不到这种“互相搀扶”的盲区。

为了补上这个盲区,Python 还内置了 gc 模块——一位“从门口名单出发的巡警”。他不看“谁拉着谁”,而是从根对象出发,走遍所有能到达的角落;凡是走不到的区域,即使里面的人互相拉着手,也整体判定为“可以清走”。引用计数是门卫的笨办法,gc 是巡警的全局视野,两者缺一不可。

常用配合有四个:gc.collect() 手动触发一次回收,把当前可达性分析做完;gc.get_objects() 返回 GC 正在跟踪的全部对象,配合 objgraph.count 做二次筛选;gc.garbage 列出“无法回收的循环引用”,通常是环上带 del 方法的对象;gc.set_debug(gc.DEBUG_LEAK) 输出回收日志。实战中 objgraph 与 gc 的标准组合是:先 gc.collect() 把可回收的清掉,再用 objgraph 统计残余;残余里持续增长的对象,就是需要 show_backrefs 追查的嫌疑环。

巡警(GC)负责清场,侦探(objgraph)负责画图,两人配合才能解决“互相搀扶”的问题。理论讲完,最后一节我们看一次完整的破案。

refcount(A)=1, refcount(B)=1,A↔B,root↛A\displaystyle \text{refcount}(A) = 1,\ \text{refcount}(B) = 1,\quad A \leftrightarrow B,\quad \text{root} \not\rightarrow A
循环引用中 A、B 互相引用,各自的引用计数都不为 0;但只要没有任何根对象指向这个环,它们整体就是不可达的,最终可以被 GC 回收。
  1. 两个引用计数A 被 B 引用,B 被 A 引用,计数都为 1,所以纯引用计数永远降不到 0。refcount(A)=1, refcount(B)=1\displaystyle \text{refcount}(A) = 1,\ \text{refcount}(B) = 1
  2. 互指关系存在从 A 到 B 和从 B 到 A 的两条边,形成闭环。A↔B\displaystyle A \leftrightarrow B
  3. 不可达条件不存在从任何根对象指向 A 的路径,这是“可以回收”的判定依据。root↛A\displaystyle \text{root} \not\rightarrow A

实战案例

凌晨两点,告警电话响起:任务队列 worker 的内存又涨到了 90%。这不是第一次了,团队已经重启过三回。侦探登场,这次他带着完整破案手册:快照 → 对比 → 追链 → 修复 → 复盘。每一步都有对应的 objgraph 工具,每一步都需要人来做判断。

背景是这样:一个 Django + Celery 的长驻 worker,每处理一条任务就创建一个 Result 对象,并把结果写进模块级字典 RESULTS,key 是任务 ID。字典只增不减——它就是一册永不清理的名册。但谁也没想到去查这本“名册”,因为单个 Result 很小,直到累积几十万条才暴露。这正解释了为什么内存泄漏总是“温水煮青蛙”。

排查流程可复用为四步:第一步,调用 show_most_common_types,发现 Result 数量异常;第二步,用 show_growth 确认它在持续增长;第三步,by_type(“Result“) 拿到实例,对其中一个调 show_backrefs(max_depth=3),图里清楚出现 RESULTS 字典;第四步,修复:把 RESULTS 改为 weakref.WeakValueDictionary,或给每条记录加 TTL,定期清理过期任务。复盘时还要加防线:代码评审重点检查全局容器;压测脚本里用 objgraph 对比快照;CI 中加一条”内存回归测试”,让同样的问题下次自动报警。

破案手册合上,侦探收工。你会发现,所谓内存泄漏,无非是两类故事:有人被名册(缓存)拉住,或有人互相搀扶(循环引用)且无人认识。objgraph 给的是关系图,而看懂图的你,才是真正的侦探。

λ=N2−N1t2−t1\displaystyle \lambda = \frac{N_2 - N_1}{t_2 - t_1}
两次内存快照之间,驻留对象总数 N 的增量除以时间间隔,就是泄漏速率 λ。修复合格的判别标准:λ 从明显大于 0 降到接近 0。
  1. 两次采样点在 t1 时刻采样得 N1,在 t2 时刻采样得 N2,间隔要足够长才能看出趋势。(t1,N1),(t2,N2)\displaystyle (t_1, N_1), (t_2, N_2)
  2. 增量与间隔分子是对象数增量,分母是时间间隔,两者相除得到单位时间的增长速度。N2−N1t2−t1\displaystyle \frac{N_2 - N_1}{t_2 - t_1}
  3. 速率 λ单位时间内新增驻留对象数,修复后应趋于 0,否则泄漏仍在继续。λ→0\displaystyle \lambda \to 0

AI 应用场景

长驻服务内存告警排查

阶段:模型服务与业务后端的性能调优、故障排查

解决的问题:传统做法靠人工 review 代码和反复重启服务,治标不治本。用 objgraph 对进程做类型快照与反向引用分析,把“对象被谁持有”画出来,能把几天的人工排查压缩到一小时内。推理服务、推荐网关这类长驻进程尤其需要。

具体案例:某推荐后端每隔 6 小时内存涨满触发 OOM。工程师在压测环境用 show_most_common_types 发现 UserEmbedding 对象异常增长,show_backrefs 追到全局特征缓存字典持有 key 从未删除,改为 TTL 缓存后内存曲线平稳。

数据管道与爬虫批量任务

阶段:数据采集与预处理任务的稳定性保障

解决的问题:爬虫和 ETL 逐批处理时,若批次对象被队列、连接池等全局容器持有,内存会线性攀升直至 OOM。用 objgraph 快照对比能快速锁定“批次残留”,而不是靠肉眼猜。

具体案例:数据平台爬虫 worker 处理 10 万 URL 后内存翻倍。用 objgraph.count("Response") 观察批次增长,show_backrefs 发现 requests 会话池持有响应对象,修复后峰值内存下降 60%。

AI 推理服务的缓存管理

阶段:模型推理服务的在线服务优化

解决的问题:推理服务常把 tokenizer 结果、prompt 特征缓存进全局 dict,键空间无限增长。objgraph 可视化引用链能直接画出“只进不出”的容器,帮助工程师决定用 LRU 还是弱引用。

具体案例:某 LLM 推理网关把每次请求的 prompt 特征缓存进模块级字典做“加速”,实际命中率低却内存暴涨;objgraph 图显示字典持有全部历史特征对象,改为 LRU + WeakValueDictionary 后内存稳定。

Python 演示

python53 行
import gcimport objgraph  class Task:    pass  CACHE = []  def run_without_leak():    for _ in range(5):        t = Task()        del t    print("无泄漏场景 Task 存活数:", objgraph.count("Task"))  def run_with_leak():    for i in range(5):        t = Task()        CACHE.append(t)    print("缓存泄漏场景 Task 存活数:", objgraph.count("Task"))  def trace_backrefs():    first = CACHE[0]    print("第一个 Task 的反向引用链:")    objgraph.show_backrefs(first, filename="backrefs.png", max_depth=3)  def trace_refs():    print("从根出发的引用关系:")    objgraph.show_refs([Task], filename="refs.png", max_depth=2)  def stats():    print("当前最常驻的类型 TOP5:")    objgraph.show_most_common_types(limit=5)  def main():    run_without_leak()    run_with_leak()    trace_refs()    trace_backrefs()    stats()    gc.collect()    print("gc 后 Task 仍存活:", objgraph.count("Task"))  if __name__ == "__main__":    main()
目的:演示同一份代码在“正常释放”和“全局缓存泄漏”两种场景下的差异,并用 objgraph 的计数、画图、统计函数把泄漏可视化。
  1. import objgraph引入侦探工具箱,所有画图和统计函数都从这里来。
  2. CACHE = []模拟“从不清理的嘉宾名册”,是泄漏场景的源头。
  3. del t删除本地名字,让引用计数减一;但不代表对象一定被释放。
  4. objgraph.count("Task")数出当前存活 Task 实例的数量,对比两种场景。
  5. CACHE.append(t)全局容器持有引用,制造“该走却走不了”的泄漏。
  6. objgraph.show_backrefs(first, filename="backrefs.png", max_depth=3)追查“谁在引用第一个 Task”,把引用链画成图保存。
  7. objgraph.show_refs([Task], filename="refs.png", max_depth=2)从 Task 类出发向外画引用关系图。
  8. objgraph.show_most_common_types(limit=5)输出当前存活数量前五的类型排行。
  9. gc.collect()手动触发一次 GC,随后再次计数,验证缓存泄漏的对象依然健在。
  10. if __name__ == "__main__":保证脚本可直接运行,也便于被其他模块导入复用。

常见误区

误区我以为 Python 对象只有引用计数降到 0 才会被垃圾回收,所以 del 之后没释放就一定是引用计数没算对。

正解引用计数是 Python 的基础回收机制,但不是全部。循环引用里 A 和 B 计数都是 1,引用计数永远降不到 0,但整个环若从根不可达,仍然会被 gc 模块回收。把回收理解成“从根出发的可达性分析 + 引用计数”双轨,才准确。

为何常见:CPython 文档常强调引用计数,初学者容易把“主力机制”当成“唯一机制”。

误区装上 objgraph 后,我以为运行一下就能直接输出“泄漏点是 xxx 文件的第几行”。

正解objgraph 只负责把对象和引用关系画出来,它是取证工具而非判案工具。哪条引用链不该存在、哪个容器该清理,需要结合业务逻辑判断。侦探提供关系图,破案靠你。

为何常见:工具名带"graph",输出又直观,容易让人误以为它有智能分析能力。

误区内存泄漏了,我手动调用 gc.collect() 总能把该清的全清掉吧。

正解全局缓存持有的引用是“从根可达”的,这些对象根本不是垃圾,gc.collect() 不会也不应该回收它们。gc 只回收不可达对象;对可达但多余的对象,必须靠代码删引用或用弱引用,而不是指望 gc。

为何常见:把“回收”理解成“打扫所有残留”,忽略了 gc 的判定前提是“不可达”。

误区只要我写了 del obj,这个对象马上就被释放,内存立刻降回去。

正解del 只删除一个名字、减去一次引用计数。只要还有别的引用(如全局缓存、闭包、事件回调),对象依然健在。对应到派对:客人自己说“我要走了”,但门卫还攥着他的袖子,他照样走不了。

为何常见:del 的语义跟 C++ delete、Java 的习惯混淆,以为 del 是“强制销毁”。

误区我把所有对象都画进一张图,信息越全越容易找到问题。

正解对象成百上千时,巨图几乎不可读,画图本身也会卡死。正确做法是用 max_depth 限制深度、按类型过滤、先统计排行再对重点对象画局部 backrefs 图。侦探不会把全城人都画进关系板,只画嫌疑人周边。

为何常见:第一次用 objgraph 的人看到完整图很震撼,但实际排查需要“小图 + 聚焦”。

面试题

Q1. objgraph 最核心的能力是什么?

单选题 · easy

  • A.自动修复所有内存泄漏
  • B.绘制对象间引用关系图← 正确答案
  • C.在线编译 Python 源代码
  • D.自动释放全部临时变量

答案:B。objgraph 是“内存侦探”,它把对象间的引用关系画成图,让人看清“谁在拉住谁”,但它不能自动修复、不能编译、也不会自动释放变量。B 才是它的本职。

Q2. 想查看“哪些对象正在引用某个对象”,应该调用哪个?

单选题 · easy

  • A.调用 show_refs 函数
  • B.调用 show_backrefs 函数← 正确答案
  • C.调用 count 统计函数
  • D.调用类型排行函数

答案:B。show_refs 是“顺藤摸瓜”看他引用了谁,show_backrefs 是“倒查真凶”看谁引用了他。查泄漏要找“拉住对象不放的手”,所以用 show_backrefs。

Q3. 在 Python 中执行 del obj 之后,关于该对象的说法正确的是?

单选题 · mid

  • A.一定会被立刻回收清掉
  • B.计数减一,仍有引用则不回收← 正确答案
  • C.必须重启整个进程才可回收
  • D.Python 会自动忽略掉 del 语句

答案:B。del 只是删掉一个名字、让引用计数减一。如果全局缓存、闭包等还持有引用,对象依然健在,就像客人说要走但门卫还攥着他的袖子。

Q4. 全局容器缓存导致的内存泄漏,最直接的修复方式是什么?

单选题 · mid

  • A.换用性能更强的 CPU 硬件
  • B.删除缓存中对该对象的强引用← 正确答案
  • C.调用 objgraph 自动完成清理
  • D.把对象重新复制一份再使用

答案:B。缓存泄漏的根因是“名册上多了名字”,删掉引用或用弱引用才能让对象恢复自由。objgraph 只会画图不会清理,换硬件和复制对象都治标不治本。

Q5. 为何“只有互相引用、无外部引用”的对象无法靠纯引用计数回收?

单选题 · hard

  • A.引用计数足够大时会触发自动回收
  • B.引用计数机制只能处理整数型对象
  • C.两者计数都不为零,需从根判断可达性← 正确答案
  • D.循环引用一旦出现就会导致进程崩溃

答案:C。A 和 B 互相引用时计数都是 1,永远降不到 0,所以门卫(引用计数)放过了它们。只有 GC 巡警从根出发做可达性分析,才能发现这个环与世隔绝并清走。

Q6. objgraph.show_most_common_types(limit=5) 的返回值是什么?

单选题 · easy

  • A.返回各类型的定义源码文本
  • B.返回各类型实例数量排行← 正确答案
  • C.返回各函数的调用次数排行
  • D.返回各模块的导入时间排行

答案:B。它是“人口普查”函数,输出存活数量前五的类型排行。它不分析源码、不统计函数调用、也不管导入时间。

Q7. 调用 gc.collect() 之后,被全局缓存持有的对象为什么仍然存活?

单选题 · hard

  • A.因为 gc 默认不处理任何对象
  • B.因为缓存引用让它从根可达← 正确答案
  • C.因为 Python 无法回收任何对象
  • D.因为缓存对象不属于 Python 对象

答案:B。gc 只回收“从根不可达”的对象。全局缓存里的引用让对象从根就是可达的,它不算垃圾,gc 不会也不应该回收它。这正是缓存泄漏和循环引用的本质区别。

Q8. 用 objgraph 排查内存泄漏时,合理的排查动作有哪些?

多选题 · mid

  • A.用类型统计观察异常增长的对象← 正确答案
  • B.用反向引用图追查引用链的源头← 正确答案
  • C.用 by_type 获取实例并逐个分析← 正确答案
  • D.等待 Python 自动给出泄漏报告

答案:ABC。标准流程是“普查 → 取实例 → 追反向引用”。Python 不会自动输出泄漏报告,D 是把 objgraph 当成“自动判案工具”的常见误解。

Q9. 关于 Python 循环引用与 gc 模块的说法,正确的有哪些?

多选题 · extreme

  • A.纯引用计数无法回收不可达的环← 正确答案
  • B.gc 模块从根出发标记可达对象并回收环← 正确答案
  • C.环上的对象若从根可达则不会被回收← 正确答案
  • D.所有循环引用都会立刻导致程序崩溃

答案:ABC。循环引用的本质是“互拉但无人认识”,纯引用计数失效,需要 gc 从根出发做可达性分析;只要环整体从根可达,就不是垃圾,不会被回收。D 把问题夸大,大多数循环引用不会崩溃,只是泄漏。

Q10. 以下关于 objgraph 的说法,正确的有哪些?

多选题 · hard

  • A.objgraph 用来绘制对象引用图← 正确答案
  • B.需配合引用语义才能定位泄漏← 正确答案
  • C.能够自动修复所有的内存泄漏
  • D.是 Python 自带的官方标准库

答案:AB。objgraph 是第三方库,负责画图和统计,判断需要人结合引用语义完成。它不能自动修复,也不是标准库。记住:侦探给图,破案靠人。