想象你管理着一座繁忙的图书馆——这就是 Python 程序的运行现场。书是对象,变量名是借阅者的手,而每本书的封底都贴着一张“借阅登记卡”,记录当前有多少人在借它:有人来借,卡片数字加一;有人归还,数字减一;数字归零,管理员立刻把书撤下书架。这套机制就是引用计数,它让图书馆高效运转,却有一个天生的漏洞——两本书互相在推荐栏里写着“必读:对方”,于是管理员看甲时觉得乙在借甲,看乙时觉得甲在借乙,两本书的登记卡永远停在了“1“,即使根本没有读者。于是图书馆又养了一支巡逻清点队(gc 模块),定期从大门口出发,沿着借阅记录把所有”真正有人能走到”的书都涂上标记,没标记的书哪怕卡片上还有人名也一律回收。而为了不让清点队每次都要逛遍全馆,图书馆又把书分成新书区、常借区、存档区三个区域,分代回收让清点频率各不相同。这套三层管理机制,就是 Python 内存管理的全部秘密。

岗位关联

对 AI 从业者而言,训练迭代、推理服务、数据管线全都跑在 Python 上,掌握引用计数、gc 和分代回收,你才能在服务内存暴涨时快速定位元凶、合理调优参数,让长跑服务稳定不 OOM。

目录
  1. 可视化演示
  2. 引用计数基础
  3. 循环引用问题
  4. 标记清除机制
  5. 分代回收策略
  6. 参数调优方法
  7. 内存泄漏排查
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

展示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 这个函数”这件事本身,就产生了一次临时引用。这个数字变化可以用一个计数公式精确描述。

引用计数的好处是“零延迟、不打断进程”,缺点也藏在它的原理里:它只看得见“有没有人指着”,却看不见“指着的人是不是也快死了”。就像两本互相推荐的幽灵书,卡片上明明写着有人借,可那些“借阅者”本身就是对方。这套机制的单兵作战能力止步于循环引用,于是图书馆需要第二层防线。

