☰
Python内存管理机制全解:引用计数、垃圾回收与循环引用实战
2026/9/28 15:13:08 网站建设 项目流程

Python的内存管理机制,几乎所有 Python 开发者都背过“引用计数加垃圾回收”这句话,但真正把它搞透、能用来排查线上问题的并不多。我自己也是在一次内存疯狂暴涨的事故里,才彻底把这块知识钉死在脑子里。今天就用这篇长文,把引用计数、垃圾回收、分代收集、循环引用的完整链路拆开揉碎讲清楚,整个过程会结合 CPU 和内存开销来聊,基于实测和项目踩坑经验。

这篇东西适合这几类人:正在面试前突击 Python 底层的同学,想知道gc模块到底在干嘛的 Web 后端工程师,以及内存莫名飙升、怀疑有“隐式引用”但无从下手的开发。我会把原理、观察方法、调优参数和真实排查案例一起给出来,全部基于 CPython 实现来说,PyPy 或 Jython 有不同逻辑,不在本文范围内。

1. 内存管理的核心逻辑:一切从引用计数开始

1.1 Python 如何为每个对象维护“被引用数”

CPython 源码里每个对象都有一个固定的头结构,里面最关键的就是ob_refcnt,这个整型字段记录着当前有多少个地方正在引用这个对象。你可以简单理解成每个人头上都顶着一个计数器,你被变量名引用了一下,计数器加一;某个容器把你放了进去,计数器再加一;什么地方把你删掉了,计数器减一。当计数器减到 0,说明整个程序里再也没有任何入口能找到这个对象,它立刻就会被释放。

日常写代码时,这个计数器在你完全没感知的情况下不断变化。看这段:

import sys a = [1, 2, 3] print(sys.getrefcount(a)) # 2

为什么打印出来是 2,而不是 1?因为读取getrefcount(a)这个动作本身,函数传参时也会让你的引用计数临时加一。这是一个超级经典的坑,很多人测试时以为引用计数异常,其实只是被这个隐式引用干扰了。你继续执行:

b = a print(sys.getrefcount(a)) # 3 c = [a, a] print(sys.getrefcount(a)) # 5

赋值给新变量、放进列表容器、作为参数传进函数、成为某个对象属性,这些操作都会实打实地递增引用计数。同样地,把一个变量重新赋值成别的值、把列表里的元素删掉、函数调用结束时局部变量消失,计数器也随之递减。

正因为这个计数器是每时每刻都在变的,CPython 选择把它作为一个普通字段直接存在对象头里,读取和修改都只是对一个内存字节做操作,不经过额外查找。这样做的好处是回收时机是确定的:引用计数归零的那一刻,对象立即被销毁,内存当场归还给 Python 分配器,不会像某些语言那样拖到某个不确定的 GC 周期才回收。

1.2 计数归零:释放的完整链路是什么

当一个对象的引用计数从 1 降到 0,CPython 会走一条固定的释放链路。以列表对象为例,步骤大致是这样:

  1. 触发Py_DECREF(obj)宏,内部对ob_refcnt做减一操作。
  2. 判断减完后是否为 0。
  3. 为 0 则调用该对象的tp_dealloc函数,也就是类型专属的析构入口。
  4. 列表的tp_dealloc会先把自己内部持有的元素全部Py_DECREF一遍,再释放类型相关的附加内存。
  5. 最后归还对象本体所占的内存块。

值得留意的是第五步里“归还”的含义。CPython 为保证性能,并不是每次都调用操作系统的free()把内存交还给系统,而是内存分配器将小块内存收回到自己的缓存池里,之后创建同类型对象时可以直接复用。所以用resource或psutil看进程 RSS,经常发现 Python 进程长期占用着“没被释放”的虚拟内存,这其实是分配器缓存机制在作怪,而不是传统意义上的泄漏。后面讲排查时我会再次强调这个区别。

