news 2026/10/22 13:37:47

Python内存管理探秘:从引用计数到垃圾回收与内存池

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Python内存管理探秘:从引用计数到垃圾回收与内存池

你有没有遇过这种场景:一个Python服务跑上一整天,内存像温水煮青蛙一样一点点往上爬,重启之后立刻恢复正常,可业务代码翻来覆去也找不到哪里“泄漏”了?我第一次认真研究这个问题,是在一个常驻调度服务里——每3秒跑一轮任务,内存稳步上涨,查了半天IO句柄、日志堆积、外部连接,最后发现真正的元凶根本不是业务代码,而是我对Python内存管理机制的几个基础认知出现了偏差。

这篇想聊聊CPython里的三件事:引用计数、垃圾回收(GC)、内存池。它们共同决定了“对象什么时候死”“死了以后内存去哪了”“为什么内存看着高却未必是泄漏”。也会带上我用gc模块、objgraph、tracemalloc排查内存增长的实际套路。适合写过较长驻服务、被内存问题折磨过,或者单纯想理解Python内存原理的开发者。

1. 引用计数:Python默认的“自动释放”引擎

先强调一个大前提:引用计数不是Python语言规范强制要求的,是CPython解释器的核心实现。PyPy、Jython等实现用的是另一种垃圾回收策略。所以下面所有内容都限定在“CPython 3.x”这个语境下讨论,这也是绝大多数人实际使用的环境。

CPython里每个对象都是PyObject结构体,开头两个核心字段:引用计数ob_refcnt和类型指针ob_type。引用计数就是对象的“存活名片”,它记录着当前有多少个变量、容器、临时位置指向这个对象。计数一旦归零,对象立刻走析构流程并释放内存。

1.1 从对象创建看引用计数的增减规则

引用计数听着简单,但实际增减点很多,最容易漏的是“放入容器”和“函数传参”这两类。我整理了一张表,覆盖了常见场景:

场景对引用计数的影响
新对象创建,变量指向它计数从0变1
另一个变量引用同一对象+1
对象作为参数传入函数+1
函数返回,局部变量生命周期结束对应局部变量的引用-1
对象放入列表/字典/集合+1
对象从容器中移除-1
变量被重新赋值成其他内容旧对象计数-1
del某个变量对应对象计数-1

代码验证最直接:

import sys obj = [1, 2, 3] print(sys.getrefcount(obj)) # 2:变量obj的引用 + 传给getrefcount时的临时引用 ref = obj print(sys.getrefcount(obj)) # 3 del ref print(sys.getrefcount(obj)) # 2

注意sys.getrefcount本身就有一个临时参数引用,所以结果会比“真实引用数”多1。另外,如果你在交互式环境或调试器里跑这段代码,输出往往会更大,因为工具的变量查看、历史记录也会持有对象。这时候别慌,看增减趋势比看绝对值有意义。

1.2 引用计数归零:立即销毁与__del__触发

和其他语言的垃圾回收相比,CPython的引用计数有一个非常鲜明的特点:确定性释放。意思是只要引用计数降到0,对象马上被回收,不需要等某个GC周期。

class Demo: def __del__(self): print("Demo 被回收") d = Demo() del d # 立刻打印 “Demo 被回收”

这里有个经验陷阱:在IPython或Jupyter里跑,del d可能不会立刻打印,因为交互环境会把变量存进历史记录,相当于多了一个看不见的引用。真实业务代码里如果确认没有其他引用,释放时机是可以预期的。

1.3 引用计数的代价:GIL下的线程安全与性能损耗

引用计数不是免费的。每次赋值、函数调用、容器操作都要做计数加减,还要检查是否归零,这本身就是一笔持续的开销。更麻烦的是多线程:不同线程可能同时操作同一个对象的引用计数,加减操作必须原子化。CPython选择用GIL统一保护引用计数,这也是GIL很难被彻底移除的原因之一——一旦去掉,引用计数的线程安全就是个巨大问题。

所以你会发现,纯Python代码在频繁构造小对象时会很吃力。优化思路通常也在这里:减少临时对象创建、复用结构、尽量用内置的高效容器。

2. 循环引用:引用计数解决不了的那道坎

引用计数很直接,但有一个结构性问题:它只能管“引用链没有闭环”的情况。一旦对象之间互相引用,计数就永远减不到零。

2.1 环是怎么形成的

写一个最经典的例子:

class Node: def __init__(self): self.parent = None self.children = [] a = Node() b = Node() a.children.append(b) # b 的引用计数 +1 b.parent = a # a 的引用计数 +1

此时a和b互为引用,即使把外界对a、b的引用都删掉,两者的计数仍然是1。程序退出前,这组对象既无法被引用计数回收,也确实不再是垃圾?不是,它们已经不可达了,但它们互相把对方“撑住”了。

并不是所有对象都会进入GC的视野。像整数、字符串这种不能引用其他对象的类型,不需要被追踪;只有可能形成引用网的容器、自定义实例才需要。可以用gc.is_tracked验证:

import gc class Node: def __init__(self): self.children = [] n = Node() print(gc.is_tracked(n)) # True,实例的__dict__可能引用其他对象 print(gc.is_tracked(42)) # False,整数无法引用他人

GC的追踪对象是垃圾回收的工作范围,引用计数是“即时释放”,这两个机制是互补的。

2.2 标记-清除:从根开始找仍然活跃的对象

GC面对循环引用的思路不是“数引用”,而是“画地图”。它从根集合出发——模块全局变量、当前运行栈上的局部变量、调用帧等——沿着每个对象对外的引用一路遍历,把所有仍可达的对象标记为“活着”。堆上没有被标记的对象,全都视为垃圾,统一回收。

这个区别很关键:垃圾回收里的“垃圾”定义是“从根不可达”,而不是“引用数为0”。引用计数是从个体视角数“谁引用我”,标记-清除是从整体视角判断“我还能不能从根被发现”。

我常用的类比是:引用计数像挨家挨户问“你们家有没有人记着隔壁老王”,标记清除则是从居委会的名单出发,把所有还能联系上的人画在一张通讯录里,名单之外的人直接销户。两种思路各有代价:

维度引用计数标记-清除(GC)
触发时机引用变化时即时处理分代达到阈值后扫描
能否回收循环引用不能能
释放确定性即时、可预测有延迟,可能停顿
主要开销每次赋值、传参都在做计数全堆扫描与标记

2.3 PEP 442:Python 3.4之后循环引用回收更可靠了

这里有个老版本的大坑:Python 3.4之前,如果循环引用里的对象定义了__del__,GC会因为无法确定终结器的调用顺序而拒绝处理它们,把对象一股脑丢进gc.garbage列表,需要开发者手动清理。很多老教程至今还在教“用gc.garbage判断泄漏”,那其实是旧时代的产物。

Python 3.4引入PEP 442,实现了更安全的终结机制:收集器会对需要执行__del__的对象做排序,再逐个安全调用,带终结器的循环对象也可以被回收了。所以现在排查内存问题,基本不需要再过度担心“__del__导致循环引用无法回收”这件事。但__del__本身仍有其他坑,这个放到第六章细说。

既然GC能处理循环引用,那是不是可以抛弃引用计数、全面改用标记清除?不行。标记清除需要扫描整个堆,频繁全量扫描会卡顿。于是CPython又引入了分代机制。

3. 分代垃圾回收:为什么年轻对象要先被照顾

分代回收的核心来自一条实践统计:绝大多数对象创建之后很快就死了。程序里大量临时对象存活时间极短,只有少数对象能活过几次回收。既然如此,就没必要每次都扫描全部对象,把精力集中在“最可能产生垃圾”的年轻对象上最划算。

3.1 分代假设与默认阈值:新对象为什么频繁被扫描

CPython把内存中的GC追踪对象分成三代:0代、1代、2代。默认阈值是(700, 10, 10),触发逻辑是:

  1. 每一代记录“分配对象数减去释放对象数”,当0代的这个差值超过700,触发一次0代收集。
  2. 0代收集后仍然存活的对象晋升到1代。
  3. 0代收集每完成10次,触发一次1代收集。
  4. 1代收集每完成10次,触发一次2代收集。

所以2代收集的间隔大致是“新增对象数约70000个”的水平,比0代宽松得多。年限越大的对象,被扫描的频率越低。

可以用gc.get_count()看当前各代的状态:

import gc print(gc.get_threshold()) # (700, 10, 10) print(gc.get_count()) gc.collect() print(gc.get_count())

get_count()返回的元组含义是:当前0代累计变更数、距离上次0代收集的0代收集次数、距离上次1代收集的1代收集次数。看着有点绕,但配合阈值就能理解“下一代收集还要多久触发”。

3.2 手动回收与阈值调参:什么情况下需要动手