refcount(o)=∑r∈引用1[ r→o ],回收  ⟺  refcount(o)=0\text{refcount}(o) = \sum_{r \in \text{引用}} \mathbb{1}[\, r \to o \,], \qquad \text{回收} \iff \text{refcount}(o) = 0
  1. 对象 o被监控的 Python 对象,比如一个列表或类实例
    o={某个 Python 对象}o = \{\text{某个 Python 对象}\}
  2. 引用全集所有可能保存对象位置的地方,包括变量、容器槽位、函数实参、栈帧
    Refs={变量, 容器槽位, 函数实参, 栈帧}\text{Refs} = \{\text{变量},\, \text{容器槽位},\, \text{函数实参},\, \text{栈帧}\}
  3. 指示函数逐个判断每个引用位置是否指向 o,是则记 1,否则记 0
    1[r→o]={1r 正在引用 o0否则\mathbb{1}[r \to o] = \begin{cases} 1 & r \text{ 正在引用 } o \\ 0 & \text{否则} \end{cases}
  4. 回收条件统计结果为零时立即释放,多一个计数就多续命一次
    refcount(o)=0⇒释放 o 的内存\text{refcount}(o) = 0 \Rightarrow \text{释放 } o \text{ 的内存}

循环引用问题

假设图书馆里有两本书,甲书封底印着“相关推荐:乙书”,乙书封底印着“相关推荐:甲书”。管理员盘点的逻辑很朴素:只要甲书提到乙书一次,就给乙书登记卡加一;乙书提到甲书一次,就给甲书加一。结果两本书的卡片上永远各停着一个“1“。它们既没有被读者借走,也永远不会因为”没人借”而下架——每个书目都把对方当作自己存在的理由。Python 中最经典的循环引用就是这样:两个对象互相设置属性,a.ref = b; b.ref = a,然后外部变量被删光,只剩两个对象面面相觑。

从底层看,问题出在“计数不为零”这个规则的盲区。当外部变量被删除后,a 和 b 各自只剩下一个来自对方的引用,引用计数各为 1,永远不会降到 0。可在现实世界里,从根对象出发已经没有任何路径能找到它们了——它们是“不可达的垃圾”,却被计数规则判定为“存活”。这就是引用计数机制最著名的局限。Python 的解决方案是引入 gc 模块:一支能把“假借阅关系”拆穿的巡逻清点队。你可以做个小实验:先 gc.disable() 关闭这支队伍,再构造大量循环引用,观察内存只升不降;重新开启后 gc.collect(),内存应声回落。

回到图书馆的比喻:那两本书的卡片数字不是伪造的,但“借阅者”是假的——它们互为对方虚构了一位读者。要识破这种谎言,靠的不是登记卡,而是巡逻队从门口出发的实际搜寻。

A→B,  B→A  ⟹  refcount(A)=1,  refcount(B)=1A \to B,\; B \to A \;\Longrightarrow\; \text{refcount}(A)=1,\;\text{refcount}(B)=1
外部删除后:  refcount(A)>0∧refcount(B)>0⇒引用计数无能为力\text{外部删除后}:\; \text{refcount}(A)>0 \wedge \text{refcount}(B)>0 \Rightarrow \text{引用计数无能为力}
公式说明:循环引用让“计数为零”这个条件永远无法满足,就像两本书互为对方存在的理由,把引用计数规则拖入死局。
  1. 循环引用形成A 对象持有指向 B 的引用,B 又持有指向 A 的引用
    A→B,  B→AA \to B,\; B \to A
  2. 外部变量消失代码中的 a、b 变量离开作用域或被 del 删除
    del a,b⇒外部引用=0\text{del } a, b \Rightarrow \text{外部引用} = 0
  3. 剩余计数内部互相指认,各自计数仍然保持为 1
    refcount(A)=1,  refcount(B)=1\text{refcount}(A) = 1,\; \text{refcount}(B) = 1
  4. 判断结论对象从根不可达,计数又不为零,无法触发引用计数回收
    不可达∧refcount>0⇒循环垃圾\text{不可达} \wedge \text{refcount} > 0 \Rightarrow \text{循环垃圾}

标记清除机制

巡逻清点队的做法彻底换了一套思路:不再看卡片上的数字,而是从图书馆大门口出发,沿着真实的借阅路线一步步走。凡是能被人从门口一路走到跟前的书,巡逻队就用荧光笔在书脊上画一个绿点“存活”。全部走完后,所有没有绿点的书——哪怕卡片上写着 10 个借阅者——一律撤下架。这套算法的名字叫标记-清除(Mark-Sweep)。

Python 的 gc 模块正是这样工作的。它维护一个根对象集合,包括全局变量、当前调用栈、各个栈帧里的局部变量等。从这些根出发,沿着对象间的引用关系做图遍历;能遍历到的对象标记为“可达”,遍历结束后,整个对象池中所有未标记的对象就是垃圾。这里有一个关键细节:gc 只追踪可能形成循环引用的容器对象——列表、字典、集合、自定义类实例;而整数、短字符串这类不可变对象通常不参与追踪,因为它们根本不可能产生循环引用。你可以随时用 gc.collect() 手动触发一次完整的标记清除,它会返回本次回收的对象个数。

巡逻队当然也有成本:每次全馆清点都要发动一次图遍历,如果图书馆藏书百万本,这趟巡查的代价不菲。所以 Python 不能让清点队天天全馆跑,它需要分而治之——这就引出了分代回收的智慧。

reachable={ o∣∃ r∈roots,  r⇝o },garbage=U∖reachable\text{reachable} = \{\, o \mid \exists\, r \in \text{roots},\; r \leadsto o \,\}, \qquad \text{garbage} = U \setminus \text{reachable}
公式说明:标记-清除的关键是“从根可达才算活”,而不是“有人指着就算活”。这正是一眼识破循环引用的武器。
  1. 根对象集合程序里一切活着的出发点,全局变量、栈帧局部变量、解释器内部引用
    roots={全局变量, 栈帧局部变量, 解释器内部引用}\text{roots} = \{\text{全局变量},\, \text{栈帧局部变量},\, \text{解释器内部引用}\}
  2. 可达路径从某个根出发,能通过一连串引用关系走到对象 o
    r⇝o  ⟺  ∃ r=v0→v1→⋯→vk=or \leadsto o \iff \exists\, r = v_0 \to v_1 \to \cdots \to v_k = o
  3. 标记生存者所有能被根走到的对象组成存活集合
    reachable={o∣∃ r∈roots,  r⇝o}\text{reachable} = \{ o \mid \exists\, r \in \text{roots},\; r \leadsto o \}
  4. 清除垃圾对象池 U 中不在存活集合里的对象全部释放
    garbage=U∖reachable⇒free(garbage)\text{garbage} = U \setminus \text{reachable} \Rightarrow \text{free(garbage)}

分代回收策略

聪明的管理员把图书馆分成三个区:新书区、常借区、存档区。新书区每天都有大量新采购的书涌入,它们寿命短、容易淘汰,所以每周清点一次;坚持到常借区的书已经证明自己有读者,降级风险小,改为每月清点;能进存档区的都是经典,每季度清点一次就够。活过清点的新书晋升到更高区,清点频率随之降低。Python 的三代回收就是如此:新对象进入第 0 代,G0 回收中存活下来的对象晋升到第 1 代,再熬过几次回收则晋升到第 2 代。

具体触发规则由一组阈值决定,默认 gc.get_threshold() 返回 (700, 10, 10)。意思是:分配次数减去释放次数达到 700 时,触发一次 G0 回收(只扫第 0 代);每进行 10 次 G0 回收,触发一次 G1 回收(扫第 0、1 代);每进行 10 次 G1 回收,才触发一次 G2 回收(全量标记清除)。这套设计的经济学在于:绝大多数对象出生即死亡,把它们集中在新书区高频清理,成本低、见效快;而能熬到老年代的对象,与其反复打扰,不如降低检查频率。

新书多折腾,老书少打扰——这套分区哲学正是 Python 吞吐性能的重要保障。至于每代具体什么时候该调整阈值,就要看管理员手里的温度计了。

T=(T0,T1,T2)=(700,10,10),{G0 触发:count0≥T0G1 触发:G0 回收次数≡0(modT1)G2 触发:G1 回收次数≡0(modT2)T = (T_0, T_1, T_2) = (700, 10, 10), \qquad\begin{cases}\text{G0 触发}: & \text{count}_0 \ge T_0 \\\text{G1 触发}: & \text{G0 回收次数} \equiv 0 \pmod{T_1} \\\text{G2 触发}: & \text{G1 回收次数} \equiv 0 \pmod{T_2}\end{cases}
公式说明:三个阈值环环相扣,让大面积回收的发生频率呈数量级递减,把清理成本压缩到最低。
  1. 阈值元组三个数字依次控制第 0、1、2 代回收的触发节奏
    (T0,T1,T2)=(700,10,10)(T_0, T_1, T_2) = (700, 10, 10)
  2. G0 回收触发第 0 代新分配的净增量累计到 700,立即清扫新生代
    count0≥T0⇒只回收 G0\text{count}_0 \ge T_0 \Rightarrow \text{只回收 G0}
  3. G1 升级条件G0 回收每满 10 次,顺带扫一遍 G0 加 G1
    G0 回收次数 mod T1=0⇒回收 G0+G1\text{G0 回收次数} \bmod T_1 = 0 \Rightarrow \text{回收 G0+G1}
  4. G2 升级条件G1 回收满 10 次后,才做一次全量回收
    G1 回收次数 mod T2=0⇒全量回收\text{G1 回收次数} \bmod T_2 = 0 \Rightarrow \text{全量回收}

参数调优方法

管理员很快发现,新书区每周都堆得满满当当,清点队刚清完一轮,新的书又涌进来,清点排队越来越长。于是他决定把“新书区触发值”从 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 停顿),书架有没有又塞满(内存驻留)。找到那个“既不卡顿又不浪费内存”的甜点位,就是参数调优的全部艺术。

f0=λT0,单次停顿∝扫描对象数=O(N0)f_0 = \frac{\lambda}{T_0}, \qquad \text{单次停顿} \propto \text{扫描对象数} = O(N_0)
总开销=f0×O(N0)+f1×O(N0+N1)+f2×O(N0+N1+N2)\text{总开销} = f_0 \times O(N_0) + f_1 \times O(N_0{+}N_1) + f_2 \times O(N_0{+}N_1{+}N_2)
公式说明:调参不是越大越好,而是要在“触发频率”和“单次停顿”之间找平衡。
  1. 分配速率单位时间内新分配的对象数量,是新生代垃圾产生的源头
    λ=Nalloc/Δt\lambda = N_{\text{alloc}} / \Delta t
  2. G0 回收频率分配速率除以阈值,阈值越大触发越稀
    f0=λ/T0f_0 = \lambda / T_0
  3. 单次停顿成本阈值越大,积压的待扫描对象就越多,暂停越久
    pause0∝T0+存活对象数\text{pause}_0 \propto T_0 + \text{存活对象数}
  4. 总开销求和三代各自的频率乘以扫描规模,加起来才是真实成本
    cost=∑g=02fg×Sg\text{cost} = \sum_{g=0}^{2} f_g \times S_g

内存泄漏排查

就算安保系统再完善,也有抓不到的幽灵书。管理员这次上了双保险:第一,在图书馆每扇窗口装了摄像头(tracemalloc),记录每本书从哪里被领走、当时占了多少空间;第二,请制图师画了一张“书际关系网”(objgraph),把谁引用谁画成一张看得见的图。有了这两样工具,幽灵书再也藏不住。

tracemalloc 的用法非常直白:先 tracemalloc.start() 开启监控,程序跑一段时间后 take_snapshot() 拍一张内存快照;再跑一段时间拍第二张,用 compare_to(snap1, "lineno") 按代码行对比,就能看出哪一行代码分配了最多新增内存。objgraph 则可以做逆向侦查,objgraph.show_refs([obj]) 把某个对象的所有引用链画成图,一眼看清是谁紧紧抓着它不放。常见泄漏场景包括:全局缓存无限增长从不清理、循环引用但对象定义了 __del__ 导致 gc 也处理不了、线程长期持有的局部对象没有释放、事件回调形成闭包链让大对象永远活着。判断是否泄漏也有公式可依——连续两个时刻的驻留内存差,如果持续为正且找不到业务收益,就该报警了。

摄像头告诉管理员“哪扇窗户进来的书最多”,关系网告诉管理员“哪些书本互相拉着不撒手”。双管齐下,再隐蔽的幽灵书也只有被清走一条路。

V(t2)−V(t1)=Δalloc⏟新增分配−Δfree⏟实际释放V(t_2) - V(t_1) = \underbrace{\Delta_{\text{alloc}}}_{\text{新增分配}} - \underbrace{\Delta_{\text{free}}}_{\text{实际释放}}
若 V(t2)−V(t1)≫0 且无业务性增长需求⇒疑似泄漏\text{若 } V(t_2) - V(t_1) \gg 0 \text{ 且无业务性增长需求} \Rightarrow \text{疑似泄漏}
公式说明:泄漏排查的本质是“比较两个时刻的内存快照”,让看不见的增长变成看得见的差额。
  1. 两次采样在 t1 和 t2 两个时刻记录进程驻留内存的大小
    V(t1),  V(t2)V(t_1),\; V(t_2)
  2. 差值构成内存变化量等于新增分配总和减去释放总和
    ΔV=∑size(new)−∑size(freed)\Delta V = \sum \text{size}(\text{new}) - \sum \text{size}(\text{freed})
  3. 泄漏判据排除业务上合理增长后,差值仍持续为正,就要怀疑有对象无法回收
    ΔV>0∧无对应业务收益⇒泄漏\Delta V > 0 \wedge \text{无对应业务收益} \Rightarrow \text{泄漏}
  4. 代码行定位用 tracemalloc 按代码行聚合新增内存,找出增量最大的源头
    top=arg⁡max⁡line∑size(alloc at line)\text{top} = \arg\max_{\text{line}} \sum \text{size}(\text{alloc at line})

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 演示

python56 行
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()
目的:演示循环引用的形成与 gc 回收,查看分代状态,并用 tracemalloc 对比快照定位内存增长源。
  1. import gc, sys, tracemalloc引入内存管理三件套:垃圾回收器、引用计数查询、内存追踪。
  2. real = sys.getrefcount(obj) - 1getrefcount 因参数传递多加一次临时引用,减 1 才是真实值。
  3. a.follows = b给对象 A 挂上指向对象 B 的引用,为循环引用埋下伏笔。
  4. del a, b删除外部变量,两个对象只剩下彼此之间的引用。
  5. gc.collect()手动触发一次标记清除,返回值是本次回收的对象个数。
  6. gc.get_count()返回 (count0, count1, count2),一眼看三代各自积压了多少对象。
  7. gc.get_threshold()返回当前阈值 (700, 10, 10),是调优的起点。
  8. tracemalloc.take_snapshot()给当前内存分配状态拍一张快照。
  9. 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 中普通对象被回收的触发条件是什么?

单选题 · easy

  • A.程序运行结束
  • B.引用计数归零← 正确答案
  • C.内存配额不足
  • D.GC 定时扫描

答案:B。普通对象的生死由引用计数决定,计数归零立即进入回收流程。就像图书馆的借阅登记卡归零就下架,程序结束、内存不足都只是外部因素,不是触发条件。

Q2. 调用 sys.getrefcount(x) 比真实计数多 1,原因是?

单选题 · easy

  • A.内部存在隐藏引用
  • B.存在一个临时引用← 正确答案
  • C.整数类型天然失真
  • D.统计误差不可避免

答案:B。把 x 传给 getrefcount 这个函数时,函数参数本身会创建一个临时引用,所以结果比真实值多 1。就像你把书递给检查员,检查员手里也拿了一本书,点数时自然会多算一次。

Q3. 以下哪种操作会让引用计数减少?

单选题 · easy

  • A.用 b = a 创建别名
  • B.将对象放入列表
  • C.用 del a 删除变量← 正确答案
  • D.把 a 传给函数

答案:C。del a 删除变量会让指向对象的引用数减一;造别名、放列表、传参数都是在增加引用。图书馆里只有读者真正离开(del)才在登记卡上扣数。

Q4. 两个类实例互相持有对方,会导致内存泄漏吗?

单选题 · mid

  • A.全部一定泄漏且无法回收
  • B.外部不可达时 gc 可回收← 正确答案
  • C.引用计数会正常归零释放
  • D.只有调用系统 reset 才能回收

答案:B。如果两个实例没有外部引用,它们构成循环垃圾,gc 模块的标记清除能从根出发判定不可达并回收。引用计数对此无能为力,但 gc 可以兜底,除非对象含 del。

Q5. 关于 gc.set_threshold(700, 10, 10) 的理解,正确的是?

单选题 · mid

  • A.G0 回收后存活对象进入 G1← 正确答案
  • B.该调用关闭了自动分代回收
  • C.700 指对象总数上限为 700
  • D.10 表示每次分配 10 个对象

答案:A。阈值控制的是回收触发节奏,700 是 G0 净增触发值;每次 G0 回收中存活的对象晋升到 G1,这是分代晋升规则。700 不是总对象上限,10 也不是每次分配数量。

Q6. 引用计数会增加的场景有哪些?

多选题 · mid

  • A.执行 a = b 创建新引用← 正确答案
  • B.把对象放入 list 容器← 正确答案
  • C.将对象作为参数传入函数← 正确答案
  • D.使用 del a 删除变量

答案:ABC。创建新引用、放进容器、传参都会产生一次指向对象的新引用,计数加一;del 则是减少引用。可以理解为图书馆里每多一位借阅者,登记卡就 +1。

Q7. 分代回收中对象从 G0 晋升到 G1 的条件是?

单选题 · mid

  • A.存活时间超过三十秒
  • B.在某次 G0 回收中存活← 正确答案
  • C.引用计数升高到一定值
  • D.手动调用 gc.collect()

答案:B。分代回收不看时间也不看计数,规则只有一个:在 G0 的某次回收中没被清掉就算“熬过一轮”,晋升到 G1。存活时间只是外在表现,机制内核是“被扫描后仍可达”。

Q8. 使用 tracemalloc 排查内存增长的推荐步骤有哪些?

多选题 · hard

  • A.启动时调用 tracemalloc.start()← 正确答案
  • B.两次快照 compare_to 对比← 正确答案
  • C.使用 objgraph 画引用关系← 正确答案
  • D.使用 gc.disable() 暂停回收

答案:ABC。tracemalloc 需要先 start,再在不同时刻拍快照做对比,找出按代码行聚合的新增内存;objgraph 可以辅助画出引用链。gc.disable() 与排查无关,反而会掩盖问题。

Q9. 关于标记-清除算法,以下叙述错误的是?

单选题 · hard

  • A.标记阶段从根对象出发
  • B.不可达对象会被回收
  • C.可达对象全部保留存活
  • D.所有引用计数都会清零← 正确答案

答案:D。标记清除从根出发标记可达对象,保留可达、回收不可达,它不修改引用计数,因此“所有引用计数都会清零”是错误的。它看的是从根出发的可达性,而非计数器数字。

Q10. 关于 del 与循环引用,下列说法正确的有?

多选题 · extreme

  • A.含 del 的循环容器默认不立即回收← 正确答案
  • B.旧版 Python 可能不调用其析构方法← 正确答案
  • C.在 del 中引用对象可致对象复活← 正确答案
  • D.有 del 时对象总是回收更快

答案:ABC。定义了 del 的循环对象,在旧版本中 gc 无法安全回收,因为无法确定调用析构的顺序;PEP 442 之后才改进。在 del 里重新引用外部对象会让对象复活,回收反而更慢,绝不可能是“更快”。