#28 Python 中等

Python 垃圾回收机制大致如何工作?

GC引用计数循环引用分代回收

🎯 考察意图

数据/后端岗常问的 CPython 内存基础。考察候选人是否知道「引用计数是主路径、分代循环回收是补丁」,而不是只背一个 gc 模块名;以及能否讲清循环引用、__del__、大对象常驻这些实际坑。

📐 回答框架

  1. 先点明对象:主要讲 CPython,不是语言标准,也不是所有实现。
  2. 主机制:引用计数,对象引用归零立刻释放。
  3. 补机制:分代循环垃圾回收,专门处理互相引用的容器。
  4. 收口到工程:弱引用、__del__ 陷阱、大 DataFrame/缓存如何自己管生命周期。

💬 参考答案(口述版)

一句话:CPython 的回收是「引用计数为主、分代循环 GC 为辅」。引用一归零就释放;互相指着、计数永远到不了零的,交给循环检测。

引用计数(主路径):
每个对象头里有 ob_refcnt。赋值、入参、进容器会 +1;离开作用域、del、容器丢掉引用会 -1。到 0 立刻调用析构并释放,时延低、实现简单。这就是为什么 a = [] 再 a = None 后,那块 list 通常马上没了。

循环引用(计数的盲区):

a, b = [], []
a.append(b); b.append(a)
del a, b  # 两个 list 仍互相引用,计数到不了 0

纯计数收不掉。CPython 用 gc 模块做分代回收:容器对象(list/dict/自定义类实例等)按 0/1/2 三代跟踪,年轻代回收更勤。算法本质是:在追踪集合里找「从外部不可达的环」,把环拆掉再降计数。

和面试加分相关的几点:
1. gc.collect() 可手动触发;生产里很少常开频繁全量收集,更常见的是控制对象图别成环。
2. __del__ 和环不要混:若环上对象定义了 __del__,CPython 可能不敢随便拆(顺序不确定),对象会进 gc.garbage,形成「看起来像泄漏」。
3. 弱引用 weakref:缓存、观察者想「用得到但不延长寿命」时用,避免把缓存变成永生对象。
4. 数据场景:Pandas/NumPy 大数组的内存主要在原生缓冲区;Python 侧只是小包装。真正常驻往往是「全局缓存、循环引用的图、未关闭的连接」,不是 GC「太懒」。

口述收尾:先说 CPython、再说计数 + 循环分代,最后主动提 __del__ 环和弱引用,面试官一般就不会再认为你只背了名词。

🔍 关键词

CPython引用计数循环引用分代回收弱引用

💭 面试官可能的追问

追问 1:引用计数已经很快了,为什么还要分代 GC?能关掉吗?

因为环会让计数永远到不了零,必须另做图可达性分析。gc.disable() 可以关掉循环检测,引用计数仍在;只适合你能保证无环、且想减少 STW 抖动的短命脚本。有环还关 GC,就是人为泄漏。分代是为了少扫老对象:大部分垃圾是朝生夕死的,扫年轻代更划算。

追问 2:线上 RSS 只涨不降,一定是 GC 没生效吗?

不一定。先看是不是还被全局 dict、lru 缓存、默认参数可变对象、循环引用的 handler 抓着;再看是不是原生分配器把页还给 OS 慢(arena 碎片)。gc.collect() 后用 gc.get_objects() / tracemalloc / heapy 看谁还活着。数据任务里更常见的是把大 DataFrame 挂在长生命周期对象上,而不是 GC 坏了。

⚠️ 常见踩坑

  • 把 GC 说成 Java 那种单一分代收集,漏掉 CPython 引用计数才是主路径
  • 在循环引用对象上写 __del__,导致环无法回收、内存像泄漏
  • 把 RSS 不下降全部归咎于 GC,忽略全局缓存和原生内存碎片

🧠 记忆锚点

CPython:计数负责立刻释放,分代 GC 专门拆环。