很多人对 Python 内存的第一印象是"它自动管理,我不用操心"。但当线上服务跑了几天 RSS 持续上涨、del 完对象内存却没还给操作系统、或者某次 GC 停顿打穿了 P99 延迟时,你会发现"不用操心"其实是一句很贵的话。要把这些问题讲清楚,得先把 CPython 的内存模型拆开来看。

场景:对象从哪来,到哪去

在 CPython 里,几乎一切都是 PyObject。每个对象头部至少有两个字段:引用计数 ob_refcnt 和类型指针 ob_type。这意味着哪怕一个 True、一个小整数,背后都是一块带头部的堆内存。理解内存管理,本质上就是理解这些 PyObject 何时分配、何时回收。

CPython 的回收机制是两层叠加的:以引用计数为主,分代标记-清除 GC 为辅,专门处理引用计数解决不了的循环引用。这两者各管一摊,不能混为一谈。

机制一:引用计数

引用计数是即时的、确定性的。每当一个对象被新的名字、容器槽位或栈帧引用,计数加一;引用消失则减一;归零的瞬间立即触发析构和内存释放。

1
2
3
4
5
6
7
8
import sys

a = []
b = a
# getrefcount 自身持有一次临时引用,所以读数比实际多 1
print(sys.getrefcount(a)) # 通常是 3:a、b、参数
del b
print(sys.getrefcount(a)) # 2

引用计数的好处是回收时机可预测:对象一旦不可达就立刻释放,不依赖某个后台扫描周期。这也是为什么 Python 里 with 上下文、文件句柄能"出作用域就关"——CPython 实现下,对象计数归零会立刻调用 __del____exit__ 之外的清理逻辑。

但引用计数有两个致命弱点。第一是循环引用:

1
2
3
4
5
6
7
8
9
class Node:
pass

x = Node()
y = Node()
x.next = y
y.prev = x # 互相引用
del x
del y # 名字删了,但两个对象的 refcnt 仍是 1

del 之后 xy 这两个对象谁都不被外部引用,却因为互指而计数永不归零,引用计数无能为力。第二是多线程下计数本身要加锁——这正是 GIL 存在的重要原因之一:用一把全局锁保护引用计数的增减,避免每个 incref/decref 都走原子操作。

机制二:分代垃圾回收

为了清掉循环垃圾,CPython 引入了分代 GC。它只追踪容器类对象(list、dict、自定义类实例等可能产生环的对象),纯标量(int、str)不进 GC 链表,因为它们不可能构成循环。

核心算法是"标记-清除"的一个变体。GC 遍历被追踪对象,先对每个对象的引用计数做一份临时副本,然后扣掉容器内部互相引用贡献的计数。扣完之后,如果某对象的临时计数仍大于 0,说明它还被环外的某个东西引用着,是存活的;反之就是纯循环垃圾,可以回收。这个"减去内部引用"的思路是理解 CPython GC 的关键。

分代的设计基于弱代假说:大多数对象很快就死。CPython 分三代(0、1、2)。新对象进 0 代,0 代触发频繁、扫描快;熬过一次回收的对象晋升到更老的代,老代扫描间隔更长。触发由计数器驱动,大致是"分配数减释放数超过阈值"就扫一次 0 代,0 代扫够若干次再带上 1 代,以此类推。

1
2
3
4
5
import gc

print(gc.get_threshold()) # 默认类似 (700, 10, 10)
print(gc.get_count()) # 当前三代的计数器
gc.collect() # 手动全代回收,返回不可达对象数

工程权衡与边界

STW 停顿。 分代 GC 在扫描时需要短暂"停下世界"。对绝大多数应用毫秒级停顿无感,但在低延迟服务或持有海量长生命周期对象(大缓存、ORM 加载的百万行)的进程里,老代全扫会变贵。一个常见且有效的手段是在这类场景禁用或调高阈值

1
2
3
4
import gc
gc.disable() # 完全交给引用计数,循环垃圾靠重启或手动 collect
# 或者:在加载完大块只读数据后冻结,避免反复扫描
gc.freeze() # 把现存对象移出常规分代,不再参与每次扫描

gc.freeze() 在 fork 模型的服务(如 Gunicorn pre-fork)里尤其有用:父进程加载完数据后 freeze,子进程 fork 出来共享这些页,GC 不去动它们就不会因写时复制(CoW)把整页复制一份,能显著省内存。

内存不还给 OS。 这是最常见的踩坑。CPython 自己管着一套 pymalloc 分配器:小对象(默认 ≤512 字节)由 arena/pool/block 三级结构管理。对象释放后,内存先还给 pool 复用,只有整个 arena 全空才可能还给操作系统。结果就是——你 del 了一个大 list,进程 RSS 纹丝不动,因为那些块被留作下次分配的池子了。这不是泄漏,是缓存。判断真泄漏要看曲线是否持续单调上涨,而不是涨上去之后维持平台期。

大对象与碎片。 大于阈值的对象绕过 pymalloc 直接走系统 malloc。频繁分配/释放大小不一的对象会造成堆碎片,让 RSS 比"实际存活字节数"高出一截。

常见误区

  • __del__ 救不了你,反而坑你。 早期 CPython 中,含 __del__ 的对象若卷入循环,GC 无法确定析构顺序而把它们丢进 gc.garbage 不回收(这一限制在后续版本已大幅改善,但仍应避免在 __del__ 里写复杂逻辑)。析构清理优先用 with / contextlib
  • getrefcount 的读数永远偏大 1,因为传参本身建了一次引用,别拿它做精确断言。
  • 以为 gc.disable() 能根治停顿:它只关掉循环回收,引用计数照常工作,循环垃圾会一直堆积。适合短生命周期批处理进程,不适合长跑服务。

小结

CPython 的内存模型是"引用计数即时回收 + 分代 GC 兜底循环引用"的组合。引用计数给了确定性和及时性,代价是 GIL 和对循环的无力;分代 GC 补上循环这一块,代价是偶发 STW。再往下,pymalloc 的池化分配解释了"内存不还给 OS"的现象。把这三层——计数、GC、分配器——分开看,线上那些"内存诡异"的问题大多就能对号入座了。