1. 内存泄漏初探:什么是内存泄漏?为什么Python也会有内存泄漏?一个简单的泄漏示例

1.1 先聊聊“内存泄漏”到底是个啥

说实话,我刚入行那会儿,觉得内存泄漏是C/C++的专利。你想啊,Python不是有垃圾回收(GC)吗?变量不用了,解释器自动帮你清理,怎么还会泄漏?

但现实狠狠打了我的脸。有一次我负责一个长期运行的爬虫服务,跑了大概三天,内存从200MB飙到了2GB,最后直接OOM被系统kill了。排查了半天,发现罪魁祸首竟然是一个小小的循环引用——嗯,Python的GC在某些情况下确实会“偷懒”。

说白了,内存泄漏就是程序申请了内存,用完之后却不还给系统。在Python里,虽然GC会帮你回收大部分垃圾,但总有一些“漏网之鱼”——比如全局缓存、闭包变量、循环引用中的对象,GC可能来不及处理,或者根本处理不了。

核心定义:内存泄漏是指程序在运行过程中,动态分配的内存由于某种原因未能被释放,导致可用内存逐渐减少,最终影响程序性能甚至导致崩溃。

1.2 Python为什么也会有内存泄漏?

你可能会问:“Python不是有引用计数和标记-清除吗?” 没错,Python的GC确实有两把刷子:

  • 引用计数:每个对象都记录自己被引用的次数,次数归零就立即回收。这是主力,处理了90%以上的垃圾。
  • 标记-清除(分代回收):处理循环引用等引用计数搞不定的情况。Python把对象分成三代,新对象放第0代,熬过一轮GC就升一代。GC会定期扫描,标记可达对象,清除不可达的。

但问题就出在这里——标记-清除不是实时触发的。它只在特定阈值(比如第0代对象数量超过700个)时才启动。如果你的程序创建了大量临时对象,但GC还没来得及跑,内存就已经被吃光了。

另外,有些对象GC根本不敢碰。比如全局变量、模块级别的缓存、单例对象——这些对象的引用计数永远不会归零,GC也不会去回收它们。如果你不小心把大量数据塞进了全局列表里,那这些数据就会一直活着,直到程序退出。

我的经验:我曾经接手过一个Flask应用,每次请求都会往一个全局字典里塞日志数据。结果运行一周后,那个字典占了1.5GB内存。解决方案很简单——改用Redis或者限制字典大小。但排查过程花了我整整两天。

1.3 一个简单的泄漏示例

光说不练假把式。咱们直接看代码,感受一下Python内存泄漏是怎么发生的。

import gc
import sys

# 示例1:循环引用导致的内存泄漏
class Node:
    def __init__(self, name):
        self.name = name
        self.parent = None
        self.children = []

def create_leak():
    # 创建两个节点,互相引用
    a = Node("A")
    b = Node("B")
    a.children.append(b)
    b.parent = a  # 循环引用形成!
    # 函数结束后,a和b的引用计数都不为0
    # 因为a引用了b,b又引用了a
    # 但它们是局部变量,外部无法访问
    # 这就是“不可达但引用计数不为0”的垃圾

# 调用函数,创建泄漏
create_leak()

# 手动触发GC,看看能不能回收
print("GC收集前,不可达对象数量:", gc.collect())
# 输出:GC收集前,不可达对象数量: 2
# 说明GC成功回收了这两个对象

# 但如果我们禁用GC呢?
gc.disable()
create_leak()
print("GC禁用后,不可达对象数量:", gc.collect())
# 输出:GC禁用后,不可达对象数量: 2
# 说明即使GC禁用,手动调用collect也能回收

# 真正的泄漏:全局缓存
cache = []
def leak_by_cache():
    for i in range(100000):
        # 每次调用都往全局列表里塞大对象
        cache.append([0] * 1000)  # 每个列表约8KB
    # 这些对象永远不会被回收,因为cache是全局变量

# 调用一次,内存暴涨
leak_by_cache()
print("cache大小:", len(cache))  # 100000
# 这些数据会一直占用内存,直到程序结束

这个例子展示了两种泄漏场景:

  1. 循环引用:两个对象互相引用,导致引用计数无法归零。但Python的GC(如果启用)能通过标记-清除回收它们。所以严格来说,这不是“泄漏”,而是“延迟回收”。
  2. 全局缓存:这才是真正的泄漏。全局变量cache一直持有对象的引用,GC永远不会回收它们。随着程序运行,cache越来越大,内存越吃越多。

注意:很多人以为Python的GC能解决所有问题。实际上,GC只处理“不可达”的对象。如果你的对象被全局变量、类属性、闭包等“可达”引用链持有,GC是不会碰它们的。这就像你家里的垃圾——如果一直攥在手里,清洁工是不会帮你扔掉的。

1.4 知识体系总览

为了让你更直观地理解内存泄漏的来龙去脉,我画了一张图:

Python内存泄漏知识体系 内存泄漏 什么是内存泄漏? 为什么Python也有? 内存未释放 性能下降/OOM 长期运行服务 引用计数缺陷 GC非实时触发 全局变量/缓存 示例:循环引用 + 全局缓存

1.5 怎么判断自己遇到了内存泄漏?

我个人的经验是,别等到程序崩了才去查。平时养成几个习惯:

  • 观察内存趋势:用ps aux或者top看进程的RES(常驻内存)。如果持续增长,而且GC后也不下降,那基本就是泄漏了。
  • 用tracemalloc:Python自带的tracemalloc模块可以追踪内存分配。我一般会在开发环境开启它,定位大对象。
  • 检查全局变量:用gc.get_objects()列出所有被GC跟踪的对象,看看有没有不该存在的大家伙。

一个小技巧:我曾经在排查一个Web服务时,发现每次请求后内存都会涨几MB。用objgraph库画了个引用图,发现是日志处理器持有了请求对象的引用。去掉那个处理器后,内存就稳了。所以,善用工具比瞎猜有效得多。

1.6 小结

这一章我们聊了:

  • 内存泄漏的本质——申请了内存不还
  • Python为什么也会有——引用计数+GC的局限性
  • 一个简单的泄漏示例——循环引用和全局缓存

说白了,Python的内存泄漏不是GC的锅,而是我们写代码时埋下的坑。下一章我会带你深入分析一个真实项目中的泄漏案例,看看怎么一步步定位和修复。嗯,到时候你会看到,排查泄漏其实就像侦探破案——需要耐心,也需要工具。


可可精品资料库