Python 这么高级的编程语言,怎么还能写出内存泄漏?

点赞、收藏、加关注,下次找我不迷路

咱都知道 Python 这编程语言,开发效率那叫一个高,语法还简洁得很,深受广大开发者喜爱。可你说这么厉害的 Python,咋就会写出内存泄漏呢?今天咱就好好聊聊这个事儿,让大家在写 Python 代码时少踩坑。


一、啥是内存泄漏?先把概念搞明白

我觉得吧,内存泄漏其实就跟咱们家里收拾屋子一样。咱们的程序在运行的时候,会申请各种内存空间,就像往家里搬各种东西。正常情况下,当这些东西没用了,咱们就会把它们扔掉,释放出空间。但内存泄漏呢,就是该扔掉的东西没扔掉,一直在占用内存空间,时间长了,内存就越来越不够用,程序可能就会变慢,甚至崩溃。

在 Python 里,虽然有自动垃圾回收机制,会帮咱们处理一些不再使用的对象,回收内存。但这并不意味着咱们就完全不用担心内存泄漏了。有时候啊,一些特殊的情况会让垃圾回收机制没办法正常工作,或者咱们自己写代码的时候不小心留下了 “漏洞”,导致对象无法被正确释放,这就产生内存泄漏啦。


二、Python 内存泄漏的常见原因,这几种情况要注意

(一)循环引用:对象互相抱着不撒手

我发现循环引用是 Python 里比较常见的内存泄漏原因之一。啥是循环引用呢?比如说两个对象,互相引用对方,就像两个人互相抱着对方的胳膊,谁也不松手。这时候,如果没有其他引用指向它们,按道理来说,它们应该被垃圾回收掉。但如果垃圾回收机制没有开启,或者在某些情况下没有正确处理,它们就会一直占用内存。

举个例子,咱们定义两个类,A 和 B,在 A 里有一个属性指向 B 的实例,在 B 里也有一个属性指向 A 的实例。当我们创建这两个实例,然后去掉外部对它们的引用后,如果没有循环引用的处理,它们就会一直存在内存中。

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
del b


这时候,如果没有垃圾回收机制介入,这两个对象就会一直占用内存。不过 Python 的垃圾回收机制默认是会处理循环引用的,但前提是对象定义了__del__方法,或者循环引用中包含不可回收的对象,就可能导致垃圾回收无法正确处理,从而引发内存泄漏。

(二)闭包问题:内部函数记住了不该记住的东西

闭包大家都知道吧,就是内部函数引用了外部函数的变量。正常情况下,闭包没问题,但如果在闭包中引用了较大的对象,或者在某些循环中频繁创建闭包,而闭包又长时间不释放,就可能导致内存泄漏。

比如说,我们有一个外部函数,里面定义了一个列表,然后内部函数对这个列表进行操作,并且返回这个内部函数。如果我们在循环中不断创建这样的闭包,而这些闭包一直被保留着,那么列表就会一直被引用,无法释放。

def outer():
    large_list = [i for i in range(100000)]
    def inner():
        return large_list
    return inner

# 在循环中创建闭包
closures = []
for _ in range(1000):
    closures.append(outer())


这样一来,每个闭包都引用了一个很大的列表,这些列表就不会被释放,导致内存占用越来越大。

(三)全局变量:一直存在的 “常驻嘉宾”

全局变量在程序运行期间一直存在,如果我们不小心在全局作用域中创建了大量的对象,或者这些对象被不断修改、添加内容,而没有被正确释放,就会导致内存泄漏。

比如说,我们有一个全局列表,在一个循环中不断向里面添加元素,却没有清空它的操作,那么这个列表就会越来越大,占用的内存也越来越多。

global_list = []

def add_to_global_list():
    for i in range(1000):
        global_list.append(i)

# 多次调用
for _ in range(1000):
    add_to_global_list()

这样下去,global_list会变得非常大,内存就这么被占用了。

(四)装饰器使用不当:额外的引用带来麻烦

装饰器是 Python 中很方便的特性,但如果使用不当,也可能导致内存泄漏。比如说,装饰器在包装函数的时候,可能会引入额外的引用,导致被装饰的函数对象无法被正确释放。

