尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Python内存管理机制详解:引用计数、垃圾回收与分代回收

Python内存管理机制详解:引用计数、垃圾回收与分代回收 开头先说实话Python内存管理机制这个话题讲的人多讲透的人少。很多人写了两三年Python能说出“Python有垃圾回收”但问到底层是引用计数为主、垃圾回收为辅分代回收怎么分、什么触发、怎么排查内存泄漏多半就含糊了。这篇文章我就把这套机制从头到尾拆开——引用计数负责什么垃圾回收补什么漏它们在CPython里是怎么配合的以及实际编码里哪些写法会踩坑、怎么用工具排查。不管你是刚装好Python环境准备入门的新手还是已经在写爬虫、做数据分析、跑量化策略的进阶用户把这套机制搞明白都不亏。1. Python内存管理的整体设计为什么要两套机制同时存在1.1 先理解“万物皆对象”这个前提要聊内存管理得从Python的对象模型说起。你在Python里写的每一个整数、字符串、列表、函数、类实例底层都是一个对象。CPython用C语言实现了一套结构体来描述这些对象每个对象头部都有一个引用计数器和类型指针。也就是说Python的每次变量赋值、函数调用、容器操作本质上都是在操作这些对象以及指向它们的引用。这也解释了为什么同样一段逻辑Python往往比C慢C直接在栈上分配和回收内存Python则要为每个对象维护引用计数还要在适当的时候做垃圾回收扫描。这些操作都是有成本的。很多初学者抱怨“Python太慢”其实一半的锅要算在内存管理的动态性上。1.2 双轨机制的职责划分一个管“即时”一个管“兜底”CPython的内存清理策略简单说就是引用计数负责“即时回收”垃圾回收负责“处理残留”。两个机制不是重复劳动而是互补。引用计数的逻辑非常朴素每个对象上挂着一个计数器有多少个地方引用这个对象计数就是几。当计数变成0说明没有任何变量、容器、函数参数再指向它这个对象就没有存活的意义了内存当场释放。这种机制的好处是实时性好对象一没用就回收不会积压适合处理大量临时对象。但引用计数有个致命的盲区——循环引用。A引用BB引用A两边计数永远不为0对象就永远得不到清理。垃圾回收器GC就是为这个场景设计的。CPython的GC是一个分代回收器它会周期性地扫描对象之间的引用关系找出那些“互相引用但已经不可达”的孤岛把它们整体回收掉。可以这么理解引用计数是每天的保洁工垃圾回收是每周一次的大扫除。1.3 内存分配的底层链路在CPython里对象创建时有个专门的分配层叫pymalloc。它对小对象做内存池化管理把同类尺寸的小对象放进预先划分好的缓冲区里复用避免频繁调用系统malloc。比如你创建100万个字典这些小字典的内存会优先从池里拿释放时也不立刻还给操作系统而是留着复用。这套机制极大提升了小对象频繁创建销毁场景下的性能代价就是进程的内存占用看起来不那么“下降得干净”。而大对象通常指超过512字节的PyLong或某些buffer则会直接走系统malloc分配释放后操作系统可能会把内存拿回去但到底什么时候拿回去取决于操作系统的策略。所以说你看到Python进程内存占用高不一定是泄漏也可能只是pymalloc池子里的闲置内存没归还。这一点在后文排查部分会反复提到。2. 引用计数机制拆解从入门到踩坑2.1 引用计数到底是怎么变化的先说几个必知场景变量赋值、函数传参、容器添加元素、对象被其他对象持有引用计数都会增加反之离开作用域、容器删除元素、函数返回则减少。特别提醒sys.getrefcount()本身也会让引用计数临时1因为它在读取你的对象时也会产生一个临时引用。我拿一个最常见的例子来说import sys a [] print(sys.getrefcount(a)) # 输出2而不是1 b a print(sys.getrefcount(a)) # 输出3 c [a] print(sys.getrefcount(a)) # 输出4 del b print(sys.getrefcount(a)) # 输出3这里第一次输出2就是因为getrefcount函数内部的临时引用。容器持有引用这个点很关键——一个列表里塞进了另一个对象就是对这个对象加了一次引用。这意味着如果你把一个大对象塞进一个长期存在的全局列表里它就会被这个列表一直拽着没法释放。函数传参同理Python的参数传递本质是“对象引用的传递”进函数时计数1出函数时-1。有些同学一直搞不清楚“Python到底是值传递还是引用传递”从引用计数的角度就一目了然传的是引用但引用本身是按值传递的。2.2 循环引用一个经典而隐蔽的内存陷阱循环引用长什么样我随手写一个最常见的数据结构class Node: def __init__(self, value): self.value value self.parent None self.children [] root Node(root) child Node(child) root.children.append(child) child.parent root当root和child都离开作用域之后按照引用计数逻辑root的计数是1因为child.parent还指向它child的计数也是1因为root.children还指向它两个都大于0都得不到即时回收。但它们其实已经没人用了形成一块“自我抱团”的无法访问内存。这就是引用计数最大的软肋。在老版本Python里带__del__方法的对象如果卷进循环引用还会出现更麻烦的情况——对象破裂析构方法无法保证调用。这个问题在Python 3.4之前很让人头大3.4之后通过PEP 442解决了循环引用中finalizer的调用顺序问题。但即便如此也不要依赖__del__来做关键清理工作后面会展开讲。2.3 引用计数带来的性能隐忧与实践应对引用计数的每次增减都伴随一个操作这在创建和销毁大量临时对象时会放大。典型的场景是数据处理比如一个循环里反复做字符串拼接、反复构建中间列表。字符串是immutable的拼接一次就创建一个新字符串产生大量临时对象引用计数一路狂飙。我的习惯是能复用就复用能用生成器就不一次性构建大列表能用join就不做循环拼接。比如# 不要这样 s for item in items: s item , # 换成这样 s ,.join(items)这不仅仅是提升速度的问题还直接减少了内存分配和回收的频率。引用计数虽然是即时清理但架不住高频调用带来的开销。在写爬虫时每页解析会产生大量字符串和字典如果循环里积攒临时对象不及时释放内存峰值会非常难看。3. 垃圾回收器实战分代回收与gc模块的正确用法3.1 分代回收的原理为什么搞“代”CPython的GC不是每次都对所有对象做一次全量扫描那样太慢了。它把对象分成三代第0代、第1代、第2代。新创建的对象进入第0代某次回收后存活下来的对象升入第1代第1代回收后存活的再升入第2代。越是老代的对象越少被扫描因为能存活很久的对象大概率会继续存活没必要每次都检查。默认阈值是(700, 10, 10)。意思是第0代的对象数量超过700时触发一次第0代回收第1代回收每10次第0代回收触发一次第2代回收每10次第1代回收触发一次。你可以用gc.get_threshold()查看当前值用gc.set_threshold()调整。如果程序里非常频繁地产生临时对象第0代回收会被频繁触发这时候适当调高阈值能减少扫描次数但代价是垃圾存活时间变长。这个权衡要看场景。3.2 常用gc接口和手动处理的时机gc模块有几个接口值得熟练掌握import gc # 手动触发一次回收 gc.collect() # 查看当前每代的对象数量 gc.get_count() # 查看所有被GC跟踪的对象数量 len(gc.get_objects()) # 调整阈值 gc.set_threshold(1000, 15, 15)什么时候该手动gc.collect()我自己遇到最多的是两类场景。一类是批量任务跑完后内存不降。比如一个函数做了一个大数据集的处理生成了大量循环引用对象函数返回后光靠引用计数释放不掉需要一次gc.collect()来兜底。另一类是程序空闲期比如Web框架接收完一个请求、爬虫抓完一批页面后此时GC一次对用户体验影响小内存也能清爽很多。但要克制。gc.collect()是全量扫描调用频率过高反而坑性能。有的同学喜欢在每次循环结束都collect一次这其实违背了分代回收的设计初衷——它已经帮你把扫描频率优化过了你再去打断它的节奏只会增加无谓开销。3.3__del__到底能不能用怎么用很多从C转过来的同学习惯在Python类里写__del__来释放资源这个做法在CPython下很危险。原因在于引用计数为0时__del__确实会被调用但如果对象卷入了循环引用调用时机就变得不确定更麻烦的是在GC过程中__del__可能会异常导致对象进入gc.garbage列表挂着不释放。我在实际项目中见过一个案例某个类里写了__del__去关闭数据库连接结果对象互相引用程序结束前大量连接没被关闭数据库连接数直接打爆。后来我统一改用上下文管理器class DBClient: def __init__(self, conn): self.conn conn def close(self): self.conn.close() def __enter__(self): return self def __exit__(self, *args): self.close()这样至少能保证在with块结束时资源必被释放不依赖GC或者引用计数的触发时机。资源清理这类事情显式永远比隐式靠谱。4. 内存泄漏排查工具、流程与实战速查4.1 别急着下“泄漏”结论先区分真实原因进程内存只涨不降第一反应别是“泄漏”。前面说了pymalloc池会保留闲置内存Python进程占用内存不立刻下降很正常另外操作系统的内存分配器对已释放大块内存的复用策略也不是即时的。要判断是不是真泄漏可以看内存是否持续、无上限地增长而不是锯齿状跳高。真正要警惕的泄漏模式我见过这些全局容器无限增长比如缓存字典只加不删。类的类属性存了实例实例没被清理但类属性一直持有引用。回调函数闭包里捕获了大对象导致大对象随回调长期存活。线程ThreadLocal里存的对象没有清理。日志Handler打开了文件但从不关。4.2 用objgraph和tracemalloc定位对象去向排查工具里objgraph和tracemalloc是两把好手。objgraph可以画对象引用图tracemalloc可以追踪每一行代码申请的内存。先看objgraph的典型用法import objgraph # 统计当前内存中某类对象的数量 objgraph.count(list) # 找到某个类的所有实例和它们的引用链 objgraph.show_refs([my_obj], filenamerefs.png) # 观察内存增长点 objgraph.growth(limit10)objgraph.growth()是我用来排查泄漏的第一步打印哪些类型的对象数量在持续增加如果有某一个自定义类或者字典对象的数量一直在涨基本锁定了泄漏源然后show_refs看到底是谁拉着这些对象不放。tracemalloc适合定位“哪一行代码分配了大内存”import tracemalloc tracemalloc.start() # 运行你的业务代码... current, peak tracemalloc.get_traced_memory() print(f当前使用: {current / 1024 / 1024:.2f} MB) print(f峰值使用: {peak / 1024 / 1024:.2f} MB) snapshot tracemalloc.take_snapshot() top_stats snapshot.statistics(lineno) for stat in top_stats[:10]: print(stat)那个statistics(lineno)输出的结果里能看到每个源文件具体哪一行代码占用了多少内存。这个方法定位到问题代码行特别有效比靠肉眼翻代码快太多了。4.3 巧妙使用weakref打破引用持有很多循环引用和缓存场景其实可以靠弱引用weakref从源头解决。弱引用不会增加对象的引用计数也就是说它“指向”一个对象但不阻止对象被回收。如果对象被回收了弱引用会自动变成None或失效。比如做一个缓存字典之前很多人用全局dict数据越攒越多。换成WeakValueDictionary之后缓存里存储的值不再强引用对象等对象被外部释放了缓存里对应的条目也会自动消失不用手动清import weakref class Foo: pass cache weakref.WeakValueDictionary() a Foo() cache[key] a del a # 对象被回收 print(cache.get(key)) # None我自己做观察者模式、事件回调注册的时候都优先考虑weakref这能从设计层面把内存泄漏问题消解掉一大半。4.4 常见问题速查表这里整理一份我自己实践中最常见的内存问题速查表方便大家对着查症状可能原因定位方法解决办法内存持续上涨数值不回落全局容器不断累积objgraph.growth()看对象增量定期清理或改用weakref容器对象数量暴增但代码没明显问题循环引用阻碍引用计数回收gc.get_referrers()看引用链手动gc.collect()或优化对象结构某类大对象从图片/数据加载后无法释放闭包或回调持有引用objgraph.show_refs()画引用图断掉闭包持有改成弱引用程序结束后连接数不释放资源类对象用__del__检查类里是否有__del__改用上下文管理器显式关闭内存看起来只升不降但函数正常返回pymalloc池和OS内存缓冲tracemalloc看真实分配量无需处理或通过gc.collect()部分归还5. 工程实践在不同场景下的内存策略与优化心得5.1 爬虫与批处理场景批量任务的内存峰值控制爬虫和批处理任务有个共同特点——循环里反复创建对象如果处理逻辑写得粗糙内存峰值很容易失控。比如批量抓取网页每页解析出的DOM对象、正则匹配的结果、临时列表都会在循环迭代中堆积。我的做法是能迭代就不存储能流式就不全量。解析完一个网页之后数据入库或写入文件然后让局部变量自然退出作用域。如果任务实在太重我还会在每完成一批比如1000条之后加一次gc.collect()并配合日志输出当前内存占用。这个“批次手动回收”的组合在大量实战里都很稳内存曲线可以压得很平。注意不要每一条都collect那样性能损耗太大。5.2 数据分析与量化策略善用对象池和向量化做量化策略或者数据分析时最常见的坑是一次性把全量数据加载进内存然后产生大量中间副本。比如Pandas里反复apply、merge每次都产生新DataFrame旧对象要靠GC回收内存峰值自然高。能用numpy向量化运算就不要循环能原地操作就不要创建副本。另外像一些频繁实例化的小对象——订单对象、K线对象——可以考虑用对象池复用。对象池的原理不复杂提前创建一批对象用完放回去下次取来用核心目的就是减少对象创建和GC带来的开销。这个技巧在低延迟场景收益特别明显。5.3 CPython与其他Python解释器的GC差异最后聊一个容易混淆的点本文讲的都是CPython的行为。如果你用的是PyPy它的垃圾回收机制完全不同——PyPy默认使用一种基于追踪的GC类似JVM的G1它没有引用计数对象何时回收完全由GC决定。这其中最大的差别是在CPython中引用计数为0立刻释放你可能很依赖这个特性但在PyPy中你需要确保不写依赖“对象马上被回收”的代码。另外gc.disable()只关闭分代回收不关闭引用计数。有些性能优化文章鼓吹全局禁用GC我实测下来在纯计算脚本里确实能挤一点性能但代价是一旦出现循环引用内存会越积越多风险极高。我的建议是不要全局禁用分代回收最多在局部热点代码周围临时关闭结束之后再打开。这种“定点关闭”的方式兼顾了性能和安全。5.4 我总结的一套内存体检清单每次排查内存问题我基本按这个顺序来先用tracemalloc确认是不是线性增长排除pymalloc误判。再用objgraph.growth()看哪些类型对象在涨定位方向。用gc.get_referrers(obj)把对象的引用者挖出来看谁在“抱团”。最后针对问题设计修复优先考虑弱引用、上下文管理器、批量手动回收。这套流程我用了很多年几乎没有解决不了的内存问题。工具不在多关键是先分清“正常占用”和“异常增长”再去揪那个真正持有引用不放的对象。写到最后说一点个人体会Python的内存管理机制虽然复杂但它不是靠背概念就能理解的东西最好的方式是真的写出一个循环引用、真的用objgraph画一次引用图、真的看一次内存泄漏现场。只有见过一次“不释放”的对象有多顽固你才会对引用计数和GC的协作关系有切身体感。以后能记住的不是你背过多少阈值参数而是你亲手排查问题时踩过的那个坑。
返回列表