引用计数还有一个重要特点:确定性带来的低延迟。每个对象的回收都是即时、分散的,开销被摊在不固定的各个操作之间,不会出现 GC 风暴导致程序卡顿。对于一个生命周期很短的对象(比如临时拼接的字符串、函数内新建的列表),它在函数返回时就被立刻释放,内存能迅速被复用,这比“攒一批再回收”的设计在任何场景下都更平滑。

1.3 引用计数的致命短板:循环引用

引用计数机制最大的软肋,就是解决不了循环引用。看这段:

class Node: def __init__(self): self.child = None a = Node() b = Node() a.child = b b.child = a del a del b

删掉a和b两个变量之后,a的引用计数并不会归零,因为b.child还指着它;b同理,因为a.child也还指着它。两个对象彼此互相“搀扶”,引用计数永远停在 1,谁都别想被释放。更复杂的循环网络也是这样:三个对象首尾相连、容器对象互相套娃、闭包引用了外部栈帧……只要形成环,引用计数就完全无能为力。

这也是 Python 必须在引用计数之外再设计一套独立垃圾回收器的根本原因。引用计数管的是“普通断开”,GC 管的是“环状残留”。两者分工明确,缺一不可。

一个额外提醒:如果你确定自己的代码里完全用不到循环引用,理论上可以手动关闭 GC 来换取一点性能余量,但绝大多数场景不建议这么干,你很难保证项目依赖的某个框架没有分页缓存、引用环之类的设计。我把开启和关闭对性能的影响放到了后面的调优章节里细讲。

2. 循环引用与分代垃圾回收

2.1 为什么需要独立的垃圾回收器

Python 垃圾回收器(gc模块)是一个独立于引用计数的子系统,它的核心职责就是找出“虽然已经不可达、但引用计数仍大于 0”的循环引用对象,然后把它们成环的引用断开,让引用计数能顺利降到 0,最终触发常规释放流程。

但注意一个细节:GC 并不是对所有对象都做跟踪的。你通过gc.is_tracked()能验证,只有可能产生循环引用的容器类型才被列进跟踪名单。列表、字典、自定义类的实例、集合这些都可以跟踪;整数、字符串等不可变原子类型,永远不会被 GC 跟踪。原因是它们自身不可能持有指向其他对象的引用,自然不存在形成环的可能。这个设计很聪明,直接让 GC 的工作量大幅减少。

gc模块经典的两大功能组件是:用于寻找不可达环的检测器,以及一个用来触发检测的分代调度器。对于复杂的循环引用结构,Python 使用“标记-清除”算法:从根集合出发遍历整个对象图,能走到的是“可达”,走不到的是“不可达”。不可达对象如果在跟踪列表里,就会被优先放进待释放集合。

标记阶段对性能影响最大的地方在于,它会访问并遍历到每一个存活对象。Python 里对象数量几乎都是十万百万级的,所以必须用一个“概率上能快速发现垃圾”的调度策略,这就是分代收集理论的应用场景。

2.2 分代调度:三个代龄和一套概率理论

Python 把被 GC 跟踪的对象分成三“代”:第 0 代、第 1 代、第 2 代。任何新创建并被跟踪的对象,最先都归入第 0 代。每完成一次该代对象的收集,如果对象仍然存活,代龄就提升一档;第 2 代提升到顶之后就不再升级,一直待在老年代里。

这背后的概率假设非常朴素:一个对象如果能在一个完整的年轻代回收周期里存活下来,那它大概率会被长周期持有(比如全局缓存、配置项、单例对象),短时间内再次成为垃圾的可能性很低;反之,生命周期极短的对象大概率创建后很快归零,在年轻代就能被快速“转正”。因此 Python 对年轻代使用更高的回收频率,对老年代降低频率,用少量收集成本覆盖大部分回收收益。

阈值控制是通过gc.set_threshold(threshold0, threshold1, threshold2)来调整的。默认值是 700、10、10,含义要从下往上读:

  • 每新分配 700 个被跟踪对象且第0代对象数量 - 第0代上次收集后的存活数达到 700,做一次第 0 代收集。
  • 第 0 代在两次收集之间如果累积了 10 次,做一次第 1 代收集。
  • 第 1 代累积 10 次时,做一次第 2 代收集。

