人人都会AI编程

4.4 内存泄漏常见场景与排查方法

更新时间:2026-07-12

即使在有垃圾回收的语言里,内存泄漏依然可能发生。Python 的内存泄漏通常不是操作系统级别的物理泄露,而是对象生命周期管理失误,导致不再使用的对象仍然被引用,无法被回收。下面梳理最常见的几种场景和对应的排查方法。


常见场景

1. 循环引用中的 del 陷阱

当两个或多个对象相互引用,且其中一个类定义了 del 方法,垃圾回收器会将其放入 gc.garbage 列表而无法自动回收。

import gc

class A:
    def __del__(self):
        pass

class B:
    def __del__(self):
        pass

a = A()
b = B()
a.b = b
b.a = a

del a, b
gc.collect()
print(gc.garbage)   # 对象不会被彻底清理,会滞留在 gc.garbage 中

对策:尽量避免自定义 del,改用 weakref 或上下文管理器。


2. 全局容器持续增长

无意中向全局列表、字典或类属性中持续添加数据,而又从不清理,导致内存无限增长。

cache = {}

def process_request(req_id):
    data = fetch_large_data(req_id)
    cache[req_id] = data   # 请求结束后没有删除,cache 越来越大

对策:使用 functools.lru_cache 限容、定期清理,或使用 WeakValueDictionary 允许多余条目被回收。


3. 缓存未设置上限

使用 lru_cache 但没有指定 maxsize,或手动实现的缓存字典无限增长。

from functools import lru_cache

@lru_cache(maxsize=None)   # 危险:所有结果都会缓存,内存可能暴涨
def expensive(n):
    return n * n

对策:设置合理的 maxsize,如 maxsize=128


4. 循环引用 + 异常导致引用链断裂不全

对象之间循环引用,某个引用被异常打断,导致整个图无法计数归零,分代回收也可能因阈值未到而延迟处理。

class Node:
    def __init__(self):
        self.parent = None
        self.children = []

root = Node()
child = Node()
root.children.append(child)
child.parent = root

# 删除 root 但 child.parent 仍活着,构成循环引用
del root
# 此时 child 和 root 都不可达,但引用计数不为0,需等垃圾回收扫描

对策:显式使用 gc.collect(),或使用弱引用 weakref.ref 打破循环。


5. 外部资源未正确关闭

打开的文件、网络连接、数据库游标等如果没有显式关闭,可能被一直保留在内存中(同时占用系统句柄)。

files = []
for i in range(10000):
    f = open('data.txt', 'r')
    files.append(f)       # 忘记 f.close(),且一直被列表引用

对策:始终使用 with 语句管理资源。


6. C 扩展模块的内部泄漏

某些 C 扩展库(如使用 Cython 手动管理内存的模块)可能忘记释放分配的内存,Python 的垃圾回收无法介入。这类泄漏通常表现为进程内存持续增长,而 Python 对象计数正常。

对策:关注第三方库版本,遇到疑似问题使用 Valgrind(Linux)等工具检测 C 层的泄漏。


排查方法

1. tracemalloc — 内置内存追踪利器

Python 3.4+ 自带,能记录每次内存分配的调用栈,对比“快照”找出持续增长的分配点。

import tracemalloc

tracemalloc.start()

# ... 执行怀疑泄漏的代码段 ...
snapshot1 = tracemalloc.take_snapshot()

# ... 继续执行 ...
snapshot2 = tracemalloc.take_snapshot()

top_stats = snapshot2.compare_to(snapshot1, 'lineno')
for stat in top_stats[:10]:
    print(stat)

输出会显示内存占用差异最大的文件和行号,快速定位泄漏源头。


2. gc 模块 — 分析循环引用

import gc

gc.set_debug(gc.DEBUG_SAVEALL)   # 将所有不可回收对象存入 gc.garbage
# ... 代码 ...
gc.collect()
for obj in gc.garbage:
    print(type(obj), obj)

结合 gc.get_objects() 可以列出所有已知 Python 对象,按类型统计,寻找异常庞大的对象群。

from collections import Counter
cnt = Counter(type(o) for o in gc.get_objects())
print(cnt.most_common(5))

3. objgraph — 可视化对象引用链

第三方库 objgraph 可以展示某个类型对象的增长情况,并绘制反引用图(哪些对象在引用它)。

import objgraph

# 假设反复调用某函数后
objgraph.show_growth(limit=5)   # 显示增长最快的5种对象

# 如果需要具体到某个实例的引用链
roots = objgraph.get_leaking_objects()   # 需先调用 objgraph.show_most_common_types
# objgraph.show_refs([obj], filename='refs.png')  # 生成引用图

4. memory_profiler — 逐行内存分析

memory_profiler 可以精确到每一行代码的内存变化。

from memory_profiler import profile

@profile
def leaky_func():
    a = [1] * (10**6)
    b = [2] * (2 * 10**6)
    del b
    return a

if __name__ == '__main__':
    leaky_func()

运行 python -m memory_profiler example.py 会输出每行的内存增减,直观看出哪里额外占用了内存。


5. 综合排查策略建议

  • 先看整体:使用 tracemalloc 对比快照,定位到疑似泄漏的文件/函数。
  • 再看细节:用 memory_profiler 分析可疑函数内部的逐行内存变化。
  • 分析对象图:如果局部变量都已消失但内存仍然不降,用 gcobjgraph 检查循环引用和意外全局引用。
  • 检查 C 扩展:如果 Python 层面一切正常但进程 RSS 持续上涨,在 Linux 下可用 valgrind --tool=memcheck 或直接切换库的版本/使用纯 Python 替代测试。

做到这些,多数日常工作中的内存泄漏都能被快速定位和解决。