比如下面这个例子,装饰器返回了一个新的函数,而这个新函数引用了被装饰函数的一些变量,如果这些变量是大型对象,并且装饰后的函数被大量使用且长时间存在,就可能导致内存泄漏。

def decorator(func):
    def wrapper(*args, **kwargs):
        return func(*args, **kwargs)
    return wrapper

@decorator
def my_function():
    large_data = [i for i in range(100000)]
    # 处理数据
    pass

# 多次调用
for _ in range(1000):
    my_function()


这里的large_data虽然在函数内部创建,但如果装饰器的 wrapper 函数有对func的引用,而func又保留了对large_data的引用(比如在闭包中),就可能导致问题。不过这种情况相对较少,需要具体情况具体分析。

(五)对象缓存不当:缓存成了 “内存黑洞”

有时候我们为了提高程序效率,会使用对象缓存,比如缓存一些频繁创建和销毁的对象。但如果缓存的策略不当,比如缓存的对象数量没有限制,或者缓存的对象长时间不会被访问,却一直存在缓存中,就会导致内存泄漏。

比如我们有一个缓存字典,不断向里面添加对象,却没有设置过期时间或者最大容量,那么随着时间的推移,缓存会越来越大,占用大量内存。

cache = {}

def get_object(key):
    if key not in cache:
        cache[key] = create_large_object()  # 创建一个大型对象
    return cache[key]

# 频繁调用,添加不同的key
for i in range(10000):
    get_object(i)

(六)C 扩展模块问题:来自 “底层” 的隐患

当我们使用 C 扩展模块时,如果在 C 代码中没有正确释放内存,Python 的垃圾回收机制是无法处理的,这就会导致内存泄漏。因为 C 扩展模块中的内存管理是手动的,如果开发者在编写 C 代码时忘记释放内存,或者释放不正确,就会留下隐患。

比如说,在 C 扩展中分配了一块内存,但是没有对应的释放操作,那么每次调用相关函数时,都会泄漏一块内存,积累起来就会很严重。

为了让大家更清楚地理解这些常见原因,我整理了一个表格:

常见原因

具体场景

示例代码

解决方案

循环引用

对象互相引用,且包含__del__方法或不可回收对象

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

del b

避免在对象中使用__del__方法,必要时手动打破循环引用,如设置为 None

闭包问题

内部函数引用外部大型对象,且闭包长时间存在

def outer():

large_list = [i for i in range(100000)] def inner():

return large_list

return inner

closures = []

for _ in range(1000):

closures.append(outer())

尽量避免在闭包中引用大型对象,或者及时释放闭包引用

全局变量

全局作用域中存在不断增长的对象

global_list = []

def add_to_global_list():

for i in range(1000):

global_list.append(i)

for _ in range(1000):

add_to_global_list()

合理使用全局变量,及时清空不再使用的全局对象

装饰器使用不当

装饰器引入额外引用,导致被装饰函数对象无法释放

def decorator(func):

def wrapper(*args, **kwargs):

return func(*args, **kwargs)

return wrapper

@decorator

def my_function():

large_data = [i for i in range(100000)]


for _ in range(1000):

my_function()

注意装饰器的实现,避免不必要的引用

对象缓存不当

缓存无限制增长

cache = {}

def get_object(key):

if key not in cache:

cache[key] = create_large_object()

return cache[key]

for i in range(10000):

get_object(i)

设置缓存的最大容量或过期时间,定期清理缓存

C 扩展模块问题

C 代码中未正确释放内存

// C 扩展代码示例,分配内存未释放PyObject* my_function(PyObject* self, PyObject* args) { char* data = (char*)malloc(1024); // 处理数据 return Py_None;}

在 C 代码中确保内存正确分配和释放


三、怎么检测内存泄漏?

(一)tracemalloc:Python 自带的内存跟踪工具

我觉得 tracemalloc 是个挺好用的工具,它可以跟踪内存分配,让我们知道哪些地方分配了内存,以及分配的数量。使用起来也不难,首先导入 tracemalloc 模块,然后启动跟踪,在程序运行一段时间后,获取内存分配的统计信息。

import tracemalloc

tracemalloc.start()

# 这里运行可能存在内存泄漏的代码
time.sleep(10)  # 模拟程序运行