如果你调用gc.get_count(),会看到返回(count0, count1, count2)三个整数,分别表示距离上一次 0 代收集以来新分配的对象数量、距离上一次 1 代收集以来发生的 0 代收集次数、距离上一次 2 代收集以来发生的 1 代收集次数。

每次代收集达到阈值时,GC 都会重新对那一代的所有对象做一次标记-清除,所以年轻代收集的花费比较低,但频率高;老年代收集频率极低,但一次的通扫成本极高。高并发 Web 服务里,偶尔一个深夜尖峰就是 2 代大扫描触发的短暂停顿。

如果项目里的gc.disable()被调用了,阈值计数会依然增长,但不会自动触发任何回收。只有手动调用gc.collect(generation)时,对应代的所有对象才会被立即完整扫描。这里我强烈提醒一点:关闭 GC 或用gc.freeze()前,一定要把收益和风险都量化,很多项目以为关掉 GC 能提升 QPS,结果内存上涨到 OOM 后,反而是灾难性故障,比那一点性能提升昂贵得多。

2.3 PEP 442:终结器处理的重大改进

在 Python 3.4 之前,带着__del__方法的对象和循环引用碰撞时,会产生一个尴尬的拒绝释放局面。因为 CPython 会严格保证不可达对象在终结时,__del__方法必须能被正常执行,而需要执行终结方法的对象又可能标记了其他对象,导致“为了安全不能提前释放”。结果就是带有__del__对象形成循环引用时,即使 GC 已经识别出它不可达,也没法把它释放掉,只能塞进一个全局列表,由程序进程结束统一收拾。这几乎就是旧版 Python 最著名的隐式内存泄漏“重灾区”。

PEP 442 的解决思路是,允许对象在“不可达”状态下先进入终结器处理列表,然后由专门的机制统一执行__del__。执行后即使这些对象重新变得可达(比如__del__里又把全局变量引用了一遍),GC 也不会再回收它,但至少保证:

  • 循环引用中的对象可以被真正释放。
  • __del__依旧会被调用,且调用顺序稳定。
  • 不会出现“因循环引用导致永远不调用__del__”这种死锁式泄漏。

所以如果你还在维护 Python 3.3 及更老的项目,请务必尽快升级,不是开玩笑,这个改动对稳定性影响很大。对于新项目,看到__del__出现在代码里,我依然建议多想想有没有替代方案,这个钩子的调用时机在某些场景下依然比“上下文管理器”的确定性差很多。

3. 实操观察与调优:从理论到命令

3.1 利用 sys 与 gc 观察对象的真实状态

先认识几个能体现对象真实血条的 API。除了sys.getrefcount之外,gc模块提供了:

import gc objects = gc.get_objects() print(len(objects)) # 当前被GC跟踪的对象总数 stats = gc.get_stats() print(stats) # 每个item包含 collections、collected、uncollectable 三个数字

gc.get_stats()是观察代际收集频率和回收命中率的最直接方式。三个统计值分别代表:这一代被扫描了多少次、真正回收掉多少个垃圾对象、有多少对象无法被回收(几乎都是带__del__且参与循环引用的)。

还有一个被大多数人忽略的函数get_referrers(),它可以从任意对象出发反向定位“到底是谁在引用它”。排查内存问题的核心场景就是“我这个对象明明删了变量,但内存还是没降”,此时用反向追踪定位引用持有者,比拿着大把sys.getrefcount数据猜要高效得多。

再强调一遍sys.getrefcount(x)会多算一次临时引用。如果希望在代码里准确判断某变量引用计数是不是理论上归零,正确做法是把这个数减一再判断。

tracked状态也值得看。当你对某个自定义实例执行gc.is_tracked(obj)返回False,说明它还没进入 GC 的监控视野。只有它真的持有容器属性并发生修改,CPython 才把它注册到跟踪列表。千万不要以为自定义对象天生就会被跟踪,这个判断点很反直觉。

