bigywater.tech
Python · 85

gc 模块:为什么手动 gc 不一定能解决内存问题

内存涨上去了,很多人第一反应是「手动 gc.collect() 一下」。但它只处理一种问题:不可达的循环垃圾。这篇拆开四种「gc 帮不上忙」的情形,每种都是真实测量。

回收 0 个
全局 cache 持有 20000 个对象时 gc.collect() 的成果
280 → 246 MB
回收 199999 个对象后,进程 RSS 的变化
12 ms → 0.001 ms
100 万个存活对象时,gc.collect() 在 gc.freeze() 前后的耗时

验证一:「泄漏」大多不是环,是还有人抓着

实测 · 本机执行输出
  往全局 cache 里放了 20000 个对象后, Python 占用 24.3 MB; gc.collect() 回收了 0 个对象, 之后仍占用 24.3 MB
  gc.get_referrers(cache[123]) 找到的持有者: ['dict (长度 20000)']
  del cache 之后 gc.collect() 回收 0 个, 占用 0.0 MB (释放的是引用, 不是靠 gc)

全局字典里放了 20000 个对象,占 24.3 MB。gc.collect() 回收 0 个——这些对象是「可达」的,gc 无权动它们。gc.get_referrers(某个对象) 直接指出持有者是一个长度 20000 的 dict。del cache 之后内存立刻归零,靠的是引用计数,不是 gc。所以排查内存涨的第一步是问「谁还引用着它」,而不是「gc 为什么没回收」。

验证二:gc.get_objects() 只看得到被追踪的对象

实测 · 本机执行输出
  创建 5000 个自引用 Blob 之后, 数量增加最多的类型: {'Blob': 5000}

创建 5000 个 Blob,每个带一个 1000 字节的 bytearray。类型直方图里只增加了 Blob: 5000,占了大部分内存的 bytearray 根本没出现——它不会引用别的对象,所以不被 gc 追踪,gc.get_objects() 里没有它。用这个方法排查,会漏掉「不可能成环但很大」的东西;需要结合 tracemalloc 看真实分配。

验证三:回收了对象,RSS 不一定降

实测 · 本机执行输出
  基线 RSS    54.6 MB → 创建 20 万个 (各带 1000 字节 payload, 自引用)   280.1 MB → gc.collect() 回收 199999 个对象后   246.2 MB
  对照: 申请一块 300 MB 的 bytearray 并写满   546.3 MB → del 之后   546.3 MB

创建 20 万个对象(每个带 1000 字节 payload),RSS 从 55 MB 涨到 280 MB;gc.collect() 回收了 199999 个对象,RSS 只回到 246 MB。对照实验里,一块 300 MB 的 bytearray del 之后,ps 看到的 RSS 也没有下降(546 → 546 MB)。这是在 macOS 上的结果——底层内存是否立即归还给操作系统取决于分配器和系统策略。所以「监控里内存没降」不能推出「Python 没释放」;要看的是 Python 层的占用(tracemalloc)。

验证四:collect 本身的成本

实测 · 本机执行输出
  活着    10,000 个被追踪对象时, 一次完整 gc.collect() 用时     0.58 ms
  活着   100,000 个被追踪对象时, 一次完整 gc.collect() 用时     1.60 ms
  活着 1,000,000 个被追踪对象时, 一次完整 gc.collect() 用时    12.02 ms

完整回收要遍历所有被追踪的存活对象:1 万个时 0.58 ms,100 万个时 12.0 ms。如果在循环里频繁手动调用 gc.collect(),程序里存活对象越多,每次的暂停就越长。

验证五:gc.freeze()

实测 · 本机执行输出
  100 万个存活对象: freeze 前 gc.collect() 12.05 ms → gc.freeze() 之后 0.001 ms;  gc.get_freeze_count() = 1,009,146
  gc.unfreeze() 之后 gc.get_freeze_count() = 0

gc.freeze() 把当前所有对象放进永久代,之后的回收不再遍历它们:100 万个存活对象,回收从 12.05 ms 降到 0.001 ms;unfreeze 后数量归零。适合长期不变的大缓存、或者 fork 之前。冻结的对象里如果后来产生了垃圾,它们也不会被回收——这条我没有单独验证,仅从「移出回收范围」的定义推出。

分步演示:gc 帮得上忙和帮不上忙的地方

数据取自上面的真实实验
四类「手动 gc 不管用」的情形
当前情形

    本文用到的工具与边界

    • 本机 macOS 27 / Python 3.14.7,标准库 gc、tracemalloc;RSS 用 ps -o rss= 测得
    • 没有验证:Linux 上 RSS 归还的行为(macOS 与 glibc 不同)、gc.set_debug 与 gc.garbage、多进程 fork 后写时复制的实际节省量
    京ICP备2026067502号-1京公网安备11010502063487号