先说我自己的经历。去年排查一个常驻数据服务的RSS内存持续上升问题时,按Java项目的习惯,第一件事就是找GC日志、看GC频率。但Python环境里翻了半天,也没找到什么“GC日志”——Python的GC默认不打印,除非你主动开debug开关。后来我把gc模块的各个参数摸了一遍,才发现Python里的“垃圾回收”,和我原本理解的Java式GC根本不是一回事。标题里这句话“在Python中,GC是自动管理内存的机制,用于回收不再被引用的对象所占用的内存”,听起来很简单,但落到实际排查时,你会撞上“引用计数”“分代回收”“循环引用”“标记清除”“终结器”这一堆概念,它们各有各的职责,也各有各的坑。这篇文章就把这些层面拆开讲透,同时聊聊我实际踩过的问题和调优经验,适合对Python内存管理想建立完整认知的人,也适合正在为“内存只增不减”发愁的开发者。
1. Python的回收主力和辅助清理是两个完全不同的东西
很多人把“Python的GC”当成一个整体机制,但实际上CPython的自动内存管理至少由两层组成,而且这两层的设计逻辑截然不同。
1.1 引用计数:真正干脏活累活的第一道防线
CPython每个对象头上都维护着一个字段,叫引用计数,对应C API里的PyObject.ob_refcnt。当你把一个对象赋值给新变量、塞进列表、作为参数传进函数时,这个字段就会加一;当变量被重新赋值、函数弹栈、对象从容器移除时,这个字段就会减一。一旦计数归零,对象立刻走tp_dealloc逻辑,把内存释放掉。
这套机制是嵌入在所有操作里的,不需要“GC线程”去感知,也不需要“暂停程序再扫描”。你可以随时在自己的代码里验证这种实时性:
import sys class Demo: pass obj = Demo() # sys.getrefcount(obj) 的结果总是比真实计数大1 # 因为把 obj 作为参数传进去时,这个函数本身也让引用临时+1 print(sys.getrefcount(obj)) # 通常输出2把对象放进容器,计数会继续涨:
lst = [obj, obj] print(sys.getrefcount(obj)) # 输出4:1个变量引用 + 2个列表元素 + 1个函数参数引用计数在后台左右着几乎所有对象的生死。del obj其实只是删掉了“名字到对象”的绑定,并不直接销毁对象,只有计数归零后销毁动作才真正发生。
1.2 分代回收:专门处理引用计数解决不了的循环引用
既然引用计数实时又高效,为什么还需要一个“GC”?因为引用计数有一个天然盲区:循环引用。
拿最简单的两个对象说:
class Node: pass a = Node() b = Node() a.next = b b.next = a del a del b这时a被b.next引用着,b被a.next引用着,两个对象的引用计数都还是1,但它们对外已经没有任何路径能被代码拿到了。它们互相抱着,永远不死。这就是“泄漏”——不是内存泄漏定义里的那种失控增长,而是垃圾回收器看不见的垃圾。
分代回收(garbage collector,也就是gc模块管理的部分)就是为这个场景设计的:它周期性从根对象出发,沿着引用关系遍历所有对象,能到达的就是存活对象,到不了但形成环的,就是可回收对象。这个“找环并回收”的过程,就是我后面要展开的gc.collect()背后的逻辑。
1.3 Python的GC和Java/Go的GC有什么本质不同
很多刚从Java转到Python的人会把两者画等号,这是排查问题时的第一道门槛。JVM的GC是“集中式垃圾回收”,阶段性地停止应用线程,标记、清理、压缩,工作量大但算法成熟。CPython的GC则是“引用计数 + 定时清扫”的组合拳:引用计数处理绝大多数对象的即时回收,分代回收只处理偶尔出现的循环引用。
这意味着Python里对象的销毁时点非常确定——引用归零的那一刻就回收了,不归零就一直不回收。而Java的回收时点基本不可预测。反过来,Python每个赋值、每个传参都在做引用计数的加减操作,存在CPU消耗;JVM则把这类操作的时间省下来,集中在GC阶段花。所以“Python的GC慢”往往不是分代GC慢,而是引用计数这种零碎操作贯穿在每一行代码里的累积成本。
2. 引用计数如何决定一个对象的生死
这一节重点讲引用计数在日常代码里的真实表现,以及几个特别容易让人误判的细节。
2.1 引用计数增减的全过程
一个对象从生到死,引用计数会经历无数次的加减。看一个带函数的例子:
def process(items): # items入参时,调用方的引用计数+1 total = 0 for item in items: total += item # 函数返回后,items局部引用销毁,调用方的引用计数-1 return total numbers = [1, 2, 3] # numbers引用计数=1 result = process(numbers) # 传参时计数=2,函数返回后再回落到1 del numbers # 计数归零,列表对象被释放真实运行时到底加了多少次、减了多少次,比这个例子复杂得多,但逻辑主线就是这样:每个引用关系的建立和消失都反映在计数上。
有一类高频场景需要特别注意:把同一个对象放进多个列表,或把对象作为多个字典的值。很多人在写ORM、写缓存结构时,同一个模型实例被塞进多个索引容器,这时你以为删掉原始变量就释放了,实际上引用计数还高得很。对象只有在所有容器都移除之后才会释放。
2.2 小整数、驻留字符串:引用计数归零不了的“永生对象”
CPython里有一个特殊机制:-5到256之间的小整数是全局缓存的。无论你在代码里写多少次100,它都是同一个对象,引用计数永远不会归零,因为解释器守着它们。字符串也有类似情况,部分短字符串会被内部驻留(interned),同样的字符串字面量会不会指向同一个对象,要看解释器策略。
这个细节直接影响了我们调试时的直觉:
a = 100 b = 100 print(a is b) # True,指向同一个缓存对象但a = 257; b = 257时用is比较,结果通常是False。很多人刚学Python时被is和==的区别弄晕,根源其实就在驻留机制上。GC层面这不算什么事,但它提醒我们:不要指望所有“不再使用”的对象都会被立即回收,CPython自己养了一批常驻对象。
2.3 del之后内存没降,不是泄漏,是分配器还在手里
这是排查线上问题时最容易产生误解的地方。假设你跑了一段脚本,删掉了一堆大列表,然后用psutil看进程RSS,会发现内存占用几乎没降。你可能会怀疑是GC失效了,其实问题不在GC,而在底层内存分配器。
CPython在释放小对象时,并不会每次把内存还给操作系统,而是把空闲块放入内存池复用。这样做是为了避免频繁的系统调用和内存碎片化。对象头虽然释放了,但内存页还握在进程手里,等后续新的对象分配时直接复用。真实情况是:Python对象层的“垃圾”确实已经被回收了,你看到的RSS下降仅仅是“带宽不够”。
区分“对象被回收”和“内存还给OS”这两件事,对判断内存问题方向很重要。后面第6节我会给一个系统性的排查路径。
2.4 引用计数不能被关闭带来的宏观影响
CPython引用计数是架构级的,无法像JVM那样选择不同的GC算法。这意味着即使你手动调优gc模块,也改变不了“每个对象引用归零立即释放”的既定路数。你真正能调整的,只是“循环引用多久被扫一次、怎么扫”而已。
所以,当我们讨论Python内存调优时,一定要搞清楚自己能控制什么、不能控制什么。禁用gc模块不会让引用计数失效;反复调用gc.collect()也不会让非循环的对象死得更快。制定优化方案前先把这个心智模型建立起来,否则后面每一步都可能白忙。
3. 循环引用是谁制造出来的:典型代码模式
循环引用不是罕见例外,而是几乎每个大型项目里都会出现的结构。下面这几个场景,是我在真实业务代码中见过最多的情况。
3.1 自引用和互相引用:最基础的循环结构
最简单的是自引用:
class SelfRef: def __init__(self): self.me = self x = SelfRef() del x这里x.me指向自身,del x之后引用计数不是0,而是1。对象成了一个孤岛,唯一引用它的是自己。这个例子看似刻意,但在ORM关联、树结构、图数据结构里低头可见。
双向链表和树节点的父子关系是另一个典型:
class TreeNode: def __init__(self, name): self.name = name self.children = [] self.parent = None root = TreeNode("root") child = TreeNode("child") root.children.append(child) child.parent = root del root del child删除root和child后,两个节点依然互相持有,GC不扫的话它们会一直躺在内存里。这种结构在业务里很常见:文件系统目录树、组织架构树、菜单树。如果你只删了前端暴露的根节点,忘了清理子节点的parent反向引用,一定会有残留。
3.2 闭包和回调:隐蔽的循环引用制造机
闭包制造循环引用的方式更隐蔽。看这个例子:
def outer(): class Obj: pass obj = Obj() def inner(): return obj # 闭包捕获了 obj obj.inner = inner # obj 又持有 inner return obj item = outer() del item这里obj持有inner函数,inner的闭包作用域又持有obj,两者形成环。你没有写任何parent/child这种明显的引用关系,但用gc.get_objects()查的时候,能发现这一公一私两个对象都还站在GC的待扫描队列里。
闭包循环常见于事件回调、装饰器生成的包装函数、局部函数捕获循环变量再被对象持有的场景。调试这种问题时,先看看“谁持有了谁能到达谁”,不要急着怀疑GC没干活。
3.3 为什么循环引用只能通过“从根遍历”才能找出来
引用计数之所以处理不了循环引用,是因为它只看到“上一个持有者”,看不到“整体是否还有出口”。循环里的每个对象都觉得自己被持有,但实际上整个引用图已经没有一条路能通向它们。
分代回收的思路是换一个角度:从根集合(globals()、locals()、栈帧、模块、__main__等)出发,把所有能到达的对象标为“存活”,遍历结束后,没被标上的就是垃圾。这个算法叫标记-清除(mark-sweep),它能识别循环引用,但代价是要遍历整个引用图,所以CPython引入了分代策略来降低遍历频率,也就是第4节要讲的调度逻辑。
4. 分代回收的调度算法:从0代到2代,阈值不是拍脑袋定的
4.1 为什么非得分代
大部分对象的生命周期都很短。你在循环里创建的临时列表、临时字典,一批代码执行完就该没了。如果GC每次都全量扫描所有对象,绝大多数时间都在反复检查这些“注定短命”的临时对象,浪费极大。
分代回收的基本假设是:一个对象活过得越久,它越可能继续活下去。新创建的对象放进0代,活过一轮回收没被清掉的,晋升到1代;再活过一轮,晋升到2代。0代扫描频率最高,2代扫描频率最低。这样一来,GC可以把绝大部分精力集中在新对象聚集的区域,老对象那边偶尔看一眼就行。
4.2 阈值和晋升的具体逻辑
gc模块默认的阈值是:
import gc print(gc.get_threshold()) # (700, 10, 10)含义是:
- 0代:当“已分配对象数 - 已释放对象数”超过700时,触发一次0代回收
- 1代:0代回收每发生10次,触发一次1代回收
- 2代:1代回收每发生10次,触发一次2代回收
注意这里不是“每分配700个对象就扫描一次”,而是跟踪净增量。这个差异很关键:如果大量对象被分配然后又立即释放,净增量可能一直不高,GC就不会频繁触发。
可以用gc.get_count()观察当前代际的最新计数,这个函数返回元组(0代计数, 1代计数, 2代计数),对应各代距上次回收的累积状态。
import gc print(gc.get_count()) for i in range(2000): _ = [object() for _ in range(200)] print(gc.get_count())实测中跑完这段,0代计数大概率已经超过700,下次分配动作就会把0代回收拉起来。如果你从没主动调用过gc.collect(),也能感受到GC在你不知道的时候跑了多少次。
4.3 哪些对象进GC跟踪,哪些永远不进去
分代GC只跟踪一种东西:可能形成循环引用的容器对象。对于int、str这类不可变基础对象,它们不可能引用别人,也就不可能参与循环引用,因此默认不被跟踪。你可以用gc.is_tracked()验证:
import gc print(gc.is_tracked([])) # True print(gc.is_tracked({})) # True print(gc.is_tracked(123)) # False print(gc.is_tracked("abc")) # False这个细节在分析gc.get_objects()结果时特别重要。你要统计“当前GC世界里有哪些对象”,看到的其实是“被跟踪的容器和自定义对象”,而不是所有内存。很多字符串和数字占着大量内存,但根本不会出现在gc.get_objects()里。
4.4 用调试输出看GC真实运行
想亲眼看到GC什么时候跑、跑一次回收多少对象,可以打开调试输出:
import gc gc.set_debug(gc.DEBUG_STATS) # 下面这段会促使GC触发 for i in range(3000): d = {"key": [1, 2, 3]} gc.set_debug(0)开启后,控制台会输出类似gc: objects in each generation: 1122, 34, 0、gc: collected 1087、gc: uncollectable 0、gc: 0.023s这样的信息。这在实际调优中很有用,能让你直观看到GC耗时不均匀地出现在哪个阶段,而不是拿秒表感觉性能波动。
5.del、弱引用和回收的边界条件
5.1 终结器为什么会拖累回收
__del__(终结器)是Python对象的一个特殊方法,引用计数归零或分代GC准备回收对象时会尝试调用它。但当一个不可达循环引用里混入了带有__del__的对象,情况会变得微妙——解释器无法确定这些对象的清理顺序,不知道该先调谁的终结器,于是干脆把这些对象放入gc.garbage列表,不再自动回收。
import gc class SelfRefWithDel: def __init__(self): self.me = self def __del__(self): raise RuntimeError("unreachable") x = SelfRefWithDel() del x gc.collect() print(gc.garbage) # 会把x这个对象留在列表里更准确地说,CPython从3.4版本开始(PEP 442)改进了终结器的处理逻辑:如果对象是简单循环引用的一部分,且对象本身有__del__,它会被移动到gc.garbage中等待你手动处理。如果你没有在业务代码里清理gc.garbage,这些对象就可能一直存活到进程退出。
现代Python代码的正确姿势是:尽量不要依赖__del__做资源清理。文件句柄、数据库连接、锁的释放应该走with语句和contextlib.contextmanager,让生命周期由代码块显式控制。如果确实要在对象回收时做点事情,可以考虑weakref.finalize,它比__del__可控得多。
5.2 weakref:打破循环引用的轻量级武器
弱引用(weakref)的设计目标就是“引用但不影响对象存活”。它不对对象的引用计数产生影响,被引用对象回收之后,通过弱引用取到的值变成None。
import weakref class Cache(dict): pass cache = Cache() data = ("big", "object") ref = weakref.ref(data) print(ref()) # 取到对象 del data print(ref()) # None,对象已经被回收在业务系统里,弱引用最常见的用途是避免“缓存对象反过来被缓存引用拖死”。比如你要实现一个观察者模式,发布者保存一堆订阅者列表;如果发布者对订阅者使用强引用,订阅者即使业务上已经不需要了,也会因为被发布者持有而一直活着。改成弱引用列表之后,订阅者没了,列表里自然只剩None。
5.3 异常对象和栈帧:很容易被忽略的伪回收陷阱
except块里的异常对象,会在异常处理结束后仍然被局部变量持有。而异常对象的__traceback__属性会引用整个栈帧,栈帧里又有局部变量表,局部变量又可能引用其他对象,形成一条很长的存活链。
常见的坑是这个:
def risky(): try: 1 / 0 except Exception as e: log(e) # 日志框架可能还持有 traceback # 这里如果不显式 del e,e 会一直活到函数结束, # 并且携带完整栈帧,区域内的局部变量全部存活处理很长事务的批处理脚本里,这种“异常对象 + 栈帧”的引用链会在不知不觉中把大量中间结果钉在内存里。我的习惯是:在except块末尾对临时异常变量做e = None,或者用with把可能出错的操作包起来,让异常变量尽快离开作用域。
6. 别把“内存不降”都赖给GC:版本差异和真正排查路径
6.1 CPython版本演进中GC的变化
如果你还在拿3.8时代的经验分析3.12的服务,很可能得出错误的结论。3.10到3.12之间,CPython内部的内存管理和GC做了几次重要调整:3.11优化了垃圾回收的标记过程,单轮GC的暂停时间更短;3.12对对象分配器做了大规模重构,每个对象的分配逻辑更细粒度,内存布局更紧凑,也让一部分曾经属于GC管理的元数据被折叠进了对象头。
这些变化带来的直接结果是:不同版本下“RSS不降”的原因可能不一样。老版本里很常见的“arena没归还OS”问题,在新版本里表现模式也变了。升级Python大版本后,内存曲线突然变好或变差都是正常现象,不要直接归因到业务代码。
6.2 三层排查:Python对象层、进程内存层、C扩展层
我见过太多人一看到RSS上涨就开始调GC参数,结果调了半天,真正问题在于C扩展没有释放句柄,或者tracemalloc压根看不到那部分内存。排查CPU或内存问题,先分三层看。
第一层,Python对象层。用tracemalloc定位到底哪些Python对象在消耗内存:
import tracemalloc tracemalloc.start() # 跑一段要排查的业务代码 # ... snap = tracemalloc.take_snapshot() for stat in snap.statistics("lineno")[:20]: print(stat)tracemalloc按文件、行号、调用栈把分配记录列出来,能很快找到“就是个列表没清”或者“重复创建了同一个大对象”这类问题。
第二层,GC对象统计层。用gc.get_objects()按类型计数,看GC世界里是否有异常的大对象聚集:
import gc from collections import Counter gc.collect() obj_types = Counter(type(o).__name__ for o in gc.get_objects()) for name, count in obj_types.most_common(20): print(name, count)注意这个统计只覆盖被GC跟踪的容器对象,所以更适合回答“有没有大量自定义对象没被回收”这种问题,而不是“总内存去哪了”。
第三层,进程内存层。用psutil观察进程的RSS/VMS曲线:
import psutil import os proc = psutil.Process(os.getpid()) print("RSS:", proc.memory_info().rss / 1024 / 1024, "MB")如果这一层看到内存持续上涨,而第一层显示Python对象总量稳定,那问题大概率不在Python GC的控制范围内,而是C扩展(比如numpy的底层缓冲区、opencv的图像帧、lxml的解析树)在独自占用内存。这类内存在tracemalloc和gc里都看不到,只能靠进程内存观测配合扩展模块的显式释放逻辑来定位。
6.3 一个可以套用的排查顺序
我遇到“内存只升不降”的问题时,常规动作是这样:
- 先
gc.collect()跑一次完整回收,看看gc.garbage是否为空 - 用
tracemalloc跑一段真实热点路径,找出Python对象层的大块头 - 用
psutil记录半小时RSS曲线,观察上涨节奏 - 对照步骤2的结果:RSS上涨和Python对象增长是否同步
- 如果不同步,排查C扩展资源、线程栈、网络连接等非Python对象层
这套顺序能帮你把“GC不干活”和“根本不是GC的事”分开,避免瞎调参数。
7. 性能优化:什么时候该自己接管GC
7.1 默认GC配置其实很保守
绝大多数业务代码不需要动GC参数。(700, 10, 10)这个默认配置对大多数应用足够温和,GC暂停时间通常远小于一次网络IO。但有几类场景确实值得主动干预:批量创建临时对象的长任务、对延迟抖动敏感的实时服务、驻留内存型且能预判生命周期的服务端缓存。
7.2 实测中的一组调整
我曾经在一个批量数据处理脚本里遇到过一个典型问题:循环里每次迭代都在创建临时列表和字典,0代回收被频繁触发,GC时间占了总运行时间的百分之十几。调整的第一步是把0代阈值从700提到5000,减少0代回收的触发频率:
import gc gc.set_threshold(5000, 20, 20) # 跑完业务后手动完整回收 gc.collect()阈值调大后,单次0代回收的扫描范围会更大,但触发次数明显下降。批量任务这种“跑完就走”的场景,收益非常明显。代价是如果对象寿命都特别长,0代里堆积的对象多了,晋升1代的压力会变大,所以阈值不是越大越好,要靠实际数据找平衡点。
如果希望彻底避免批处理过程中的GC暂停,也可以临时禁用GC,结束后再开启并手动回收:
import gc gc.disable() try: do_heavy_task() finally: gc.enable() gc.collect()但这里必须充分确认业务代码里没有循环引用积累。如果你disable之后又不collect,存在大量互相持有的临时对象,内存会直接失控。这个操作属于“有经验后再用”的招,不建议新手一上来就全局禁用GC。
7.3 另一个实用工具:gc.freeze()
gc.freeze()是Python 3.7引入的,作用是把当前所有存活对象冻结,不让它们再参与后续GC扫描。这个工具很契合“服务启动后加载一堆不可变配置、缓存数据”的场景。服务启动完成后,这些对象本来就会活到进程结束,与其让GC反复扫描它们,不如一次性冻结掉,降低GC的全局负担。
import gc # 启动阶段加载大量配置、预缓存数据 load_config() load_cache() gc.freeze()注意freeze()只冻结“调用时已经存活”的对象,之后新建的对象仍然正常参与GC。如果业务里有“启动后不断创建新对象”的逻辑,冻结不会影响它们。
7.4 调整GC参数要看的指标
不管哪种调法,落地之后都要用数据验证,不要凭感觉。我建议至少盯三个指标:GC触发次数、单次GC耗时、进程RSS曲线。触发次数和耗时可以用gc.set_debug(gc.DEBUG_STATS)看到,RSS曲线用psutil记录。如果调完之后GC触发少了一半,但RSS上涨速度反而更快,那说明回收能力跟不上分配节奏,需要回退阈值或改成手动collect。
不过,我对这类调优的真实体会是:多数项目的内存问题根源不在GC机制,而在对象生命周期设计。该用with管资源的地方用了__del__;该用弱引用的缓存用了强引用;该清空的全局列表在异常路径上漏了清理。GC参数调整只是把问题往后推,而不是解决它。
先说结论:CPython这种“引用计数实时回收 + 分代GC兜底循环引用”的架构,决定了摸清对象之间的引用关系比调GC阈值重要得多。你看到的每一个“内存不降”,都值得先追问一句:这块内存在哪一层?是引用计数没归零、分代GC没来得及扫、还是底层分配器还给OS太慢?用tracemalloc、gc.get_objects()、psutil三层对照一圈,答案通常会自己浮出来。至于gc.disable()、gc.freeze()这些操作,我只会在充分理解业务对象生命周期之后再动手,而且一定会配套监控,毕竟Python的GC机制虽然古早,但它挡住的远程事故,比它能带来的性能收益更值得看重。