3.2 手动触发收集的时机与阈值调整

API 层面看,gc.collect(generation)可以让你随时对某代做一次完整扫描。但实战中我不建议代码里到处塞“随手 collect”,原因很简单,扫描老年代的开销非常现实,在你不需要回收时强制执行,只会白白吃掉 CPU。

比较合理的手动触发场景有这几种:

  • 用完一个超大的缓存结构后立刻调用gc.collect(2),并且清除强引用,让对象有机会尽快被归位给分配器复用,这在大数据处理、批量训练前尤其有用。
  • 对性能敏感的服务启动阶段,先创建一个缓存池,再执行gc.collect(2),然后再gc.freeze()。freeze()会把当前所有存活对象移入一个“永不追踪”列表,不再参与后续分代收集。这样初始化阶段后的长期运行中,GC 只需要关注启动后新建的短命对象,老年代的扫描成本被直接省掉,对降低延迟抖动非常有效。
  • 单元测试里需要确保某些对象被释放时,手动“强制垃圾”能验证终结器是否真的执行过。

阈值调整是个平衡游戏。把threshold0从 700 降到 50,意味着 GC 更频繁地扫描第 0 代,活对象数量下降,但 CPU 会被扫描开销拖累;反向提高阈值,GC 频率降低,但内存峰值上升,瞬时耗时也会集中在某一次大扫描里。网上很多“性能优化”文章建议直接gc.disable(),我只能说这种建议适合玩具项目。真正的生产服务,GC 这场后台保洁工作完全可以用“周期收集 + 冻结初始化对象”的组合拳替代暴力关闭,效果又稳又可控。

3.3 常见误区与不合理的调优方向

第一个误区:把回收频率调高,以为对象能更早释放。实际上gc设计的目的只是清理循环引用,大部分对象是靠引用计数即时释放的。把 GC 调得过于频繁,只会不断扫描那些本来就不需要它插手的对象图,纯纯浪费时间。

第二个误区:把sys.setrecursionlimit调大后联想 GC 也会递归穿层。实际上 GC 用的是迭代式的对象图遍历,跟 Python 调用栈递归深度没有关系。但你确实不能在__del__里做递归或者复杂操作,因为终结器会阻塞 GC 周期,从而拖慢整个进程。

第三个误区:拥有大量自引用或互引用的对象时,以为只要del 变量就万事大吉。刚才已经验证了,循环引用根本不会让引用计数归零,此时该出手的必须是 GC。如果你确认环里所有对象都没有覆盖__del__,而你又希望立刻回收,正确的做法是:

import gc gc.collect(0) gc.collect(1) gc.collect(2)

四个误区都绕过之后,你才算真正理解 GC 在 Python 里的角色:引用计数负责日常,GC 负责兜底收尾,两者各管一段,不能互相替代。

4. 常见问题与排查技巧实录

4.1 内存只增不减:泄漏的定位方法论

我相信每个 Python 后端开发都经历过那种恐怖故事:进程内存从 20% 慢慢爬到 90%,然后 OOM,重启,循环往复。这种问题的排查顺序非常重要,乱开工具只会越查越乱。

第一步,先区分真泄漏和假泄漏。假泄漏是指 Python 分配器缓存了内存但未交还给 OS,表现为进程 RSS 高但虚拟内存稳定,实际可用内存并没有持续丢失。判断方法很简单:用 Python 侧统计存活对象数量和总内存,如果对象数量不再增长,那大概率就是分配器缓存问题,可以通过malloc_trim(0)在 Linux 下尝试释放还给系统。

第二步,用tracemalloc定位新分配对象的方向。tracemalloc是个标准库模块,能在分配时记录调用栈快照,是抓“谁在疯狂创建新对象”的利器:

