Python 垃圾回收
今天要讲的是Python的垃圾回收机制
众所周知,我们现在的计算机都是图灵架构。图灵架构的本质,就是一条无限长的纸带,对应着我们的存储器。随着寄存器、异失性存储器(内存)和永久性存储器(硬盘)的出现,也出现了一个矛盾——存储器越来越快,价格也越来越贵。因此,如何利用好每一份告诉存储器的控件,永远是系统设计的一个核心。
回到Python的应用:Python程序在运行的时候,需要在内存中开辟一块空间,用于存放运行时产生的临时变量;计算完成后,再将结果输出到永久性存储器中。如果数据量过大,内存空间管理不善jui很容易出现OOM(out of memory)的现象,程序就会被系统中断
而对于服务器来说,这种设计对于不中断的系统哦过来说,内存管理就显得尤为重要,不然很容易引发内存泄漏的现象。
什么是内存泄漏?
这里的泄漏,并不是说内存出现了信息安全的问题,被恶意程序利用了,而是指程序没有设计好,导致程序未能释放已经不再使用的内存
内存泄漏也不是指内存在物理上消失了,而是意味着代码在分配了某段内存后,因为设计失误,市区了对这块内存的控制,从而导致了内存资源的浪费。
那么,Python优势如何解决这些问题的呢?更明确的问题:对于不会再次用到的内存空间,Python又是通过什么机制来回收的呢?
计数引用
我们在前面不停的强调过,Python中一切皆为对象,因此,我们所有的一切变量,本质上都是对象的一个指针,那么如何知道一个对象,是否永远都不被调用了呢?
我们在上一章讲的一个非常直观的思路,就是当这个对象的引用计数(类似于指针)为0的时候,说明这个对象用不可达,呢么这个时候,它也就自然成为了垃圾,需要被回收。
我们这时候看看下面的例子:
import os
import psutil
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)
info = p.memory_full_info
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(100000000)]
show_memory_info('after a created')
func()
show_memory_info('finished')
通过这个例子,我们可以看出来,在调用甘薯func后,列表a被创建,内存就会占用比较多,但是在函数调用以后内存则返回正常。
这是因为函数内部声明列表a是局部变量,在函数返回以后,局部变量的引用会注销掉;此时,列表a所指代的对象引用数为0,Python变回执行垃圾回收,因此之前占用的大量内存就被释放回来了。
然后我们把代码稍微修改一下(我们只改动func函数)
def func():
show_memory_info('initial')
global a
a = [ i for i in range(100000000)]
show_memory_info('after a created')
func()
show_memory_info('finished')
我们在上面的代码里,把a声明为全局变量,那么即使函数返回以后,垃圾回收就不会被触发,大量的内存仍然被占用。或者下面的方式也是一样的
def func():
show_memory_info('initial')
a = [ i for i in range(100000000)]
show_memory_info('after a created')
return a
a = func()
show_memory_info('finished')
这里,函数通过返回值,生成的列表依旧是被引用的,所以垃圾回收也没被触发。
上面就是最常见的几种情况。由表及里,下面,我们深入看一下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))
这里我们引入一个新的函数
sys.getrefcount()
这个函数,是可以查看一个变量的引用次数。这段代码本身应该很好理解,但是,getrefcount本身也会引入一次计数。
另一个要注意的点,在函数发生调用的时候,会产生额外的两次引用,一次来自函数栈,另一个是函数参数。
import sys
a = []
b = a
print(sys.getrefcount(a))
#3次引用
c = b
d = b
e = c
f = e
g = d
print(sys.getrefcount(a))
#8次引用
看看这段代码,稍稍注意一下,a、b、c、d、e、f、g这些变量指的是同一个变量,而sys.getrefcount()并不是统计一个指针,而是要统计一个对象被引用的次数,所以最后会有8次引用。
当我们理解了引用这个概念以后,引用释放是一种非常自然和清晰的思想。相比C语言里,我们需要用free去手动释放内存,Python的垃圾回收机制就显得省心省力了。
可是,如果我们想用手动的方式释放内存,又该怎么操作呢?
其实我们首先用del来删除对象的引用,然后强制调用gc.collect()清除没有引用的对象,就可以手动启动垃圾回收。
import sys
import gc
a = [i for i in range(100000000)]
del a
gc.collect()
按照上面的方法就实现了手动的垃圾回收。
这里可以考虑一个问题:
引用次数为0是垃圾回收启动的充要条件么?
我们可以一步一步的看:先看看下面的代码
def fun():
show_memory_info('initial')
a = [i for i in range(100000000)]
b = [i for i in range(100000000)]
show_memory_info('after a,b created')
a.append(b)
b.append(a)
fun()
show_memory_info('finish')
在上面的程序段里,a和b列表互相引用,并且是作为局部变量的,但是在函数fun调用以后,a和b的指针从程序意义上已经不存在了,但是很明显的,依然有内存占用!这是为什么呢?因为互相引用,导致他们的引用数都不为0。
再想一想,如果这段代码实在实际生产环境中,即便是a和b开始的时候占用的空间没有很大,但是经过长时间的运行以后,Python所占用的内存会原来越大。最终服务器就爆掉了,后果不堪设想。
虽然在很多的环境下互相引用很容易被发现,问题不会特别大,但是更隐蔽的情况是一个引用环的出现,在工程代码比较复杂的情况下,引用环是很不容易被发现的。那我们又该怎么办呢?这种情况下,我们就需要我们前面所讲的,显式的调用gc.collect()来启动垃圾回收。
import gc
def fun():
show_memory_info('initial')
a = [i for i in range(100000000)]
b = [i for i in range(100000000)]
show_memory_info('after a,b created')
a.append(b)
b.append(a)
fun()
gc.collect()
show_memory_info('finish')
Python使用标记清除(mark-sweep)算法和分代收集(generational),来针对循环引用的自动垃圾回收,我们在这里还可以简单的介绍一下
标记清除算法
我们用一个先导图的方式来理解不可达这个概念,对于一个有向图,如果从一个节点触发进行遍历,并标记出来其经过的所有节点,那么,在遍历结束后,所有没有被标记出来的节点我们都将其称之为不可达节点,显而易见,这些节点的存在是没有任何意义的,这个时候我们就需要对其进行垃圾回收。
但是,每次遍历全图对Python而言是一种巨大的性能浪费,所以,在Python的垃圾回收实现中,mark-sweep使用双向链表维护一个数据结构,并且只考虑容器类的对象(只有容器类对象才能产生循环引用)。具体的算法我们这里就不讲了,只是看看大概的实现思路是什么
而分代收集算法,则是另一个优化手段
Python讲所有的对象都分为3代,刚刚创立的对象是第0代,经历过一次垃圾回收的对象,变回依次从上一代挪到下一代。而每一代启动自动垃圾回收的阈值,则是可以单独指定的。当垃圾回收容器中新增对象减去删除对象达到相应的阈值时,就会对这一代对象启动垃圾回收。
基于分代收集的思想是,新生的对象更有可能被垃圾回收,而存活更久的对象也有更高的概率继续存活。因此,这一种做法可以节约不少计算量,从而提高了Python的性能。
回到刚才的那个问题,引用计数是其中最简单的实现,不过引用计数并非其充要条件,他只能作为充分不必要条件;至于其他的可能性,我们所讲的循环引用正式其中一种。
调试内存泄漏
即便是有了自动回收机制,但切记这也不是万能的。内存泄漏是我们不想见到的十分影响性能的。有没有什么调试的手段呢?下面我们就来介绍以为十分得力的助手一名——objgraph,他是一个非常好用的可视化饮用哦过关系的包,这里就主要推荐两个函数——show_refs(),他可以生成清晰的引用关系图(objgrph可以通过pip安装,代码会生成一个.doc的文件,可以用graphviz打开,官网链接,或者直接从网盘上下载(提取码73z4)。软件在解压后bin文件夹内的GVEdit.exe。
import objgraph
a = [1,2,3]
b = [4,5,6]
a.append(b)
b.append(a)
objgraph.show_refs([a])
打开生成的图片
可以看出来生成的上面那段代码的引用调用图,很直观的发现,有两个list互相引用,说明很容易引起内存泄漏。这样就很容易去排插代码层。
另一个非常有用的函数是show_backrefs(),我们还用上面的两个列表来展示一下:
import objgraph
a = [1,2,3]
b = [4,5,6]
a.append(b)
b.append(a)
objgraph.show_backrefs([a])
再看一下生成的图片
这个图就稍微复杂了一些,但是这个API内包含了更多的参数,我们在使用之前可以了解一下他的官方文档。
总结
最后我们来总结一下这一章节的内容
1.垃圾回收是Python自带的机制,用于释放不会再用到的内存空间;
2.引用计数是其中最简单的实现方法,不过要注意,他只是个充分非必要条件,因为循环引用需要通过不可达判定释放可以回收;
3.Python的自动回收算法包括标记清除和分代收集,主要针对的是循环引用的垃圾收集;
4.调试内存泄漏可以用objgraph这个可视化的分析工具。
课后思考
自己如何实现一个垃圾回收的判定方法呢?要求比较简单:输入一个有向图,给定起点表示程序入口点,给定有向边,输出不可达节点。
实现思路:这是个比较经典的深度优先搜索(dfs)遍历,从起点处开始遍历,对遍历到的节点做一个记号,遍历完成后对所有的节点扫一遍,没有被做记号的,就是需要垃圾回收。