想象你管理着一座繁忙的图书馆——这就是 Python 程序的运行现场。书是对象,变量名是借阅者的手,而每本书的封底都贴着一张“借阅登记卡”,记录当前有多少人在借它:有人来借,卡片数字加一;有人归还,数字减一;数字归零,管理员立刻把书撤下书架。这套机制就是引用计数,它让图书馆高效运转,却有一个天生的漏洞——两本书互相在推荐栏里写着“必读:对方”,于是管理员看甲时觉得乙在借甲,看乙时觉得甲在借乙,两本书的登记卡永远停在了“1“,即使根本没有读者。于是图书馆又养了一支巡逻清点队(gc 模块),定期从大门口出发,沿着借阅记录把所有”真正有人能走到”的书都涂上标记,没标记的书哪怕卡片上还有人名也一律回收。而为了不让清点队每次都要逛遍全馆,图书馆又把书分成新书区、常借区、存档区三个区域,分代回收让清点频率各不相同。这套三层管理机制,就是 Python 内存管理的全部秘密。
对 AI 从业者而言,训练迭代、推理服务、数据管线全都跑在 Python 上,掌握引用计数、gc 和分代回收,你才能在服务内存暴涨时快速定位元凶、合理调优参数,让长跑服务稳定不 OOM。
可视化演示
展示CPython引用计数实时追踪对象生命周期,以及gc模块分代回收检测循环引用的完整过程。
本节配有交互动效(进入视口后自动加载,约 3KB)。
引用计数机制 引用增加:赋值、传参、容器添加时refcount+1 引用减少:del、超出作用域、容器移除时refcount-1 refcount==0 立即释放内存 引用计数是CPython内存管理的第一道防线
引用计数基础
图书馆里的每本书都贴着一张“借阅登记卡”。每当一个读者从书架上拿书,管理员就在卡片上写“+1“;读者归还,就写”-1“。卡片归零的那一刻,管理员毫不犹豫地把书撤下架。Python 中的每个对象就是那本书,而对象的头部就内嵌着这张登记卡——一个名为 ob_refcnt 的数字。你写下 b = a 时,a 所指对象上的计数加一;执行 del b 时减一;把对象塞进列表、作为参数传给函数,计数同样加一。这个机制最迷人的地方在于”实时”:只要计数到零,内存立刻被释放,不需要等任何定时器。
不过这张卡片的登记规则比想象中更精细。初始赋值时 a = [] 计数为 1;再写 b = a,计数变成 2;函数调用 foo(a) 时,函数内部的形参又让计数临时加 1。所以在 Python 里用 sys.getrefcount(a) 查看引用数,你会惊讶地发现返回值比真实值多 1——因为“把 a 传给 getrefcount 这个函数”这件事本身,就产生了一次临时引用。这个数字变化可以用一个计数公式精确描述。
引用计数的好处是“零延迟、不打断进程”,缺点也藏在它的原理里:它只看得见“有没有人指着”,却看不见“指着的人是不是也快死了”。就像两本互相推荐的幽灵书,卡片上明明写着有人借,可那些“借阅者”本身就是对方。这套机制的单兵作战能力止步于循环引用,于是图书馆需要第二层防线。
- 对象 o被监控的 Python 对象,比如一个列表或类实例
- 引用全集所有可能保存对象位置的地方,包括变量、容器槽位、函数实参、栈帧
- 指示函数逐个判断每个引用位置是否指向 o,是则记 1,否则记 0
- 回收条件统计结果为零时立即释放,多一个计数就多续命一次
循环引用问题
假设图书馆里有两本书,甲书封底印着“相关推荐:乙书”,乙书封底印着“相关推荐:甲书”。管理员盘点的逻辑很朴素:只要甲书提到乙书一次,就给乙书登记卡加一;乙书提到甲书一次,就给甲书加一。结果两本书的卡片上永远各停着一个“1“。它们既没有被读者借走,也永远不会因为”没人借”而下架——每个书目都把对方当作自己存在的理由。Python 中最经典的循环引用就是这样:两个对象互相设置属性,a.ref = b; b.ref = a,然后外部变量被删光,只剩两个对象面面相觑。
从底层看,问题出在“计数不为零”这个规则的盲区。当外部变量被删除后,a 和 b 各自只剩下一个来自对方的引用,引用计数各为 1,永远不会降到 0。可在现实世界里,从根对象出发已经没有任何路径能找到它们了——它们是“不可达的垃圾”,却被计数规则判定为“存活”。这就是引用计数机制最著名的局限。Python 的解决方案是引入 gc 模块:一支能把“假借阅关系”拆穿的巡逻清点队。你可以做个小实验:先 gc.disable() 关闭这支队伍,再构造大量循环引用,观察内存只升不降;重新开启后 gc.collect(),内存应声回落。
回到图书馆的比喻:那两本书的卡片数字不是伪造的,但“借阅者”是假的——它们互为对方虚构了一位读者。要识破这种谎言,靠的不是登记卡,而是巡逻队从门口出发的实际搜寻。
- 循环引用形成A 对象持有指向 B 的引用,B 又持有指向 A 的引用
- 外部变量消失代码中的 a、b 变量离开作用域或被 del 删除
- 剩余计数内部互相指认,各自计数仍然保持为 1
- 判断结论对象从根不可达,计数又不为零,无法触发引用计数回收
标记清除机制
巡逻清点队的做法彻底换了一套思路:不再看卡片上的数字,而是从图书馆大门口出发,沿着真实的借阅路线一步步走。凡是能被人从门口一路走到跟前的书,巡逻队就用荧光笔在书脊上画一个绿点“存活”。全部走完后,所有没有绿点的书——哪怕卡片上写着 10 个借阅者——一律撤下架。这套算法的名字叫标记-清除(Mark-Sweep)。
Python 的 gc 模块正是这样工作的。它维护一个根对象集合,包括全局变量、当前调用栈、各个栈帧里的局部变量等。从这些根出发,沿着对象间的引用关系做图遍历;能遍历到的对象标记为“可达”,遍历结束后,整个对象池中所有未标记的对象就是垃圾。这里有一个关键细节:gc 只追踪可能形成循环引用的容器对象——列表、字典、集合、自定义类实例;而整数、短字符串这类不可变对象通常不参与追踪,因为它们根本不可能产生循环引用。你可以随时用 gc.collect() 手动触发一次完整的标记清除,它会返回本次回收的对象个数。
巡逻队当然也有成本:每次全馆清点都要发动一次图遍历,如果图书馆藏书百万本,这趟巡查的代价不菲。所以 Python 不能让清点队天天全馆跑,它需要分而治之——这就引出了分代回收的智慧。
- 根对象集合程序里一切活着的出发点,全局变量、栈帧局部变量、解释器内部引用
- 可达路径从某个根出发,能通过一连串引用关系走到对象 o
- 标记生存者所有能被根走到的对象组成存活集合
- 清除垃圾对象池 U 中不在存活集合里的对象全部释放
分代回收策略
聪明的管理员把图书馆分成三个区:新书区、常借区、存档区。新书区每天都有大量新采购的书涌入,它们寿命短、容易淘汰,所以每周清点一次;坚持到常借区的书已经证明自己有读者,降级风险小,改为每月清点;能进存档区的都是经典,每季度清点一次就够。活过清点的新书晋升到更高区,清点频率随之降低。Python 的三代回收就是如此:新对象进入第 0 代,G0 回收中存活下来的对象晋升到第 1 代,再熬过几次回收则晋升到第 2 代。
具体触发规则由一组阈值决定,默认 gc.get_threshold() 返回 (700, 10, 10)。意思是:分配次数减去释放次数达到 700 时,触发一次 G0 回收(只扫第 0 代);每进行 10 次 G0 回收,触发一次 G1 回收(扫第 0、1 代);每进行 10 次 G1 回收,才触发一次 G2 回收(全量标记清除)。这套设计的经济学在于:绝大多数对象出生即死亡,把它们集中在新书区高频清理,成本低、见效快;而能熬到老年代的对象,与其反复打扰,不如降低检查频率。
新书多折腾,老书少打扰——这套分区哲学正是 Python 吞吐性能的重要保障。至于每代具体什么时候该调整阈值,就要看管理员手里的温度计了。
- 阈值元组三个数字依次控制第 0、1、2 代回收的触发节奏
- G0 回收触发第 0 代新分配的净增量累计到 700,立即清扫新生代
- G1 升级条件G0 回收每满 10 次,顺带扫一遍 G0 加 G1
- G2 升级条件G1 回收满 10 次后,才做一次全量回收
参数调优方法
管理员很快发现,新书区每周都堆得满满当当,清点队刚清完一轮,新的书又涌进来,清点排队越来越长。于是他决定把“新书区触发值”从 700 调到 1200,减少清点次数;可不久后又发现常借区堆了不少陈年垃圾,于是把“G1 触发次数”从 10 调小到 5,让大鱼快点落网。调参就像调暖气:先看温度计,再动旋钮,调完还要观察效果。Python 给管理员的旋钮就是 gc.set_threshold()。
实际操作中,先 gc.get_threshold() 读当前配置,再 gc.set_threshold(t0, t1, t2) 写入新值;gc.get_count() 返回 (count0, count1, count2),也就是三代各自攒了多少待处理对象。调大 G0 阈值能降低回收频率,但单次扫描的暂停时间会变长;调小阈值则相反。调优思路有一条主线:如果程序疯狂创建短命对象(比如训练循环里反复构造中间张量),可以适当调大 T0 减少高频打扰;如果内存里长寿对象很多、想尽快让它们进入老年代,可以调小 T1/T2。调完之后必须监控 RSS 和 GC 耗时,防止按下葫芦浮起瓢。
每一次调整旋钮,管理员都要蹲在图书馆角落观察一会儿:排队的人有没有变长(GC 停顿),书架有没有又塞满(内存驻留)。找到那个“既不卡顿又不浪费内存”的甜点位,就是参数调优的全部艺术。
- 分配速率单位时间内新分配的对象数量,是新生代垃圾产生的源头
- G0 回收频率分配速率除以阈值,阈值越大触发越稀
- 单次停顿成本阈值越大,积压的待扫描对象就越多,暂停越久
- 总开销求和三代各自的频率乘以扫描规模,加起来才是真实成本
内存泄漏排查
就算安保系统再完善,也有抓不到的幽灵书。管理员这次上了双保险:第一,在图书馆每扇窗口装了摄像头(tracemalloc),记录每本书从哪里被领走、当时占了多少空间;第二,请制图师画了一张“书际关系网”(objgraph),把谁引用谁画成一张看得见的图。有了这两样工具,幽灵书再也藏不住。
tracemalloc 的用法非常直白:先 tracemalloc.start() 开启监控,程序跑一段时间后 take_snapshot() 拍一张内存快照;再跑一段时间拍第二张,用 compare_to(snap1, "lineno") 按代码行对比,就能看出哪一行代码分配了最多新增内存。objgraph 则可以做逆向侦查,objgraph.show_refs([obj]) 把某个对象的所有引用链画成图,一眼看清是谁紧紧抓着它不放。常见泄漏场景包括:全局缓存无限增长从不清理、循环引用但对象定义了 __del__ 导致 gc 也处理不了、线程长期持有的局部对象没有释放、事件回调形成闭包链让大对象永远活着。判断是否泄漏也有公式可依——连续两个时刻的驻留内存差,如果持续为正且找不到业务收益,就该报警了。
摄像头告诉管理员“哪扇窗户进来的书最多”,关系网告诉管理员“哪些书本互相拉着不撒手”。双管齐下,再隐蔽的幽灵书也只有被清走一条路。
- 两次采样在 t1 和 t2 两个时刻记录进程驻留内存的大小
- 差值构成内存变化量等于新增分配总和减去释放总和
- 泄漏判据排除业务上合理增长后,差值仍持续为正,就要怀疑有对象无法回收
- 代码行定位用 tracemalloc 按代码行聚合新增内存,找出增量最大的源头
AI 应用场景
大模型推理服务的内存静默攀升
阶段:模型服务部署与运维监控
解决的问题:本地部署大模型推理服务,每处理一万个请求内存就上涨几十 MB,最终整个进程被 OOMKilled 重启。传统 top、RSS 只能看到内存涨,却不知道涨在哪;用 tracemalloc 两次快照对比后,发现是对话历史缓存里残留的中间张量引用未被释放。
具体案例:某团队部署 7B 参数模型,启动 8 小时后驻留内存从 6GB 涨到 12GB。通过 tracemalloc.take_snapshot() 对比快照,定位到 chat_history 全局列表持有旧张量;用 weakref 替换强引用后,内存稳定在 6.5GB,服务不再重启。
数据预处理管线中的 GC 卡顿
阶段:数据预处理与特征工程
解决的问题:DataLoader 每个 epoch 都会重建大量 list、dict 容器,分配速率极高,导致 G0 回收频繁触发,数据加载线程频繁卡顿。用 gc.set_threshold() 适当调大 T0,并改用生成器替代一次性大列表,垃圾峰值明显下降。
具体案例:某视觉模型训练中,每个 step 约分配 200MB 临时对象,G0 回收造成约 2ms 抖动。调整阈值并复用批次缓冲后,数据加载吞吐提升约 25%,GPU 利用率也更平稳。
强化学习回放缓冲的引用泄漏
阶段:训练与实验管理
解决的问题:DQN 训练中 Replay Buffer 存放海量 transition,部分 transition 意外持有旧策略网络参数的引用,导致参数对象无法被回收,训练内存不断膨胀。用 objgraph 画出引用关系后发现,episode 对象同时被 buffer 和 policy 引用,切断其中一条引用链后内存回落。
具体案例:某强化学习实验运行到第 200 万步时内存爆掉,objgraph.show_refs() 显示 transition 中缓存了 action 的 logits 张量,改为只存动作索引后,内存占用下降 40%。
Python 演示
import gcimport sysimport tracemalloc class Book: """a book that may reference another book""" def __init__(self, name, follows=None): self.name = name self.follows = follows def real_refcount(obj, label): # getrefcount returns extra 1 for temp argument real = sys.getrefcount(obj) - 1 print(f"{label} real refcount = {real}") return real def circular_ref_demo(): print("== 1. circular references ==") a = Book("ThreeBody") b = Book("BallLightning") a.follows = b b.follows = a real_refcount(a, "a") real_refcount(b, "b") del a, b collected = gc.collect() print(f"gc.collect() collected = {collected}") def generation_demo(): print("\n== 2. generation status ==") print("gc.get_count() =", gc.get_count()) print("gc.get_threshold() =", gc.get_threshold()) def leak_demo(): print("\n== 3. tracemalloc growth ==") cache = {} tracemalloc.start() snap1 = tracemalloc.take_snapshot() for i in range(500): cache[i] = Book(f"book_{i}") snap2 = tracemalloc.take_snapshot() stats = snap2.compare_to(snap1, "lineno") for st in stats[:3]: print(st) if __name__ == "__main__": circular_ref_demo() generation_demo() leak_demo()
import gc, sys, tracemalloc引入内存管理三件套:垃圾回收器、引用计数查询、内存追踪。real = sys.getrefcount(obj) - 1getrefcount 因参数传递多加一次临时引用,减 1 才是真实值。a.follows = b给对象 A 挂上指向对象 B 的引用,为循环引用埋下伏笔。del a, b删除外部变量,两个对象只剩下彼此之间的引用。gc.collect()手动触发一次标记清除,返回值是本次回收的对象个数。gc.get_count()返回 (count0, count1, count2),一眼看三代各自积压了多少对象。gc.get_threshold()返回当前阈值 (700, 10, 10),是调优的起点。tracemalloc.take_snapshot()给当前内存分配状态拍一张快照。snap2.compare_to(snap1, "lineno")按代码行对比两张快照,找出新增内存最多的分配点。
常见误区
误区我以为 `del a` 之后内存马上归还给操作系统,`__del__` 也会立刻执行,任务管理器和 RSS 应该立刻降下来。
正解引用计数归零后,CPython 确实会回收这个对象,但内存通常归还给 Python 自己的内存分配池,而不会立刻还给操作系统。__del__ 大多数情况会执行,但遇到循环引用或对象被“复活”的情形可能延迟甚至不执行。
为何常见:人们用“释放”一词,下意识把它等同于操作系统层面可见的内存下降,忽视了 Python 内存分配器的缓冲层。
误区我一旦执行 `gc.disable()`,所有对象都会堆积在内存里,程序很快内存爆炸。
正解gc 模块只负责清理“循环引用垃圾”。普通对象的生死完全由引用计数管理,与 gc 的开关无关。禁用 gc 后,只有不可达的循环引用会滞留,通常量很小。
为何常见:误以为 Python 的全部垃圾回收都依赖 gc 模块,忽略了引用计数才是主力。
误区只要两个对象互相引用,就必然内存泄漏,必须自己在代码里手动断开引用才安全。
正解没有外部引用的循环引用对象,会被 gc 自动检测并回收,只要对象不含 __del__ 或没有在析构里复活。只有关闭 gc、对象定义 __del__、或引用链被某些内部机制保护时才会成为真正的泄漏。
为何常见:看到 refcount 不为零就以为是泄漏,忽略了“标记清除能从根出发判断真实可达性”这一层。
误区把 `gc.set_threshold(700,10,10)` 改成 `(2000,100,100)`,回收跑得少,程序应该更快。
正解阈值调大虽然降低了回收频率,但每次回收时积压的对象更多,单次扫描停顿时间变长。对新生代频繁产垃圾的程序,调大 T0 可能有效;但盲目调大 T1/T2 会导致全量回收时的大停顿。
为何常见:误把“频率低”等同于“开销小”,忽略了垃圾总量不变,只是把多次小清理换成了几次大清理。
误区服务 RSS 不断上涨,我立刻断定代码泄漏了,翻遍 gc.garbage 也没找到循环引用,非常困惑。
正解内存上涨可能是三种原因:真实泄漏(不可达对象未被回收)、良性缓存(业务缓存在增长,比如推理结果的 KV cache)、内存碎片化。正确的第一步是用 tracemalloc 快照对比,判断新增内存到底是“无法回收的垃圾”还是“合法持有的缓存”。
为何常见:将“物理内存增长”与“不可回收对象泄漏”画等号,跳过了取证环节直接下结论。
面试题
Q1. Python 中普通对象被回收的触发条件是什么?
- A.程序运行结束
- B.引用计数归零← 正确答案
- C.内存配额不足
- D.GC 定时扫描
答案:B。普通对象的生死由引用计数决定,计数归零立即进入回收流程。就像图书馆的借阅登记卡归零就下架,程序结束、内存不足都只是外部因素,不是触发条件。
Q2. 调用 sys.getrefcount(x) 比真实计数多 1,原因是?
- A.内部存在隐藏引用
- B.存在一个临时引用← 正确答案
- C.整数类型天然失真
- D.统计误差不可避免
答案:B。把 x 传给 getrefcount 这个函数时,函数参数本身会创建一个临时引用,所以结果比真实值多 1。就像你把书递给检查员,检查员手里也拿了一本书,点数时自然会多算一次。
Q3. 以下哪种操作会让引用计数减少?
- A.用 b = a 创建别名
- B.将对象放入列表
- C.用 del a 删除变量← 正确答案
- D.把 a 传给函数
答案:C。del a 删除变量会让指向对象的引用数减一;造别名、放列表、传参数都是在增加引用。图书馆里只有读者真正离开(del)才在登记卡上扣数。
Q4. 两个类实例互相持有对方,会导致内存泄漏吗?
- A.全部一定泄漏且无法回收
- B.外部不可达时 gc 可回收← 正确答案
- C.引用计数会正常归零释放
- D.只有调用系统 reset 才能回收
答案:B。如果两个实例没有外部引用,它们构成循环垃圾,gc 模块的标记清除能从根出发判定不可达并回收。引用计数对此无能为力,但 gc 可以兜底,除非对象含 del。
Q5. 关于 gc.set_threshold(700, 10, 10) 的理解,正确的是?
- A.G0 回收后存活对象进入 G1← 正确答案
- B.该调用关闭了自动分代回收
- C.700 指对象总数上限为 700
- D.10 表示每次分配 10 个对象
答案:A。阈值控制的是回收触发节奏,700 是 G0 净增触发值;每次 G0 回收中存活的对象晋升到 G1,这是分代晋升规则。700 不是总对象上限,10 也不是每次分配数量。
Q6. 引用计数会增加的场景有哪些?
- A.执行 a = b 创建新引用← 正确答案
- B.把对象放入 list 容器← 正确答案
- C.将对象作为参数传入函数← 正确答案
- D.使用 del a 删除变量
答案:ABC。创建新引用、放进容器、传参都会产生一次指向对象的新引用,计数加一;del 则是减少引用。可以理解为图书馆里每多一位借阅者,登记卡就 +1。
Q7. 分代回收中对象从 G0 晋升到 G1 的条件是?
- A.存活时间超过三十秒
- B.在某次 G0 回收中存活← 正确答案
- C.引用计数升高到一定值
- D.手动调用 gc.collect()
答案:B。分代回收不看时间也不看计数,规则只有一个:在 G0 的某次回收中没被清掉就算“熬过一轮”,晋升到 G1。存活时间只是外在表现,机制内核是“被扫描后仍可达”。
Q8. 使用 tracemalloc 排查内存增长的推荐步骤有哪些?
- A.启动时调用 tracemalloc.start()← 正确答案
- B.两次快照 compare_to 对比← 正确答案
- C.使用 objgraph 画引用关系← 正确答案
- D.使用 gc.disable() 暂停回收
答案:ABC。tracemalloc 需要先 start,再在不同时刻拍快照做对比,找出按代码行聚合的新增内存;objgraph 可以辅助画出引用链。gc.disable() 与排查无关,反而会掩盖问题。
Q9. 关于标记-清除算法,以下叙述错误的是?
- A.标记阶段从根对象出发
- B.不可达对象会被回收
- C.可达对象全部保留存活
- D.所有引用计数都会清零← 正确答案
答案:D。标记清除从根出发标记可达对象,保留可达、回收不可达,它不修改引用计数,因此“所有引用计数都会清零”是错误的。它看的是从根出发的可达性,而非计数器数字。
Q10. 关于 del 与循环引用,下列说法正确的有?
- A.含 del 的循环容器默认不立即回收← 正确答案
- B.旧版 Python 可能不调用其析构方法← 正确答案
- C.在 del 中引用对象可致对象复活← 正确答案
- D.有 del 时对象总是回收更快
答案:ABC。定义了 del 的循环对象,在旧版本中 gc 无法安全回收,因为无法确定调用析构的顺序;PEP 442 之后才改进。在 del 里重新引用外部对象会让对象复活,回收反而更慢,绝不可能是“更快”。