import tracemalloc tracemalloc.start(25) # 记录深度25帧的分配栈 # ... 运行你的业务逻辑 ... snapshot = tracemalloc.take_snapshot() top_stats = snapshot.statistics('lineno') for stat in top_stats[:10]: print(stat)

快速做两次快照,对比两者差异里新增最多的地方,基本就是内存增长的源头。tracemalloc只能看到“分配未释放”的部分,如果泄漏点非常隐匿,它无法直接定位到“谁在持有引用”,此时轮到objgraph出场。

第三步,objgraph.show_growth()能告诉你进程里哪些类型对象数量增长最快,结合objgraph.show_refs([obj], filename='graph.png')画出某几个可疑对象的完整引用图,你就能直接看到某个全局字典、类变量或闭包栈帧是怎么锚住这些对象的。

第四步,确定持有者之后,用gc.get_referrers(obj)反查所有引用路径,然后针对性改代码,把不该存在的强引用改成weakref或在适当时机置空。

4.2 典型案例:闭包、类变量与全局缓存

闭包导致的引用保留,是代码评审中最容易漏掉的一种泄漏来源。假设你在视图函数里定义了一个内部函数,函数内部又引用了外层的一个大列表,只要内部函数还被某个全局注册表或队列持有,外层那个大列表就会被这个闭包栈帧一路拽着,永远释放不掉。问题的隐蔽性在于:你看到的“变量”可能已经出了函数作用域,但引用链已经从内部函数向上延伸到了外部容器。

类变量这个坑更常见。你写了个类,用类属性存了一个缓冲字典:

class Cache: data = {} @classmethod def set(cls, k, v): cls.data[k] = v

这个data挂在类对象上,类对象又挂在模块上,模块生命周期基本等于进程生命周期。任何塞进去的对象,除非显式删除这个 key,否则都会一直存活。这不是 Python 的问题,而是你的设计把对象的生命周期锚到了进程级。大缓存一定要记得加淘汰策略。

全局缓存里还有一个隐性王者——functools.lru_cache和各类单例管理器。滥用缓存装饰器之后,函数的每个不同参数组合都会把结果对象留在缓存字典里。如果参数本身是不断增长的 ID 或时间戳,缓存就会无限膨胀。这类问题被objgraph.show_growth()一照就原形毕露,因为能看到某个特定自定义类型对象数量持续上升。

线程池和异步任务里的隐式引用。concurrent.futures.ThreadPoolExecutor提交的每个任务对象会持有函数及其参数引用,如果 future 结果直到任务结束都没被取走,引用链会保持。更隐蔽的是你的事件循环里某个回调函数不小心成了匿名闭包函数,它捕获了超大二进制数据帧,而这个闭包被周期性地重复注册,内存就会被“小口却不停歇”地吃干。

4.3 避坑速查表:六个高频问题的对照与解决

现象可能原因第一步排查推荐解法
对象始终在内存里对象被全局缓存/类属性引用gc.get_referrers(obj)改用weakref或定期清理
带__del__的对象无法回收在循环引用里且析构时机不确定查看gc.get_stats()的 uncollectable改用contextlib实现清理逻辑
内存不降但对象数量稳定分配器缓存观察mallinfo或 RSS 趋势考虑malloc_trim(0)或调小内存块池
所有对象计数激增tracemalloc定位到分配热点用两次快照对比增长顶优化数据流,减少临时对象创建
老年代回收耗时过长进程里长期存活对象过饱和gc.get_threshold()和get_stats()提升老年代触发阈值或gc.freeze()
用了gc.freeze()后仍上涨初始化后的对象也在累积对比冻结前后增长代际检查初始化阶段是否有漏冻结的全局对象

光看这张表还不够,再补充一个我一直强调的实践:建立一个进程级监控指标,周期性记录sys.getallocatedblocks()、gc.get_count()、gc.get_stats()和 RSS。这三个数字是三维一体观察 Python 内存的稳健组合。

  • getallocatedblocks()反映当前活跃对象总数,上升说明对象大量存活;
  • get_count()反映 GC 调度压力,数字狂增说明对象创建频率高;
  • RSS 反映真实内存占用。