大部分脚本用默认阈值就够,但有些场景值得手动干预。

场景一:程序刚启动时一次性加载了海量数据、创建了大量临时对象,之后进入稳定期。这时0代收集频繁触发,过去的临时对象还会被晋升到1代甚至2代,造成无谓扫描。可以考虑在启动加载完成后主动调用gc.collect(),把“不再会变化”的一次性数据清理掉。

场景二:某个批量处理窗口内短期创建了几百万个小对象,GC频繁触发导致卡顿。可以临时调高0代阈值,甚至在这个窗口内gc.disable(),结束后再gc.collect()。但长驻服务里绝对不要长期禁用GC,否则循环引用会越积越多。

阈值调多少算合理?我的建议是先开gc.set_debug(gc.DEBUG_STATS)观察一段时间,看每次收集的对象数量与耗时分布,再基于数据调整。盲目把0代阈值调到几万,很可能换来“每次收集卡顿几百毫秒”的代价。

4. 内存池与空闲缓存区:回收之后内存去哪了

引用计数把对象“释放”了,GC把循环垃圾“回收”了,然后呢?内存真的返回操作系统了吗?答案往往是否定的。

4.1 pymalloc:小对象为什么不要走系统malloc

CPython在系统malloc之上又实现了一层内存池,专门处理小对象分配。以512字节为分界线:小于等于512字节的请求由pymalloc管理,超过512字节的大对象才交给系统malloc直接分配。

pymalloc的逻辑很像批发市场。如果每个小对象都直接找系统要内存,每次都要发起malloc系统调用,频繁申请小块还很容易产生碎片。pymalloc会一次性从系统申请较大块区域,再切分成固定大小的“池子”(pool),池子内部再按统一规格切成小块。对象释放后,内存块留在池中复用,下次分配同类大小对象时直接取走,不再打扰操作系统。

这种设计让大量小对象的分配和释放速度明显提升,代价是这些内存不会立刻还给OS。从进程外部看,RSS会维持在一个偏高水平。

4.2 free list缓存:为什么内存占用常常“降不下来”

pymalloc只管内存块的复用,而一些内建类型还在自己的类型结构里维护了“空闲对象链表”。比如float、tuple、list、dict在释放对象时,会把有限数量的对象放在内部缓存里,下次创建同类型对象时直接复用。这样做是为了加速同类高频操作,同时减少了重复初始化的开销。

带来的直接现象是:Python进程在某些操作后内存上涨,之后即便手动gc.collect(),RSS也可能一点不降。因为那些内存都被缓存留着复用了,不是泄漏,只是“舍不得还”。

判断是缓存还是泄漏,最快速的办法是:脚本里跑一次gc.collect(),对比前后RSS,再继续观察一段时间,看内存会不会持续爬升到新高点。如果每次collect后都能回到差不多的基线,基本是缓存+回收滞后;如果基线本身一路抬高,才值得认真追查。

4.3 快速判断实验:RSS与gc.collect

import gc import resource import sys def rss_value(): # Linux 的 ru_maxrss 单位是 KB,部分平台是字节,跨平台比较先换算成字节 val = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss if sys.platform == "darwin": return val # macOS 下是字节 return val * 1024 # Linux 下是 KB print("collect 前 RSS:", rss_value()) n = gc.collect() print("本次收集对象数:", n) print("collect 后 RSS:", rss_value())

这个实验我建议在业务平稳期做,不要在业务高峰做,否则污染数据。连续采样几次,配合时间线看趋势,才有参考价值。

5. 排障实操:用gc、objgraph、tracemalloc定位内存增长

理论部分讲完,进入实战。我排查内存问题的套路基本固定:先确认是不是GC能回收的垃圾,再统计对象增长,最后追分配源头和引用链。下面按顺序铺开。

5.1 第一步:排除Python缓存,建立基线

不要一上来就怀疑泄漏。先完成三个动作:记录基线RSS、手动gc.collect()、再次记录RSS。如果collect后RSS回落明显,说明一部分对象确实已经沦为垃圾只是回收滞后;如果collect后RSS纹丝不动,说明内存被某些“活的引用”占着。

在长驻服务里,我会在代码里临时加一个监控函数,每小时记录RSS和gc.get_count(),跑一整天再画出来看趋势。曲线能告诉你的信息比任何工具都多:是持续线性上涨(疑似引用链漏清理),还是锯齿状上涨(疑似GC滞后),还是阶梯式上涨(疑似缓存被批量填充)。

