ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

Python内存管理核心机制:引用计数与垃圾回收深度解析

2026/10/7 17:26:56 拓冰建站 浏览量
Python内存管理核心机制:引用计数与垃圾回收深度解析 1. 内存管理到底在管什么先理解Python的对象本质如果你写Python写过一段时间大概率遇到过MemoryError、进程被系统杀掉、或者程序越跑越慢最后卡死的情况。这时候大部分人的第一反应是代码写错了却很少有人往更深一层想——Python到底是怎么管理内存的为什么同一个程序用Java写和用Python写内存表现完全不同我在一线写了十来年代码刚开始用Python的时候也踩过不少坑后来把CPython的源码翻了一遍才算是把内存管理这件事弄明白了个七八成。简单说Python的内存管理机制核心就是两样东西引用计数和垃圾回收。前者管着对象什么时候该销毁后者管着循环引用这种引用计数管不了的情况。很多人觉得内存管理是解释器的事跟我写代码有什么关系这个想法是错的。Python里大部分莫名其妙的bug比如对象没有按预期释放、程序内存只涨不跌、__del__方法不执行、弱引用失效……根子都在内存管理机制上。搞懂了它你才算真正控制了你的Python程序而不是被解释器牵着鼻子走。这一篇我打算把内存管理的底层逻辑拆开讲透包括引用计数是怎么工作的、垃圾回收器是怎么诞生的、分代回收是怎么设计的、以及你在实际项目中会遇到的那些和内存有关的坑。内容偏底层但我会尽量用大白话和实际代码把它讲清楚。2. 引用计数Python内存管理的绝对主角2.1 每个Python对象的隐形计数器先想一个问题你在Python里写一句a [1, 2, 3]这行代码背后发生了什么从内存角度看解释器干了两件事第一在堆上分配一块内存创建了一个列表对象第二把这个对象和变量名a关联起来。关键是这个列表对象内部藏着一个计数器记录着我被谁引用了多少次。这个计数器在CPython的源码里就是PyObject结构体里的ob_refcnt字段一个Py_ssize_t类型的整数。import sys a [1, 2, 3] print(sys.getrefcount(a)) # 输出多少取决于环境通常是2这个getrefcount会临时把引用计数加1再返回所以你在脚本里看到的数字会比实际引用数多1。这个细节搞清楚了后面很多东西就顺了。Python的引用计数规则用一句话概括每个对象维护一个引用计数当有新的引用指向它时计数加1当引用被删除时计数减1当计数归零时对象立即被销毁其占用的内存立即被释放。哪些操作会导致引用计数增加给变量赋值、把对象加入列表、把对象作为参数传给函数、把对象赋值给类的属性……这些操作本质都是让某个地方指向这个对象。反过来del删除变量、函数返回、容器被销毁、重新赋值指向别的对象……这些操作会让计数减少。import sys a [1, 2, 3] sys.getrefcount(a) # 2一个来自a一个来自getrefcount临时引用 b a sys.getrefcount(a) # 3b也指向了同一个对象 del b sys.getrefcount(a) # 2b的引用没了计数回到2这里有个很重要的点b a并没有复制列表它只是让b和a指向了同一个内存地址。这也是Python传递对象时的默认行为——传引用而不是传值。你写c a以为创建了一个副本结果改c的时候a也跟着变新手经常被这个坑到。真正需要副本的时候得用copy模块这其实也和引用计数机制有关系因为对象是共享的计数器才能维持正确的数值。2.2 引用计数的人情世故INCREF和DECREFCPython在操作对象引用的地方到处都藏着两个宏Py_INCREF和Py_DECREF。Py_INCREF就是把计数加1Py_DECREF就是减1。听起来没什么了不起的但这俩宏定义了Python对象生命周期的基本节律。Py_DECREF执行的时候会比较当前计数和0如果发现归零了就调用对象的tp_dealloc函数把对象销毁把内存释放回内存分配器。也就是说CPython默认采用即时回收策略对象一旦没人引用了立刻死内存立刻还。这是引用计数机制最大的优点实时性好没有什么时候回收的悬念对象死了就死了内存马上回来。但引用计数也有它的难处——它管不住循环引用。你写一个双向链表每个节点持有前一个节点和后一个节点的引用或者写一个类实例A持有B实例B持有A。这种情况下A和B的引用计数永远不为零因为它们互相指着对方外人已经碰不到它们了它们却还占着内存。这就是引用计数机制的死角。我举个例子你就明白了class Node: def __init__(self, name): self.name name self.next None a Node(A) b Node(B) a.next b b.next a del a del b这段代码执行完之后两个Node对象的引用计数各自还是1——a.next还指着B的对象b.next还指着A的对象。但它们对外已经不可见了程序再也拿不到这两个对象的引用。如果没有额外的机制介入这两个对象就会永远留在内存里下一个A下一个B最终内存被堆满。这就是为什么Python还需要一个垃圾回收器。引用计数管正常死亡垃圾回收管互相纠缠着死的死循环。2.3 引用计数的代价Python为什么慢说句公道话引用计数机制是有代价的。每一次引用操作都要修改计数器增量和减量都必须保证线程安全解释器内部到处是加锁和原子操作的开销。这就让Python在对象操作上天然比JVM这类不维护精确引用计数的语言要慢一些。JVM的GC策略是先不管等内存不够了统一来回收平时对象之间的引用关系根本不需要频繁记录和更新所以Java在对象的创建和销毁上反而能更快。但代价是垃圾回收的时候会有停顿而且因为不知道对象被谁引用它需要扫描整个引用图。Python的选择是平时就管好引用计数保证对象生命周期精确可控垃圾回收只处理少数残局。这两种策略没有绝对的好坏Python的设计其实是冲着开发效率和内存确定性去的——程序员写的with语句、上下文管理器、文件操作底层依赖的都是离开作用域计数归零立刻清理这个精确的机制。3. 垃圾回收器专治循环引用的救火队3.1 分代回收背后的80/20法则CPython的垃圾回收器核心策略是分代回收。它把所有的容器对象注意是容器对象分成三代、两代、一代你也可以理解成童年的、青年的、老年的。解释器里维护着三条链表分别存放这些对象。为什么要分代这里面有个统计规律大部分Python对象生命周期非常短很快就会被回收活得过一段时间的对象很可能还要存活很长时间。就好比一个公司的员工试用期内的离职率高但干满三年的老员工一般会继续待很久。分代回收的假设就是老对象不太容易死所以老对象不用频繁扫描把精力集中在新生代上。我画个简单的流程第0代新生代里对象最多回收也最频繁。每次第0代回收触发后活下来的对象被晋升到第1代。第1代累积到一定数量再回收活下来的晋升到第2代。第2代回收频率最低因为里面的对象普遍是老不死的。你可以在代码里看这些参数import gc # 查看每代的阈值通常是 (700, 10, 10) print(gc.get_threshold())这个元组的含义是第0代的对象数量达到700时触发一次第0代回收第0代回收每执行10次触发一次第1代回收第1代回收每执行10次触发一次第2代回收。你可以把这三个数字理解为第二代回收频率最低第一代居中第0代最频繁。这个设计非常像JVM里的新生代和老年代只不过CPython的实现朴素得多没有复杂的标记复制、没有记忆集这种结构就是三条链表加上简单的阈值计数。3.2 三色标记法回收器怎么发现垃圾对象垃圾回收器怎么判断一个对象是否可达这里的标准答案叫三色标记法。把对象分三种颜色白色还没被扫描到疑似垃圾。灰色已经知道是可达的但它引用的对象还没全部检查完。黑色可达并且它引用的对象都已经检查完。回收器从根节点全局变量、调用栈、寄存器等出发把直接可达的对象标记为灰色然后逐个处理灰色对象把它引用的对象标记为灰色同时把自己标记为黑色。这个过程一直持续到没有灰色对象为止。最后剩下的白色对象就是从根出发不可达的判死刑回收。为什么收集器需要遍历引用关系而不是像引用计数那样直接数人头因为循环引用的问题本质是引用计数有值但没人能到达这个对象——计数是正数人却不见了所以必须从根出发看能不能摸到它。摸不到不管你计数器是多少都是垃圾。Python的gc模块实现的标记清除算法不只是标记还要处理对象的__del__、弱引用weakref、以及那些虽然不可达但是在销毁时会访问其他对象的复杂情况。所以CPython的gc模块做了个加强版的标记清除算法在真正销毁一个对象前会先把它放进一个临时不可达列表反复检查验证防止因为对象之间的相互引用导致误删。3.3 GC只在需要的时候出现有一个点很多人不知道CPython的垃圾回收器默认只处理容器对象不是所有对象都进gc的视野。整数、浮点数、字符串这些对象它们要么是不可变的要么不会互相引用形成环靠引用计数就能搞定根本不会触发垃圾回收。而list、dict、set、tuple空的除外以及自定义类的实例这些能装下其他对象的容器才可能形成引用环才需要gc模块来兜底。这也能解释一个现象你创建一个非常大的列表删除后占用的内存立刻被释放引用计数归零了但如果你创建了两个互相引用的字典然后把它俩的变量全部del内存并不会立刻被释放得等下一次gc触发。import gc a {} b {} a[b] b b[a] a del a, b # 此时两个字典的引用计数都不为0但已经不可能访问了 gc.collect() # 手动强制执行一次回收所以如果你在代码里大量创建了容器对象而且它们之间构建了复杂的引用关系又希望内存及时释放建议在关键节点手动调用gc.collect()。这个函数会强制执行全代回收代价是可能有一点停顿但在处理完大批量数据、准备进入稳定阶段的时候调用一次是划算的。4. 实操直接在解释器里观察引用计数和GC行为4.1 用sys.getrefcount查看对象被引用了多少次前面提到了sys.getrefcount这里我再讲细一点。它的返回值始终是当前真实引用数1因为调用函数时参数本身也被传进去了一次。这个1看似麻烦其实是故意的——它保证了你在查看的时候对象的计数至少是1不会在查看过程中被销毁。import sys a [] print(sys.getrefcount(a)) # 真实引用是1a输出是2 b a print(sys.getrefcount(a)) # 真实引用是2输出是3 c [a] print(sys.getrefcount(a)) # 真实引用是3a、b、c列表里的元素输出是4这个函数在调试为什么对象没被释放时特别好用。你怀疑某个对象被谁意外持有了就多打印几次引用计数把可疑的引用点挨个排查掉。不过要注意Python解释器内部对某些对象还有隐藏的引用。小整数对象比如256、空元组、还有一些被intern的字符串它们是全局共享的引用计数会表现得虚高。你创建一个x 1000和一个y 1000如果确认is返回True说明解释器把它们缓存到了同一个对象上。这种优化对小整数和短字符串尤其明显。a 1000 b 1000 print(a is b) # 结果取决于版本和环境但经常是True因为解释器缓存了整数对象这种缓存机制叫做驻留interning。它也是引用计数的一种特例——同一个对象被大量引用节省内存但如果你试图修改它就会出大问题。幸好Python的基本数值类型是不可变的你改不了。4.2 用gc模块强制回收和观察阈值gc模块是Python自带的垃圾回收接口最常用的几个函数import gc # 拿到当前阈值 gc.get_threshold() # 手动垃圾回收返回回收了多少对象 gc.collect() # 看看每代有多少对象在跟踪 gc.get_objects()如果你对我的程序里到底有多少对象被gc盯着好奇可以这样看import gc for gen in range(3): print(f第{gen}代对象的数量{len(gc.get_objects(gen))})gc.get_objects(gen)返回该代对应的对象列表。这个函数在内存泄漏排查的时候极其有用——程序跑了一会儿你发现某个list或者自定义对象越积越多就用它看看到底是哪一代堆积了。GC还有一个选项叫gc.set_debug可以打印回收过程中的详细信息import gc gc.set_debug(gc.DEBUG_STATS | gc.DEBUG_LEAK)调试信息会输出每次回收的耗时、扫描的对象数量、释放的内存大小。比较常见的用法是开DEBUG_LEAK它会额外把回收完后仍不可达但无法被Python调用的对象比如带有__del__的对象单独列出来方便你定位哪些对象是回收器想回收但回收不了的。4.3 弱引用当你只想看一看而不想影响生死还有一个和引用计数强相关的工具weakref模块。你有时候只想观察一个对象在不在并不想因为这个观察而让对象老而不死。如果直接用普通引用去指向它它的引用计数就1了一旦这个引用不删它永远都不会被回收。用弱引用就避免了这个问题。import weakref class BigObject: def __init__(self, data): self.data data obj BigObject(大量数据) r weakref.ref(obj) print(r()) # 返回obj本身只要它还活着 del obj print(r()) # 返回None说明对象已经被回收了这里面有个经典的坑weakref.ref拿到的是一个可调用对象r每次调用r()才返回真正的对象如果对象死了返回None。你必须先判断是不是None再用否则直接调属性会报AttributeError。弱引用在缓存场景特别实用。你在实现LRU缓存时不想让缓存的对象因为缓存的引用而永远活着——缓存应该是可有可无的不是强制续命。这时用weakref.WeakValueDictionary就对了。字典本身存的是弱引用key对应的值如果已经被回收字典自动把那个条目清除。import weakref cache weakref.WeakValueDictionary() class Data: def __init__(self, v): self.v v d Data(42) cache[key] d # 存的是弱引用 del d print(key in cache) # False对象已经销毁缓存条目自动消失了说实话这个机制在很多业务场景里用不到但一旦用上能彻底告别缓存导致内存爆炸的问题。5. 真实项目里的内存问题怎么排查、怎么解决5.1 经典泄漏场景循环引用 __del__不能执行class Connection: def __del__(self): # 假设这里要关闭连接释放资源 self.close() class Session: def __init__(self, conn): self.conn conn conn.session selfSession和Connection互相引用。如果用户创建了Session就丢弃循环引用会被gc发现但gc在处理带有__del__方法的对象时默认策略非常保守如果对象上有__del__而它又在循环引用里那么gc为了安全会把它们都标记为不可回收并把它们放进gc.garbage列表里后续不再碰它们。这就意味着你的__del__不但没被执行而且对象还一直占着内存。这是Python里一个模糊诡异的行为网上很多人骂过。解决办法有几种尽量不用__del__改用它替代品比如with语句、contextlib.closing、显式调用close()。Python官方文档自己都建议不要依赖__del__做清理工作。如果非要用那就避免在__del__里访问其他对象的方法它应该只做最小的资源释放如关闭文件句柄不触发其他对象操作。用weakref打破循环引用让两者中至少有一个不持有对方的强引用循环就断了。我自己写项目时一般直接把资源释放逻辑放在业务代码里用try/finally或者with语句包住关键操作__del__只负责兜底永远不把它当作核心释放手段。这样可以避免绝大多数和gc纠缠不清的问题。5.2 用tracemalloc定位内存增长点tracemalloc是Python自带的内存跟踪工具它比gc模块更能帮助定位哪一行代码分配了内存但没释放。用法很简单开启跟踪后它可以告诉你当前内存快照里来自哪个文件哪一行代码的内存最多。import tracemalloc tracemalloc.start() # 跑你的业务代码或者跑一段测试 snapshot tracemalloc.take_snapshot() top_stats snapshot.statistics(lineno) for stat in top_stats[:10]: print(stat)它的原理是给每个Python线程的记录额外挂上分配内存时所在的代码位置信息。虽然会有一定的性能开销但在排查内存泄漏时这是最爱用的工具之一。你只要在怀疑泄漏的代码段前后各取一次快照然后对比两次快照里哪些位置的分配量增加了就知道是谁在偷偷占用内存。我处理过的一个真实线上问题一个服务每天凌晨内存缓慢增长白天恢复正常第二天继续。排查了半天最后用tracemalloc一看发现是某个日志对象里积压了一个不会失效的文件句柄引用导致日志对象被引用计数保留。找到分配位置后一行代码就修掉了。5.3 优化手段不只是加内存条你可能会问日常开发真要为了内存做那么多优化吗分情况。写数据分析、图像处理、大文件处理的脚本内存就是硬约束一万行数据无所谓一亿行就难受了。这时候有几个务实手段第一用__slots__减小自定义类的内存开销。class Point: __slots__ (x, y) def __init__(self, x, y): self.x x self.y y普通Python类的实例内部有个__dict__用来存实例属性。这个字典本身就要占不少内存。__slots__告诉解释器这个类的属性就这俩别建字典了于是每个实例省掉了字典的开销。如果创建大量点的对象省下的内存非常可观。代价是属性不能动态添加不信你可以试试给一个带__slots__的实例添加新属性会报AttributeError。第二批量处理而不是一次性加载。读大文件时不要一次性read()整个文件用for line in file:逐行读取。处理大列表时优先考虑map、filter、生成器表达式而不是立刻生成整个列表。# 不要这样 data [process(line) for line in open(huge.txt)] # 改成生成器 data (process(line) for line in open(huge.txt))生成器惰性求值每次只处理一个元素内存占用恒定的不会随着文件大小无限增长。第三用array模块或numpy处理数值数据。一个整数的list每个元素是个独立的Python对象本身就有对象头大概28字节还要加list槽位的指针。用array(i)存同样的数据本质是C数组内存占用小一个数量级。做数值计算的直接用numpy它内部是连续的内存块不仅省内存计算还快得多。5.4 调试工具速查什么场景用什么我把这些年用过的东西整理成一张表方便你对照场景选择工具/函数用途适用场景sys.getrefcount查看对象引用计数快速判断某个对象是不是被意外引用了gc.collect()手动执行垃圾回收批量数据处理后主动回收循环引用垃圾gc.get_objects(gen)查看某代被跟踪的对象观察对象堆积在哪一代gc.set_debug(gc.DEBUG_LEAK)输出GC漏网之鱼排查带__del__的循环引用对象tracemalloc定位内存分配代码行查找内存持续增长的具体原因objgraph绘制对象引用图可视化理解循环引用关系objgraph这个库虽然名字带graph但它其实只是利用gc模块的接口不依赖任何可视化库用法是import objgraph objgraph.count(list) # 统计当前list对象的数量 objgraph.show_refs([obj], max_depth2) # 生成对象的引用图在你完全摸不着头脑的时候一张引用图比一堆日志直观得多。6. 内存管理的几个反常识细节6.1 小整数和字符串缓存真的是全局共享的CPython在启动的时候会提前创建-5到256之间的小整数对象所有引用这些整数的代码用的都是同一块内存。所以你在写a 100、b 100的时候a is b的结果是True不是Python引入了什么高深的魔法只是它早就预创建好了对象。这个机制节省了海量的小整数对象重复分配但也带来一个小陷阱你不能假设改了a就等于改了b——整数不可变你改不了它本身你只能把a重新指向另一个整数对象。字符串也有缓存机制。不含空格的短字符串CPython里是长度不超标且只含字母、数字、下划线的在解释器内部会被驻留。a hello、b helloa is b很可能返回True。但如果你动态拼接的字符串超过了缓存条件is就会返回False了。所以永远不要用is去比较字符串内容老老实实用。6.2 默认参数的定时炸弹def add_item(item, target[]): target.append(item) return target这个函数的默认参数[]是在函数定义时创建的只创建一次。以后每次调用如果不传target用的都是同一个列表对象。头两次调用没问题第三次你就会发现前面的数据还在。这个问题的实质是默认参数对象被函数对象引用着引用计数不为0所以它永远不会被回收。你每调用一次往同一个列表对象里塞一个元素这个对象越来越胖却看起来像是每次调用都新建了列表。修正办法是改成def add_item(item, targetNone)在函数内部判断并新建列表。这类问题背后都是同一个逻辑那个空列表对象活得太久因为有个函数对象一直强引用着它。6.3 生成器对象和闭包带来的意外存活一个生成器函数如果要跑完才会释放内部变量但如果你把一个生成器对象存下来了它的内部状态就一直在相关的局部变量永远被引用着。def huge_generator(): big_list [i for i in range(10**6)] yield 1 yield 2 gen huge_generator() # 此时big_list还没创建不它可能已经创建了或者会在第一次next时才创建 # 取决于生成器函数内部代码的执行位置生成器的for循环迭代完之前它持有的对象都不会被释放。如果你用某个生成器只迭代了一部分就停下来了整个生成器对象又没有引用失效那么大列表就一直在。这种内存占用是隐形的用tracemalloc量的话会很吓人。闭包也是类似的外层函数的局部变量被内层函数引用着只要内层函数对象还活着那些变量就不会被回收。你在循环里创建大量闭包等于同时保留了所有循环变量的引用内存直接爆炸。该del就del别舍不得删。6.4 PyPy和其他解释器的内存模型不一样CPython用引用计数为主要回收手段PyPy则用的是移动垃圾回收器的思路更接近JVM。同样的代码在PyPy上内存表现完全不同。如果你写的项目重度依赖内存行为比如对对象生命周期有精确假设换解释器之前最好先跑一通内存性能测试避免上线了才发现回收时机和CPython完全不是一个节奏。当然绝大多数人的业务代码不会对GC时机做严格假设换解释器的主要收益是性能提升内存上各有取舍。但这个知识点还是值得知道的——它会提醒你内存管理机制是解释器的实现细节不是Python语言本身的规定。7. 我这些年在内存管理上攒下的几个经验先说最核心的一句话不要依赖事后清理尽量让对象自然死亡。尽量在业务逻辑上就把资源的生命周期设计清楚让对象在离开作用域或者完成使命后引用计数自然归零。你设计得越好gc模块的介入越少程序的性能越稳定、内存越可控。具体怎么做到我分享几个常用套路第一能用with语句就用with语句。文件、网络连接、数据库会话这些资源都有上下文管理器它们内部保证离开with块时资源被显式释放。比如with open(data.txt) as f: content f.read() # 文件到这里已经关闭了句柄资源释放了用with比手动close()好在哪第一它不怕异常第二它不依赖程序员记性和顺序。第二大量批量处理时把任务切成小块处理完就丢弃。我处理过几百万行的日志数据最开始写的就是一次性读入列表一顿操作结果内存涨到几个GB程序被系统杀掉。改成流式读取、流式处理之后内存稳定在几十MB。有时候牺牲一点代码简洁性换来的稳定性非常值。第三写框架或者组件的时候多用weakref来做解耦。比如一个事件系统观察者注册监听事件如果观察者生命周期结束却没来得及注销强引用会导致观察者永远活着。用弱引用注册监听观察者被回收后事件系统自动忽略它完美避免僵尸对象问题。第四用工具而不是猜。遇到内存问题时别急着怀疑是不是Python太菜先用tracemalloc看看到底哪一行分配了内存又没释放再用gc模块看看是不是循环引用在捣鬼。流程是先量化再定位最后修复。我见过的所有内存问题几乎都能靠这两步找到根因。第五了解你手里的Python版本。不同版本的CPython在内存分配器、GC阈值、字符串驻留规则上都有差异。比如Python 3.12以后解释器内部的per-interpreter GIL和多线程内存分配器的变化都会影响对象生命周期。升级Python版本之前如果业务重度依赖内存行为最好先在小流量环境跑一段时间别直接上生产。最后说点实在的写Python的时候真正需要手撸内存管理场景根本没有那么多。但理解内存管理机制的价值在哪里它让你在遇到程序内存炸了的时候知道从哪下手让你在设计对象关系时下意识避开循环引用让你在处理大数据时主动选择合适的数据结构和处理方式。这些判断力才是程序员知道自己在做什么和程序员只会写代码之间的区别。