Python 的自动内存管理虽然省去了手动分配释放内存的麻烦,但开发者仍需要理解其机制才能写出高效代码并排查内存泄漏。本文从引用计数、循环引用、分代回收三个核心概念出发,结合 gc 模块的调试方法,给出实用的排查步骤和代码示例。
一、引用计数:Python 内存管理的基石
每个 Python 对象内部都有一个 ob_refcnt 字段,记录指向它的引用数量。当引用计数降为 0 时,对象会立即被销毁并回收内存。使用 sys.getrefcount() 可以查看当前引用计数(注意该函数自身会临时增加一次引用)。
- import sys
- a = [] # 列表对象引用计数 = 1
- b = a # 引用计数 = 2
- print(sys.getrefcount(a)) # 输出 3(getrefcount 自身增加一次)
- del b # 引用计数降为 1
复制代码
del 关键字删除的是变量名与对象的绑定关系,并非直接删除对象。只有引用计数归零时,对象的 __del__ 方法才会被调用。但由于 __del__ 的执行时机不确定,依赖它来释放资源是不安全的,推荐使用上下文管理器(with 语句)来管理文件句柄、锁等资源。
引用计数的优点是实时回收、实现简单、确定性销毁;缺点是无法处理循环引用,并且每次赋值和删除都会修改计数,多线程下需要原子操作,存在性能开销和锁竞争。
二、循环引用:引用计数无法处理的场景
当两个或多个对象互相引用,且外部没有变量指向它们时,引用计数永远不会降为 0,导致内存泄漏。典型的循环引用出现在双向链表或树结构(父子节点互相持有)、闭包(内部函数引用外部变量,外部变量又引用函数)、回调/观察者模式、异常栈帧等场景。
- class Node:
- def __init__(self, name):
- self.name = name
- self.parent = None
- self.child = None
- a = Node("A")
- b = Node("B")
- a.child = b
- b.parent = a
- del a
- del b
- # 此时两个对象引用计数均为 1(互相指向),无法被回收
复制代码
三、分代垃圾回收:解决循环引用的利器
Python 的垃圾回收器(gc 模块)专门用于处理循环引用,它采用分代策略将对象分为三代:第 0 代(新创建的对象)、第 1 代(经历一次 GC 存活的对象)、第 2 代(经历多次 GC 存活的对象)。GC 的触发频率随代数增加而降低,因为大部分对象生命周期很短。
回收流程以第 0 代为例:
1. 标记(Mark):从根对象(全局变量、栈帧中的局部变量等)出发,遍历所有可达对象,标记为“存活”。
2. 清除(Sweep):遍历第 0 代所有对象,将未被标记的对象(即不可达的循环引用对象)回收。
3. 晋升(Promote):本轮存活的对象移入第 1 代。
自动触发阈值可通过 gc.get_threshold() 查看,默认值为 (700, 10, 10):第 0 代对象数量超过 700 时触发第 0 代 GC;第 0 代 GC 每进行 10 次触发一次第 1 代 GC;第 1 代 GC 每进行 10 次触发一次第 2 代 GC。
四、gc 模块:手动控制与调试
gc 模块提供了一系列函数用于手动控制垃圾回收和调试内存泄漏。
- import gc
- # 手动触发回收,返回回收的不可达对象数量
- unreachable = gc.collect()
- print(f"回收了 {unreachable} 个不可达对象")
- # 获取当前被 GC 跟踪的所有对象列表
- objects = gc.get_objects()
- # 获取引用某个对象的所有对象
- gc.get_referrers(obj)
- # 获取对象引用的所有对象
- gc.get_referents(obj)
- # 设置调试标志,打印所有无法回收的对象
- gc.set_debug(gc.DEBUG_LEAK)
复制代码
以下是一个手动回收循环引用的例子:
- import gc
- gc.disable() # 关闭自动回收,模拟场景
- class A:
- def __init__(self):
- self.b = None
- class B:
- def __init__(self):
- self.a = None
- a = A()
- b = B()
- a.b = b
- b.a = a
- del a, b
- unreachable = gc.collect()
- print(f"回收了 {unreachable} 个不可达对象") # 输出 2
复制代码
如果循环引用中的对象定义了 __del__ 方法,GC 会因为无法确定销毁顺序而将其标记为不可回收对象,并输出警告。此时应使用 weakref 弱引用打破循环。
五、内存泄漏排查实战
常见泄漏场景包括:全局缓存未清理、闭包持有大对象、__del__ 与循环引用、C 扩展模块分配的内存未释放、线程局部变量未清理、matplotlib 等 GUI 库未关闭的 Figure 对象。
排查工具与方法如下:
1. gc.get_objects() 统计对象数量- import gc
- obj_counts = {}
- for obj in gc.get_objects():
- t = type(obj).__name__
- obj_counts[t] = obj_counts.get(t, 0) + 1
- sorted_counts = sorted(obj_counts.items(), key=lambda x: x[1], reverse=True)
- for name, count in sorted_counts[:20]:
- print(f"{name}: {count}")
复制代码
2. objgraph 库(第三方,需安装)- pip install objgraph
- import objgraph
- objgraph.show_growth(limit=10) # 显示增长最多的对象
- objgraph.show_backrefs([my_object], filename="backrefs.png") # 生成引用关系图
复制代码
3. tracemalloc 模块(Python 3.4+)- import tracemalloc
- tracemalloc.start()
- # 运行你的代码...
- snapshot = tracemalloc.take_snapshot()
- top_stats = snapshot.statistics('lineno')
- for stat in top_stats[:10]:
- print(stat)
复制代码
4. memory_profiler 逐行分析- pip install memory_profiler
- @profile
- def my_func():
- # 你的代码
- pass
- # 运行:python -m memory_profiler script.py
复制代码
实战案例:一个 Web 服务运行几天后内存持续增长直至 OOM。排查步骤:
- 使用 gc.get_objects() 发现 dict 对象数量异常多。
- 通过 objgraph.show_growth() 发现 RequestContext 对象持续增加。
- 使用 gc.get_referrers() 找到持有这些对象的根——一个全局的 list 缓存。
- 定位到代码中未清理的请求日志缓存,将全局 list 改为 collections.deque(maxlen=1000) 或使用 weakref.WeakValueDictionary,问题修复。
六、最佳实践
- 避免循环引用:使用 weakref 模块的弱引用打破引用环。
- 资源管理使用 with 语句(上下文管理器),而非依赖 __del__。
- 大对象缓存设置上限(如 LRU 缓存)或使用弱引用。
- 长期运行的服务定期调用 gc.collect(2) 强制回收老年代。
- 调试阶段开启 gc.DEBUG_LEAK,生产环境监控 gc.get_count() 和 gc.get_stats() 指标。
通过理解引用计数、分代回收和 gc 模块,开发者可以精准掌控 Python 内存的使用情况。自动回收并非万能,良好的编码习惯才是内存安全的根本。 |