5.2 gc三板斧:get_objects、get_count、set_debug

gc.get_objects()返回当前GC追踪的所有对象。注意,它不包含整数、短字符串这类不需要追踪的对象,所以别拿它当“全量对象清单”。统计对象类型分布是常规操作:

from collections import Counter import gc gc.collect() counter = Counter(type(obj).__name__ for obj in gc.get_objects()) for name, count in counter.most_common(15): print(name, count)

如果对比两个时间点的快照,某个类型数量疯涨,线索就出现了。比如Node从1000涨到100万,方向基本锁定。

gc.set_debug(gc.DEBUG_LEAK)可以开启详细的回收日志,输出哪些对象被回收、哪些无法回收。这是排查“为什么某个对象死不掉”的好帮手:

import gc gc.set_debug(gc.DEBUG_LEAK) # 跑一段触发回收的业务逻辑 gc.collect()

日志里会打印gc: collectable <Node object at 0x...>之类信息。一旦看到大量疑似不该存在的对象出现在输出里,就去看看谁一直在引用它。

还有一个小众但好用的API:gc.freeze()。在multiprocessing用fork创建子进程后,子进程会继承父进程全部对象引用,如果服务跑了一段时间才fork,子进程的GC扫描负担会很重。在fork之后调用gc.freeze(),可以冻结当前对象,避免它们进入后续代的追踪范围,能显著加快子进程的GC过程。

5.3 objgraph:把对象引用链画出来

gc能告诉你“有哪些对象”,但回答不了“谁在引用它”。objgraph正好补这块短板,它可以用Graphviz生成引用关系图。

pip install objgraph

在代码里简单使用:

import objgraph # 查看最近内存里增长最快的前10类对象 objgraph.show_growth(limit=10) # 对某个可疑对象画引用链 objgraph.show_refs([suspicious_obj], max_depth=5, filename="refs.png")

show_refs生成的图里,你能直观看到目标对象被哪个根对象或中间对象“拿住”了。我通常这么用:先用show_growth找增长最异常的类型,再在业务代码的稳定期拿到一个该类型实例,最后show_refs定位引用路径。视觉化面对复杂引用网时,效率远高于人肉翻代码。

5.4 tracemalloc:定位对象分配的真实源头

objgraph解决“谁持有它”,tracemalloc解决“谁创建了它”。这是Python 3.4+内置的模块,能精确到“哪个文件哪一行代码分配了这个对象”。

import tracemalloc tracemalloc.start(25) # 保留更深调用栈 # 运行需要观察的业务逻辑,或者部署后运行一段时间 snapshot1 = tracemalloc.take_snapshot() # 继续运行 snapshot2 = tracemalloc.take_snapshot() for stat in snapshot2.compare_to(snapshot1, "lineno")[:15]: print(stat)

输出的stat会显示文件路径、行号、内存增量和分配次数,例如/usr/local/app/service.py:128: size=18.2 MiB, count=34000。看到这个就很容易定位到具体模块。

注意:tracemalloc有一定性能开销,生产环境不建议全景长期开启。我通常只开一个短时间窗口,比如在服务刚启动未接入流量时tracemalloc.start(),运行半小时后做一次snapshot对比,足够抓到大多数热点。

6. 规避内存泄漏:三个值得记住的坑

很多“内存泄漏”其实不是Python的GC不工作,而是业务设计上让对象一直从根可达。下面这几个坑,我都在真实项目里踩过。

6.1 坑一:全局缓存持有“该淘汰却永不淘汰”的对象

假设有一个全局字典缓存,键是数据版本,值是加载后的大数据对象。数据对象内部又把自己放进了某个容器,形成循环引用。循环引用本身不是问题,问题是全局字典作为根,让这些对象永远可达,GC根本不认为它们是垃圾。

这种问题的根不在循环引用,而在缓存没有淘汰策略。改用弱引用缓存,或者给缓存加条数上限、时间过期,才是正向解法。否则无论怎么调GC阈值都无济于事。

6.2 坑二:闭包与回调隐式持有大对象

闭包会捕获外部变量,捕获的变量又可能牵住一整条引用链。比如某个定时任务模块注册了一个回调函数,回调内部引用了业务对象,业务对象又引用了超大历史数据。定时器一直注册着回调,就等于每条引用链都被根对象长期持有。