snapshot = tracemalloc.take_snapshot()
top_stats = snapshot.statistics('lineno')

for stat in top_stats[:10]:
    print(stat)

通过分析这些统计信息,我们可以找到内存分配较多的代码行,进而排查问题。

(二)objgraph:用图形化展示对象引用关系

objgraph 可以帮我们生成对象的引用关系图,这样我们就能直观地看到哪些对象之间存在循环引用等问题。比如我们可以用objgraph.show_refs()函数来显示某个对象的引用关系。

import objgraph

# 假设obj是可能存在泄漏的对象
objgraph.show_refs(obj, filename='refs.png')

打开生成的图片,就能清楚地看到对象之间的引用情况,方便我们找出循环引用等问题。

(三)memory_profiler:逐行分析内存使用情况

memory_profiler 可以按行分析代码的内存使用情况,让我们知道每一行代码执行时内存的变化。使用时需要在函数定义前加上@profile装饰器,然后通过命令行运行脚本,生成内存使用报告。

from memory_profiler import profile

@profile
def my_function():
    large_list = [i for i in range(100000)]
    # 其他操作

my_function()

运行命令python -m memory_profiler script.py,就可以得到详细的内存使用情况,哪一行代码导致内存增加一目了然。


四、避免内存泄漏,这些技巧要牢记

(一)尽量避免循环引用

在编写类的时候,尽量不要让对象之间形成循环引用。如果实在无法避免,比如在某些数据结构中需要互相引用,那么在适当的时候,手动将引用设置为None,打破循环。比如在对象的__del__方法中,或者在对象不再使用时,主动断开引用。

(二)合理使用闭包

在使用闭包时,注意不要在闭包中引用大型对象。如果确实需要引用,尽量在闭包使用完毕后,及时释放对闭包的引用,比如将闭包变量设置为None,让垃圾回收机制可以回收相关对象。

(三)控制全局变量的使用

尽量减少全局变量的使用,特别是那些会不断增长的全局对象。如果必须使用全局变量,在不需要的时候,及时清空它们的值,比如调用列表的clear()方法,或者重新赋值为一个空对象。

(四)谨慎使用装饰器

在编写装饰器时,注意不要引入不必要的引用。如果装饰器会包装函数,尽量使用functools.wraps来保留被装饰函数的元信息,避免不必要的引用保留。同时,对于装饰器中可能引用的大型对象,要注意释放。

(五)合理设计对象缓存

如果使用对象缓存,一定要设置合理的缓存策略。比如设置缓存的最大容量,当缓存超过容量时,按照一定的策略(如最近最少使用)删除旧的对象。或者设置缓存对象的过期时间,让长时间未使用的对象自动从缓存中移除。

(六)注意 C 扩展模块的内存管理

如果我们自己编写 C 扩展模块,或者使用第三方的 C 扩展模块,一定要确保其中的内存分配和释放是正确的。对于 C 代码中的malloc等分配内存的函数,必须有对应的free释放内存,避免出现内存泄漏。

(七)定期监控内存使用

在程序运行过程中,定期使用内存检测工具(如前面提到的 tracemalloc、memory_profiler 等)监控内存使用情况,及时发现潜在的内存泄漏问题。特别是在程序长时间运行的场景下,内存泄漏的影响会逐渐积累,定期监控可以让我们尽早发现并解决问题。

(八)了解垃圾回收机制

虽然 Python 的垃圾回收机制会自动处理大部分对象的回收,但我们也要了解它的工作原理和一些限制。比如循环引用的处理,当对象定义了__del__方法时,垃圾回收机制可能无法正确处理循环引用,这时候我们就要特别注意,避免在这种情况下出现循环引用。


说了这么多,大家应该明白,虽然 Python 是一门高级编程语言,有很多方便的特性和自动内存管理机制,但这并不意味着我们可以完全忽略内存泄漏的问题。内存泄漏可能出现在各种情况下,需要我们在编写代码时保持细心,注意各种可能导致泄漏的原因,合理使用各种工具进行检测和调试。

如果大家在实际开发中遇到了内存泄漏的问题,或者有什么好的解决经验,欢迎在评论区留言分享,咱们一起交流学习,共同进步!