欢迎光临
我们一直在努力

【python零基础教程第17讲】内存管理与垃圾回收

Python 内存管理与垃圾回收:从原理到实战排查


一、引言

Python 作为一门高级语言,其自动内存管理机制让开发者从繁琐的 malloc/free 中解放出来。然而,“自动”并不意味着“无脑”——理解 Python 的内存管理原理,不仅能写出更高效的代码,还能在遇到内存泄漏时快速定位问题。本文将深入剖析 Python 的引用计数、循环引用、分代垃圾回收,并手把手教你使用 gc 模块进行排查。


二、引用计数:Python 内存管理的基石

2.1 什么是引用计数?

每个 Python 对象内部都有一个 ob_refcnt 字段,记录当前有多少个引用指向它。当引用计数降为 0 时,对象会被立即销毁,内存被回收。

import sys

a = [] # 列表对象引用计数 = 1
b = a # 引用计数 = 2
print(sys.getrefcount(a)) # 输出 3(getrefcount 自身会临时增加一次引用)
del b # 引用计数降为 1

2.2 引用计数的优缺点

优点缺点
实时性高,对象不再被引用时立即回收 无法处理循环引用
实现简单,无需全局扫描 每次赋值、删除都会修改计数,有性能开销
确定性销毁,适合资源管理(如文件句柄) 多线程下需要原子操作,增加锁竞争

2.3 del 删除的是引用,不是对象

del x 只是删除变量名 x 与对象的绑定关系,使对象的引用计数减 1。只有当引用计数归零时,对象才会被回收。

class Test:
def __del__(self):
print("对象被销毁")

t = Test()
t2 = t
del t # 只删除一个引用,不会触发 __del__
print("删除 t 后")
del t2 # 引用计数归零,触发 __del__

注意:__del__ 方法在对象被回收时调用,但它的执行时机不可预测(尤其在循环引用或异常时),因此不推荐依赖它来释放资源,应使用上下文管理器(with 语句)。


三、循环引用:引用计数的“死穴”

3.1 问题场景

当两个或多个对象互相引用时,即使外部没有任何变量指向它们,引用计数也永远不会降为 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(互相指向),无法被引用计数机制回收

3.2 循环引用的常见形式

  • 双向链表/树结构:父子节点互相持有引用
  • 闭包:函数内部引用外部变量,外部变量又引用函数
  • 回调/观察者模式:对象注册回调,回调又持有对象引用
  • 异常栈帧:异常对象持有 traceback,traceback 持有局部变量

四、分代垃圾回收:解决循环引用的利器

4.1 分代回收原理

Python 的垃圾回收器(gc 模块)专门处理循环引用。它采用**分代(Generational)**策略,将对象分为三代:

  • 第 0 代:新创建的对象
  • 第 1 代:经历一次 GC 后存活的对象
  • 第 2 代:经历多次 GC 后存活的对象

GC 触发频率:第 0 代 > 第 1 代 > 第 2 代。因为大部分对象生命周期很短(如临时变量),频繁扫描年轻代效率更高。

4.2 回收流程(以第 0 代为例)

  • 标记(Mark):从根对象(全局变量、栈帧中的变量等)出发,遍历所有可达对象,标记为“存活”。
  • 清除(Sweep):遍历第 0 代所有对象,将未被标记的对象(即不可达的循环引用对象)回收。
  • 晋升(Promote):本轮存活的对象移入第 1 代。
  • 4.3 阈值与自动触发

    import gc

    print(gc.get_threshold()) # 默认 (700, 10, 10)
    # 含义:第 0 代对象数量超过 700 时触发第 0 代 GC;
    # 第 0 代 GC 每进行 10 次,触发一次第 1 代 GC;
    # 第 1 代 GC 每进行 10 次,触发一次第 2 代 GC。


    五、gc 模块:手动控制与调试

    5.1 常用函数

    函数作用
    gc.enable() / gc.disable() 启用/禁用自动垃圾回收
    gc.collect([generation]) 手动执行回收,返回回收的对象数量
    gc.get_objects() 返回当前所有被 GC 跟踪的对象列表
    gc.get_referrers(obj) 返回所有引用 obj 的对象
    gc.get_referents(obj) 返回 obj 引用的所有对象
    gc.set_debug(flags) 设置调试标志,如 gc.DEBUG_LEAK

    5.2 手动回收示例

    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

    5.3 调试内存泄漏

    import gc

    gc.set_debug(gc.DEBUG_LEAK) # 打印所有无法回收的对象

    # 创建循环引用且包含 __del__ 方法(导致无法回收)
    class Leak:
    def __del__(self):
    pass

    x = Leak()
    y = Leak()
    x.ref = y
    y.ref = x

    del x, y
    gc.collect() # 控制台会输出警告:uncollectable objects


    六、内存泄漏场景与排查实战

    6.1 常见泄漏场景

  • 全局缓存未清理:使用 list 或 dict 作为全局缓存,不断添加数据但从不删除。
  • 闭包持有大对象:函数内部定义函数,内部函数引用了外部函数的局部变量(如大列表),导致外部函数返回后变量无法释放。
  • __del__ 与循环引用:循环引用中的对象定义了 __del__ 方法,GC 无法回收(因为无法确定销毁顺序)。
  • C 扩展模块:使用 ctypes 或 Cython 时,手动分配的内存未释放。
  • 线程与 threading.local:线程局部变量在线程结束后未清理。
  • matplotlib 等 GUI 库:未关闭的 Figure 对象持续占用内存。
  • 6.2 排查工具与方法

    方法一: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

    # 排序输出前 20 个
    sorted_counts = sorted(obj_counts.items(), key=lambda x: x, reverse=True)
    for name, count in sorted_counts[:20]:
    print(f"{name}: {count}")

    方法二:objgraph 库(第三方)

    pip install objgraph

    import objgraph

    # 显示增长最多的对象
    objgraph.show_growth(limit=10)

    # 生成引用关系图(需要 graphviz)
    objgraph.show_backrefs([my_object], filename="backrefs.png")

    方法三: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)

    方法四:memory_profiler 逐行分析

    pip install memory_profiler

    @profile
    def my_func():
    # 你的代码
    pass

    运行:python -m memory_profiler script.py

    6.3 实战案例:修复一个泄漏

    问题:一个 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 或使用 tracemalloc
    生产环境 监控 gc.get_count() 和 gc.get_stats() 指标

    八、结语

    Python 的内存管理看似“黑盒”,但通过理解引用计数、分代回收和 gc 模块,我们完全可以掌控内存的来龙去脉。记住:自动回收不是万能药,良好的编码习惯才是内存安全的根本。希望本文能帮你写出更健壮、更高效的 Python 代码。


    如果你有更多内存泄漏的排查经验,欢迎在评论区分享!

    赞(0)
    未经允许不得转载:171主机测评 » 【python零基础教程第17讲】内存管理与垃圾回收
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址