排查思路:event系统的回调列表里找“看起来没清理”的注册项;异步任务队列里检查是否有任务长期排队持有数据。对需要长期存在的订阅关系,尽量让回调只持有必要的小对象,大对象通过weakref.ref或ID在真正使用时再去取。

6.3 坑三:__del__里的意外与弱引用的正确姿势

__del__有两个麻烦:一是执行时机不可预测,尤其对象在循环引用中时,回收可能延后到GC周期;二是在__del__里抛异常,解释器会打印到stderr并吞掉异常,很容易掩盖资源未释放的问题。

终结逻辑的正确姿势是尽量try/finally包起来,把真正的资源清理放到上下文管理器而不是__del__。如果只是想“缓存一份对象但不想阻止它被回收”,弱引用是更安全的方案:

import weakref class HeavyData: pass cache = weakref.WeakValueDictionary() def get_data(key): data = cache.get(key) if data is None: data = HeavyData() cache[key] = data return data

弱引用不增加对象的引用计数,所以对象不会再因为缓存而“想死都死不掉”。对象真的被回收后,对应的弱引用条目也会自动失效,下次访问时返回None并重新创建。这是大对象缓存最推荐的实现方式之一。

讲完这些,我自己的实际感受是:排查内存问题最忌讳“怀疑GC不干活”。多数时候GC都没问题,是从根可达的一条引用链把一个本该销毁的对象死死拽住。按“先跑gc.collect()看回落,再看对象类型增长,最后trace分配源头”的顺序走一遍,基本都能收敛到具体模块。把这些机制记清楚,内存问题就谈不上玄学了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/21 19:01:22

Scikit-learn入门实践:鸢尾花分类模型构建全流程

1. 项目概述与思路拆解1.1 为什么用Scikit-learn开启机器学习我不止一次在社区里看到有人提问&#xff1a;学机器学习到底应该从框架开始&#xff0c;还是从数学推导开始&#xff1f;我的答案一直很明确——先跑通一个完整的模型&#xff0c;再回头补理论。Scikit-learn就是最适…

作者头像 李华
网站建设 2026/10/22 13:36:42

AI自主渗透工具的技术原理与金融防御实战

1. 项目概述&#xff1a;当AI不再只是防御工具&#xff0c;而是主动发起渗透的“数字特工”最近几周&#xff0c;韩国多家银行和证券公司的内部系统接连出现异常登录、敏感数据批量导出、交易指令被篡改等现象。这些攻击并非传统意义上的“撞库”或“钓鱼邮件”&#xff0c;而是…

作者头像 李华
网站建设 2026/10/18 11:09:28

Python在金融科技中的落地实践:从量化策略到风控工程

这两年聊起Python&#xff0c;几乎绕不开金融科技四个字。行情分析、因子回测、实时风控、客户画像、支付结算……凡是和数据密集、逻辑复杂的金融业务沾边的环节&#xff0c;基本都能看到Python的身影。我自己在几家团队里见过不同语言背景的人聚在一起做FinTech项目&#xff…

作者头像 李华
网站建设 2026/10/18 0:27:18

任务式对话系统核心:自然语言理解与意图槽位抽取实战

1. 从一次生硬的对话聊起去年我做了一个智能客服项目&#xff0c;客户提的第一句话是“我要查一下上个季度的库存报表”。系统回答&#xff1a;“请问您要查哪个季度的&#xff1f;”。客户当场觉得这不是智能&#xff0c;这是智障。问题就出在自然语言理解这一环。我相信很多做…

作者头像 李华
网站建设 2026/10/19 14:35:15

AI调用成本优化:Skill封装与缓存策略降低token消耗

1. 从一次账单异常说起&#xff1a;为什么你的AI调用成本总比预期高上个月帮一个做数据报表自动化的朋友看他的API账单&#xff0c;发现一个很典型的现象&#xff1a;他每天跑批处理任务&#xff0c;输入内容其实高度重复——同一份数据字典、同一套字段说明、同一段业务规则&a…

作者头像 李华
网站建设 2026/10/22 13:24:00

TensorFlow 2.0+ Keras深度学习入门实战:从零构建图像分类模型

深度学习这几年确实火&#xff0c;但真正想动手的人往往卡在第一步&#xff1a;资料太多、版本太杂、demo跑不通。我自己刚接触TensorFlow时也踩了不少坑&#xff0c;特别是1.x和2.x交替那阵子&#xff0c;照着老教程写出来的代码全是红的。后来痛定思痛&#xff0c;换到Tensor…

作者头像 李华