我们知道,目前的计算机都采用的是图灵机架构,其本质就是用一条无限长的纸带,对应今天的存储器。随后在工程学的推演中,逐渐出现了寄存器、易失性存储器(内存)以及永久性存储器(硬盘)等产品。由于不同的存储器,其速度越快,单位价格也就越昂贵,因此,妥善利用好每一寸告诉存储器的空间,永远是系统设计的一个核心。
Python 程序在运行时,需要在内存中开辟出一块空间,用于存放运行时产生的临时变量,计算完成后,再将结果输出到永久性存储器中。但是当数据量过大,或者内存空间管理不善,就很容易出现内存溢出的情况,程序可能会被操作系统终止。
而对于服务器这种用于永不中断的系统来说,内存管理就显得更为重要了,不然很容易引发内存泄漏。
这里的泄漏,并不是说你的内存出现了信息安全问题,被恶意程序利用了,而是指程序本身没有设计好,导致程序未能释放已不再使用的内存。内存泄漏也不是指你的内存在物理上消失了,而是意味着代码在分配了某段内存后,因为设计错误,失去了对这段内存的控制,从而造成了内存的浪费。
那么,对于不会再用到的内存空间,Python 是通过什么机制来管理的呢?其实在前面章节已大致接触过,就是引用计数机制。
1.Python计数机制
Python垃圾回收主要以引用计数为主,分代回收为辅。引用计数法的原理是每个对象维护一个ob_ref,用来记录当前对象被引用的次数,也就是来追踪到底有多少引用指向了这个对象,当发生以下四种情况的时候,该对象的引用计数器+1:
1. 对象被创建 a=14
2. 对象被引用 b=a
3. 对象被作为参数,传到函数中 func(a)
4. 对象作为一个元素,存储在容器中 List={a,”a”,”b”,2}
与上述情况相对应,当发生以下四种情况时,该对象的引用计数器-1
1. 当该对象的别名被显式销毁时 del a
2. 当该对象的引别名被赋予新的对象, a=26
3. 一个对象离开它的作用域,例如 func函数执行完毕时,函数里面的局部变量的引用计数器就会减一(但是全局变量不会)。
4. 将该元素从容器中删除时,或者容器被销毁时。
当指向该对象的内存的引用计数器为0的时候,该内存将会被Python虚拟机销毁。
在学习 Python 的整个过程中,我们一直在强调,Python 中一切皆对象,也就是说,在 Python 中你用到的一切变量,本质上都是类对象。
那么,如何知道一个对象永远都不能再使用了呢?很简单,就是当这个对象的引用计数值为 0 时,说明这个对象永不再用,自然它就变成了垃圾,需要被回收。
我们来看一个例子:
import os
import psutil
# 显示当前 python 程序占用的内存大小
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)
info = p.memory_full_info()
memory = info.uss / 1024. / 1024
print('{} memory used: {} MB'.format(hint, memory))
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
func()
show_memory_info('finished')
########## 输出 ##########
initial memory used: 47.19140625 MB
after a created memory used: 433.91015625 MB
finished memory used: 48.109375 MB
通过这个示例,你可以看到,调用函数 func(),在列表 a 被创建之后,内存占用迅速增加到了 433 MB:而在函数调用结束后,内存则返回正常。
这是因为,函数内部声明的列表 a 是局部变量,在函数返回后,局部变量的引用会注销掉;此时,列表 a 所指代对象的引用数为 0,Python 便会执行垃圾回收,因此之前占用的大量内存就又回来了。
明白了这个原理后,我们稍微修改一下代码:
| def func(): show_memory_info(‘initial’) global a a = [i for i in range(10000000)] show_memory_info(‘after a created’) func() show_memory_info(‘finished’)
#### 输出
initial memory used: 48.88671875 MB after a created memory used: 433.94921875 MB finished memory used: 433.94921875 MB | | —- |
新的这段代码中,global a 表示将 a 声明为全局变量。那么,即使函数返回后,列表的引用依然存在,于是对象就不会被垃圾回收掉,依然占用大量内存。
同样,如果我们把生成的列表返回,然后在主程序中接收,那么引用依然存在,垃圾回收就不会被触发,大量内存仍然被占用着:
| def func(): show_memory_info(‘initial’) a = [i for i in range(10000000)] show_memory_info(‘after a created’) return a
a = func() show_memory_info(‘finished’)
#### 输出
initial memory used: 47.96484375 MB after a created memory used: 434.515625 MB finished memory used: 434.515625 MB | | —- |
这是最常见的几种情况。由表及里,下面,我们深入看一下 Python 内部的引用计数机制。先来看代码:
| import sys
a = []
两次引用,一次来自 a,一次来自 getrefcount
print(sys.getrefcount(a))
def func(a):
# 四次引用,a,python 的函数调用栈,函数参数,和 getrefcount
print(sys.getrefcount(a))
func(a)
两次引用,一次来自 a,一次来自 getrefcount,函数 func 调用已经不存在
print(sys.getrefcount(a))
#### 输出
2 4 2 | | —- |
简单介绍一下,sys.getrefcount() 这个函数,可以查看一个变量的引用次数。这段代码本身应该很好理解,不过别忘了,getrefcount 本身也会引入一次计数。
另一个要注意的是,在函数调用发生的时候,会产生额外的两次引用,一次来自函数栈,另一个是函数参数。
| import sys
a = []
print(sys.getrefcount(a)) # 两次
b = a
print(sys.getrefcount(a)) # 三次
c = b d = b e = c f = e g = d
print(sys.getrefcount(a)) # 八次
#### 输出
2 3 8 | | —- |
看到这段代码,需要你稍微注意一下,a、b、c、d、e、f、g 这些变量全部指代的是同一个对象,而 sys.getrefcount() 函数并不是统计一个指针,而是要统计一个对象被引用的次数,所以最后一共会有八次引用。
理解引用这个概念后,引用释放是一种非常自然和清晰的思想。相比 C 语言里,你需要使用 free 去手动释放内存,Python 的垃圾回收在这里可以说是省心省力了。
不过,我想还是会有人问,如果我偏偏想手动释放内存,应该怎么做呢?
方法同样很简单。你只需要先调用 del a 来删除对象的引用;然后强制调用 gc.collect(),清除没有引用的对象,即可手动启动垃圾回收。
举例说明:
| >>> import sys
a = []
b = a
sys.getrefcount(a)
3
import gc
del a
gc.collect()
sys.getrefcount(a)
Traceback (most recent call last):
File “
NameError: name ‘a’ is not defined | | —- |
是不是觉得垃圾回收非常简单呢?这里再问大家一个问题:引用次数为 0 是垃圾回收启动的充要条件吗?还有没有其他可能性呢?
其实,引用计数是其中最简单的实现,引用计数并非充要条件,它只能算作充分非必要条件,至于其他的可能性,下面所讲的循环引用正是其中一种。
2.循环引用
先来思考这么一个问题:如果有两个对象,它们互相引用,并且不再被别的对象所引用,那么它们应该被垃圾回收吗?
请仔细观察下面这段代码:
| def func(): show_memory_info(‘initial’) a = [i for i in range(10000000)] b = [i for i in range(10000000)] show_memory_info(‘after a, b created’) a.append(b) b.append(a)
func() show_memory_info(‘finished’)
#### 输出
initial memory used: 47.984375 MB after a, b created memory used: 822.73828125 MB finished memory used: 821.73046875 MB | | —- |
这里,a 和 b 互相引用,并且,作为局部变量,在函数 func 调用结束后,a 和 b 这两个指针从程序意义上已经不存在了。但是,很明显,依然有内存占用!为什么呢?因为互相引用,导致它们的引用数都不为 0。
试想一下,如果这段代码出现在生产环境中,哪怕 a 和 b 一开始占用的空间不是很大,但经过长时间运行后,Python 所占用的内存一定会变得越来越大,最终撑爆服务器,后果不堪设想。
当然,有人可能会说,互相引用还是很容易被发现的呀,问题不大。可是,更隐蔽的情况是出现一个引用环,在工程代码比较复杂的情况下,引用环还真不一定能被轻易发现。
那么,我们应该怎么做呢?
事实上,Python 本身能够处理这种情况,我们刚刚讲过的,可以显式调用 gc.collect() ,来启动垃圾回收。
| import gc
def func(): show_memory_info(‘initial’) a = [i for i in range(10000000)] b = [i for i in range(10000000)] show_memory_info(‘after a, b created’) a.append(b) b.append(a)
func() gc.collect() show_memory_info(‘finished’)
#### 输出
initial memory used: 49.51171875 MB after a, b created memory used: 824.1328125 MB finished memory used: 49.98046875 MB | | —- |
所以你看,Python 的垃圾回收机制并没有那么弱。
Python 使用标记清除(mark-sweep)算法和分代收集(generational),来启用针对循环引用的自动垃圾回收。这里简单介绍一下。
先看标记清除算法。我们先用图论来理解不可达的概念。对于一个有向图,如果从一个节点出发进行遍历,并标记其经过的所有节点;那么,在遍历结束后,所有没有被标记的节点,我们就称之为不可达节点。显而易见,这些节点的存在是没有任何意义的,自然的,我们就需要对它们进行垃圾回收。
当然,每次都遍历全图,对于 Python 而言是一种巨大的性能浪费。所以,在 Python 的垃圾回收实现中,mark-sweep 使用双向链表维护了一个数据结构,并且只考虑容器类的对象(只有容器类对象才有可能产生循环引用)。
而分代收集算法,则是另一个优化手段。
Python 将所有对象分为三代。刚刚创立的对象是第 0 代;经过一次垃圾回收后,依然存在的对象,便会依次从上一代挪到下一代。而每一代启动自动垃圾回收的阈值,则是可以单独指定的。当垃圾回收器中新增对象减去删除对象达到相应的阈值时,就会对这一代对象启动垃圾回收。
事实上,分代收集基于的思想是,新生的对象更有可能被垃圾回收,而存活更久的对象也有更高的概率继续存活。因此,通过这种做法,可以节约不少计算量,从而提高 Python 的性能。
3.调试内存泄漏
不过,虽然有了自动回收机制,但这也不是万能的,难免还是会有漏网之鱼。内存泄漏是我们不想见到的,而且还会严重影响性能。有没有什么好的调试手段呢?
答案当然是肯定的。它就是 objgraph,一个非常好用的可视化引用关系的包。在这个包中,我主要推荐两个函数,第一个是 show_refs(),它可以生成清晰的引用关系图。
通过下面这段代码和生成的引用调用图,你能非常直观地发现,有两个 list 互相引用,说明这里极有可能引起内存泄露。这样一来,再去代码层排查就容易多了。
| import objgraph
a = [1, 2, 3] b = [4, 5, 6]
a.append(b) b.append(a)
objgraph.show_refs([a]) | | —- |
而另一个非常有用的函数,是 show_backrefs()。下面同样为示例代码和生成图:
| import objgraph
a = [1, 2, 3] b = [4, 5, 6]
a.append(b) b.append(a)
objgraph.show_backrefs([a]) | | —- |
相比刚才的引用调用图,这张图显得稍微复杂一些。不过,我仍旧推荐你掌握它,因为这个 API 有很多有用的参数,比如层数限制(max_depth)、宽度限制(too_many)、输出格式控制(filename output)、节点过滤(filter, extra_ignore)等。所以,建议你使用之前,先认真看一下文档。
总结
关于 Python 的垃圾回收机制,我主要强调下面这几点:
- 垃圾回收是 Python 自带的机制,用于自动释放不会再用到的内存空间;
- 引用计数是其中最简单的实现,都是,这只是充分非必要条件,因为循环引用需要通过不可达判定,来确定是否可以回收;
- Python 的自动回收算法包括标记清除和分代收集,主要针对的是循环引用的垃圾收集;
- 调试内存泄漏方面, objgraph 是很好的可视化分析工具。
References:
深度解析Python垃圾回收机制(超级详细): http://c.biancheng.net/view/5540.html