如果活跃对象总数下降而 RSS 不降,那就是分配器缓存问题,不是 bug;如果活跃对象总数持续上升,那你几乎可以确定代码里存在引用链锚死对象,此时往下追引用路径是正确的方向。

我在实际运维中还有一个很实用的脚本习惯,每次发布新版本后,挑一个低流量时段跑一次 10 分钟的内存采样,把上述指标保存下来跟上次发版做对比。这种预防性检查比等 OOM 告警出来再排查,省下的精力不是一点半点。

4.4 弱引用:精简引用关系的一把手术刀

Python 的weakref模块能解决很多由“生命周期锚定”引发的问题。它的核心能力是引用对象但不会增加目标对象的引用计数,当目标对象引用计数归零被回收时,弱引用对象会变成一个“失效引用”,你可以判断后采取兜底逻辑。

典型用法是缓存、事件订阅、对象池等场景。比如你有大量图表对象需要按 ID 快速查找,但又不希望图表对象一直占据内存:

import weakref class Chart: pass registry = weakref.WeakValueDictionary() c = Chart() registry['unique_id'] = c del c print(registry.get('unique_id')) # None,因为c已经被释放

弱引用这层设计成熟之后,它并不是让你“把一切强引用都改成弱引用”。因为一个只有弱引用的对象,回收时刻是不可控的,靠它做保证型任务会带来稳定性问题。正确的姿势是:你的核心业务数据结构用强引用保证存活,缓存、索引、观察者这类“丢失了也无所谓,可以从源头再重建”的附属结构交给弱引用。

5. 踩坑心得与实用扩展

把__slots__也提一嘴,因为它和内存机制强相关。自定义类默认带__dict__字典,每条属性都是一次字典 KV 存储,空间开销大;定义__slots__后,实例不再创建__dict__,属性被压缩成紧凑的固定槽位。对于几十万级对象实例,这往往能省 30%-50% 的内存,而且对象体积变小,GC 扫描成本也下降,算是一箭双雕。副作用是动态添加新属性会报错,这在某些动态接口场景需要权衡。

memoryview和缓冲区协议能避免大文件、大数组对象被反复复制,配合array模块或者 NumPy 时,能直接用底层缓冲而不产生大量临时对象。这是“减少临时对象创建”这一原则最典型的高性能落地手段。

对性能极敏感的高频路径,可以尝试不经过 Python 层而用ctypes或Cython在原生层管理内存,但这已经属于越级优化,绝大多数 Web 和数据处理场景完全不需要。

我认为这里有必要纠正一个认知边界:内存管理机制不是越懂就越要主动折腾。CPython 默认的自动平衡已经设计得相当好,引用计数负责让短期对象快速消失,分代 GC 负责清扫循环残留,日常业务代码里老老实实写清晰的引用关系、控制缓存的 data 增长、少在局部创建超大临时对象,比你想尽办法调 GC 参数更有效。

最近我把一个长期内存踩坡的数据服务做了两处改动,一是把缓存里的主键索引全部换成了弱引用字典,二是在初始化阶段启动后执行了一次gc.collect(2)再gc.freeze(),RSS 趋势直接从 12 小时翻倍变成基本平线。过程里最让我感慨的不是 API 本身多神奇,而是你终于能用gc.get_referrers()反向看到代码里每一处引用的走向,这种“看得见”的感觉,才是彻底解决内存问题的底气。

如果你正在排查一个棘手的 Python 内存问题,我的建议是:别去猜,先跑三个命令,tracemalloc抓热点,gc.get_stats()看代际,objgraph.show_growth()看类型增长。按这个顺序来,绝大多数问题都能在一个小时内定位到具体函数甚至具体一行代码。最后,记得把这次的观测脚本固化到项目的监控体系里,下次再遇到类似诡异问题时,你会庆幸自己有了一份从过去踩坑经验里沉淀下来的检查清单。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询