今天要讲的是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)遍历,从起点处开始遍历,对遍历到的节点做一个记号,遍历完成后对所有的节点扫一遍,没有被做记号的,就是需要垃圾回收。

12-26 15:22