Python 内存管理:引用计数、分代 GC 与对象池
Python 内存管理:引用计数、分代 GC 与对象池
写过几年 Python 的人,大概率都遇到过这样的场景:服务跑着跑着 RSS 持续上涨,top 里的内存占用曲线像一条不肯回头的射线;或者某个看似无害的缓存列表,把几十 GB 的内存吃得干干净净。很多人第一反应是"Python 就是慢、就是吃内存",但真正的问题往往不在语言本身,而在我们对它内存模型的理解是否到位。
Python 的内存管理是一套混合策略:以引用计数为主干做即时回收,用分代垃圾回收兜底处理引用计数无法覆盖的循环引用,再通过小整数缓存、字符串驻留、空闲链表等机制降低高频小对象的分配成本。这三者叠加,构成了一个看似简单、实则细节丰富的运行时。本文从源码行为出发,结合生产环境的真实案例,把这套机制讲透。
一、一切从引用计数开始:PyObject 与即时回收
CPython 中几乎所有对象都共享一个头部结构 PyObject(或扩展后的 PyVarObject),其中最重要的字段就是 ob_refcnt——引用计数。每当你把一个对象绑定到名字、塞进列表、作为参数传递,它的引用计数就加一;当绑定消失、元素被删除、函数返回,计数就减一。当计数归零的那一刻,对象被立即销毁,__del__(如果定义了)同步执行,内存归还给分配器。
这个机制最直接的推论是:Python 的回收是确定性的、即时的,不像 Java/Go 那样依赖 GC 线程在未来的某个不确定时刻才回收。理解这一点,对写 C 扩展、做资源管理至关重要。
import sys
a = []
print(sys.getrefcount(a)) # 2:a 本身 + getrefcount 的形参
b = a
print(sys.getrefcount(a)) # 3:又多了一个 b
del b
print(sys.getrefcount(a)) # 2注意 sys.getrefcount 返回的值比你直觉上的多 1,因为调用过程本身把一个临时引用传了进去。这个坑在排查"为什么对象没被释放"时经常误导人。
引用计数带来的另一个著名后果是线程安全代价。CPython 的 GIL 之所以存在,一个重要原因是引用计数的增减需要原子性——如果两个线程同时修改 ob_refcnt 而没有锁,计数就会错乱。这也解释了为什么去掉 GIL 如此艰难:不是不能去,而是要把引用计数的开销从"简单整数自增"升级为更昂贵的原子操作,代价可观(Python 3.13 的 free-threading 实验版本正是用偏置引用计数来缓解这一点)。
在生产环境里,引用计数最容易被忽略的坑是大对象回收的即时性假象。当你 del 掉一个巨大的 list,内存确实"归还"了,但归还给的是 pymalloc 的内存池,而不是操作系统。这也是后文要讨论的 RSS 居高不下的根源之一。
二、循环引用:引用计数的阿喀琉斯之踵
引用计数有一个致命盲区:循环引用。两个对象互相引用,各自的计数永远不为零,于是谁也不会被回收。
import gc
import sys
class Node:
def __init__(self, name):
self.name = name
self.next = None
# 手动关闭 GC,单独观察引用计数的行为
gc.disable()
a = Node("a")
b = Node("b")
a.next = b
b.next = a
print(sys.getrefcount(a)) # 2:a 自身 + 形参
del a
del b
# 此时 a、b 已无外部引用,但二者互相引用,引用计数不会归零
# 如果不禁用 GC,gc.collect() 会在这里回收它们
print("未回收对象数:", len(gc.get_objects()))
gc.collect()
print("回收后对象数:", len(gc.get_objects()))
gc.enable()这段代码揭示了一个残酷现实:如果 Python 没有 GC,循环引用就是永久的泄漏。CPython 解决这个问题的手段是补一个"可达性分析"式的循环检测器,即 gc 模块背后的分代收集器。
值得强调的是,这里的"可达性分析"不是从根对象出发找所有存活对象,而是反过来——找出那些"只被垃圾对象相互引用"的不可达环。这个方向的选择是有讲究的:正向追踪需要遍历整个对象图,代价太高;而循环检测器只需要关注那些"可能是容器、可能形成环"的对象。
需要特别提醒的是自定义 __del__ 与循环引用的组合。Python 2 里,包含 __del__ 的循环引用会成为"不可回收垃圾",永久泄漏;Python 3 改进了这一点,能回收它们,但会有一个明确的告警——PEP 442 引入的 __del__ 调用顺序问题,以及解释器退出时可能不再调用 __del__ 的语义变化,都是迁移旧代码时容易踩的坑。经验法则:能不用 __del__ 就尽量不用,改用上下文管理器或 weakref.finalize。
三、gc 模块:分代回收的工程智慧
循环检测器采用分代(generational)策略,核心假设是"大多数对象朝生夕死"。gc 模块把需要跟踪的对象分成三代(generation 0/1/2):
- 第 0 代:新创建的对象,回收最频繁;
- 第 1 代:经历过一次第 0 代回收仍存活的对象;
- 第 2 代:经历过多次回收的老对象,回收最不频繁。
回收的触发条件是"分配数减去回收数"超过阈值。默认阈值可以通过 gc.get_threshold() 查看:
import gc
print(gc.get_threshold()) # 通常为 (700, 10, 10)含义是:当第 0 代新增对象超过 700 个,触发第 0 代回收;第 0 代每回收 10 次,触发一次第 1 代回收;第 1 代每回收 10 次,触发一次第 2 代回收。三代共用一个阈值元组,语义层层嵌套。
这里有两个实战层面的认知需要纠正:
第一,gc.collect() 并不便宜。 全量回收(generation 2)要遍历所有被跟踪的容器对象,做可达性分析,是 O(存活对象数) 甚至更高的操作。在请求热路径里手动调 gc.collect() 等于自己给自己加 GC 停顿。更糟的是,它还可能打断分代假设——把本应在第 0 代被廉价回收的对象,强行拖进一次昂贵的全量扫描。
第二,禁用 GC 是一把双刃剑。 对于创建大量短生命周期对象、但几乎不产生循环引用的场景(典型的无状态请求处理),gc.disable() 可以带来可观的吞吐提升,因为省掉了每次分配时的 GC 簿记。但前提是你能保证没有循环引用泄漏,否则内存会缓慢而稳定地失控。
# 生产环境一个常见的调优决策:量化 GC 的代价
python -c "import gc; print(gc.get_threshold(), gc.get_stats())"# 一个典型的场景决策表
场景特征:
无循环引用的高吞吐服务: gc.disable() 或调大阈值
大量自定义对象图/树结构: 保持默认,甚至调小第 0 代阈值
长生命周期缓存 + 偶发峰值: 监控第 2 代回收耗时,必要时离线 collect更精细的做法是只跟踪必要的容器。CPython 默认跟踪所有可能形成环的对象(list、dict、set、自定义类实例等),但如果你有海量的"叶子"容器(例如永远不会互相引用的小 dict),可以探索用 gc.freeze()(Python 3.7+)把某些对象从 GC 跟踪中剔除,减少全量扫描的开销——但这是高级技巧,误用会导致真正的泄漏。
四、小整数缓存与字符串驻留:对象池的真相
引用计数和分代 GC 解决的是"何时回收",而对象池解决的是"是否要重复创建"。CPython 对两类高频对象做了预缓存:
小整数缓存。 CPython 启动时预创建了 -5 到 256 的所有整数对象。你在代码里写的任何 x = 100,拿到的都是这个缓存池里的同一个对象,而不是新建。
a = 256
b = 256
print(a is b) # True:命中缓存
c = 257
d = 257
print(c is d) # False:超出缓存范围,各自新建这个边界值 256 是硬编码在 CPython/Objects/longobject.c 里的 NSMALLNEGINTS/NSMALLPOSINTS 逻辑。不要依赖这个行为——它只是实现细节,换一个 Python 实现(PyPy、Jython)结果就不同。用 is 比较整数是初学者最容易犯的经典错误,正确的姿势永远是 ==。
字符串驻留(interning)。 对于符合标识符命名规则(由字母、数字、下划线组成)的字符串,CPython 会尝试驻留,让相同内容的字符串共享同一个对象。此外,编译期能确定的字符串常量会被自动驻留。
a = "hello_world"
b = "hello_world"
print(a is b) # True:编译期常量,自动驻留
c = "hello world" # 含空格,不符合标识符规则
d = "hello world"
print(c is d) # 通常为 False(运行时动态拼接尤其如此)
e = " ".join(["hello", "world"])
f = " ".join(["hello", "world"])
print(e is f) # False:运行时构造,未自动驻留运行时需要驻留时,可以显式调用 sys.intern()。它适用的典型场景是:海量重复的字符串键,例如日志里的字段名、JSON 解析出的重复 key、字典的 key。驻留后这些字符串只保留一份内存,省下的空间在"百万级重复字符串"的量级上非常可观。
import sys
keys = [sys.intern(line.strip()) for line in open("ids.txt")]不过驻留也有代价:驻留表本身是一个全局字典,驻留的字符串永不清除(Python 3.12 之后才逐步改为可被 GC 回收的驻留表)。如果驻留的是高频变化、无穷无尽的字符串,等于人为制造了一个只进不出的全局内存黑洞。所以驻留只适合有限集合、重复率极高的字符串。
五、tracemalloc:内存泄漏排查的实战武器
当服务的内存持续上涨,光靠"感觉"定位问题效率极低。tracemalloc 是标准库自带的、能给出内存分配回溯栈的工具,它比 objgraph、guppy 等第三方库更轻量、无需额外安装,是排查 Python 层内存问题的第一选择。
基本用法分三步:启动跟踪、拿快照对比、定位差异来源。
import tracemalloc
tracemalloc.start()
# 模拟一段"疑似泄漏"的代码
leak = []
def work():
for _ in range(10000):
leak.append("x" * 100) # 不断追加,且 leak 全局持有引用
snapshot1 = tracemalloc.take_snapshot()
work()
snapshot2 = tracemalloc.take_snapshot()
for stat in snapshot2.compare_to(snapshot1, "lineno")[:5]:
print(stat)输出会按内存增量排序,直接指向分配最多的文件与行号。lineno 粒度能精确到某一行代码,traceback 粒度则给出完整调用栈,适合复杂框架里层层封装的场景。
生产环境中,tracemalloc 的正确打开方式不是常开——它的开销不低(每个分配都要记录栈)。推荐的做法是:
- 灰度阶段开启:在内存上涨的节点,周期性(如每小时)抓取快照并落盘;
- 对比相邻快照:用
compare_to找出增量最大的分配点,配合traceback粒度定位到具体调用链; - 关注"仍存活"的对象:泄漏的本质是"对象被创建后本应释放却一直活着",所以要比对
statistics("traceback")里 top 分配点是否随时间线性增长。
# 快速定位:打印当前内存占用 Top 5 的分配栈
python - <<'PY'
import tracemalloc
tracemalloc.start()
# ... 运行业务代码 ...
snap = tracemalloc.take_snapshot()
for stat in snap.statistics("traceback")[:5]:
print(stat)
for line in stat.traceback.format():
print(" ", line)
PY需要提醒的是,tracemalloc 只能看到Python 层的分配,看不到 C 扩展内部通过 malloc 分配的内存(例如 NumPy 数组的底层 buffer、某些驱动库的缓冲区)。如果你发现 Python 层分配量稳定、但进程 RSS 还在涨,就要把怀疑的目光转向 C 扩展、内存池碎片、或者 mmap 等系统级资源了。
六、RSS 居高不下:pymalloc 与系统归还
一个高频灵魂拷问:"我的对象都 del 了,为什么进程 RSS 还是那么高?"
答案是 CPython 的 pymalloc 分配器。小对象(<= 512 字节)从按 size class 划分的内存池(pool)和 arena 中分配,回收时对象回到空闲链表,但内存块并不立即归还操作系统。这是一个典型的"用空间换时间"策略——高频分配释放的场景下,反复向系统 brk/mmap 的代价远高于留着内存池。
这带来的现象是:内存水位只涨不降,或者只在 arena 完全清空时才批量归还。真正要确认是否泄漏,不能只看 RSS,要看峰值之后能否回落,以及是否存在真正的泄漏源。
# 观察内存水位的两个关键指标
ps -o rss,vsz,pid,cmd -p <PID>排查思路可以收敛成一条主线:RSS 上涨 → 区分是 Python 对象还是 C 层 → 用 tracemalloc 定位 Python 层 → 用 valgrind/heaptrack 或平台工具定位 C 层 → 结合业务判断是"真泄漏"还是"缓存设计使然"。 很多被报为"内存泄漏"的问题,最后发现是应用层无界的 LRU 缓存、无限增长的全局 list、或者忘记清理的线程局部变量——这些不是 Python 内存管理器的锅,而是业务逻辑没有设定内存上界。
小结与建议
- 引用计数是主干,GC 是兜底:理解"确定性回收 + 循环检测"的分工,才能解释
del之后的行为。 - 警惕循环引用:构建图、树、双向链表、带回调注册的结构时,优先用
weakref打破环;慎用__del__。 - GC 参数要因场景而异:无循环引用的高吞吐服务可考虑禁用或调大阈值;对象图密集的场景保持默认甚至收紧;先量化(
gc.get_stats())再调优。 - 对象池是性能优化,不是语义保证:永远不要用
is比较整数或字符串值,用==;sys.intern只用于有限且高重复的字符串集合。 - 排查内存问题用 tracemalloc 打头阵:先定位 Python 层分配栈,再考虑 C 扩展与分配器碎片;RSS 高不等于泄漏,先看水位能否回落。
- 给缓存和容器设上限:绝大多数"内存泄漏"是业务侧的无界增长,Python 内存管理器只是忠实地执行了你的设计。