Python的垃圾回收机制

原创
2020/12/03 16:35
阅读数 3.7K

垃圾回收机制

垃圾回收(GC) 大家应该多多少少都了解过,什么是垃圾回收呢?垃圾回收GC的全拼是 Garbage Collection,在维基百科的定义是:在计算机科学中,垃圾回收(英语:Garbage Collection,缩写为GC)是一种自动的内存管理机制。当一个电脑上的动态内存不再需要时,就应该予以释放,以让出内存,这种内存资源管理,称为垃圾回收。我们都知道在C/C++里用户需要自己管理维护内存,自己管理内存是很自由,可以随意申请、释放内存,但是极易会出现内存泄露,悬空指针等问题;像现在的高级语言Java,Python等,都采用了垃圾回收机制,自动进行内存管理,而垃圾回收机制专注于两件事:① 找到内存中无用的垃圾资源。 ② 清除这些垃圾资源并把内存让出来给其他对象使用。

Python作为一门解释型语言,因为简单易懂的语法,我们可以直接对变量赋值,而不必声明变量的类型,变量类型的确定、内存空间的分配与释放都是由Python解释器在运行时自动进行的,我们不必关心;Python这一自动管理内存的功能极大的减少了开发者的编码负担,让开发者专注于业务实现,这也是成就Python自身的重要原因之一。接下来,我们就扒一扒python的内存管理。

引用计数机制

Python中一切皆对象,也就是说,在Python中你用到的一切变量,本质上都是类对象。实际上每一个对象的核心就是一个结构体PyObject,它的内部有一个引用计数器ob_refcnt,程序在运行的过程中会实时的更新ob_refcnt的值,来反映引用当前对象的名称数量。当某对象的引用计数值为0,说明这个对象变成了垃圾,那么它会被回收掉,它所用的内存也会被立即释放掉。

typedef struct _object {
    int ob_refcnt;//引用计数
    struct _typeobject *ob_type;
} PyObject;

以下情况是导致引用计数加一的情况:
对象被创建,例如a=5
对象被引用,b=a
对象被作为参数,传入到一个函数中(要注意的是,在函数调用发生的时候,会产生额外的两次引用,一次来自函数栈,另一个是函数参数)
对象作为一个元素,存储在容器中(例如存储在列表中)

下面的情况则会导致引用计数减一:
对象别名被显示销毁 del a
对象别名被赋予新的对象
一个对象离开它的作用域
对象所在的容器被销毁或者是从容器中删除对象

我们还可以通过sys包中的getrefcount()来获取一个名称所引用的对象当前的引用计数(注意,这里getrefcount()本身会使得引用计数加一)

import sys
a = [123]
print(sys.getrefcount(a))
# 输出为2,说明有两次引用(一次来自a的定义,一次来自getrefcount)

def func(a):
    print(sys.getrefcount(a))
    # 输出为4,说明有四次引用(a的定义、Python的函数调用栈,函数参数,和getrefcount)

func(a)
print(sys.getrefcount(a))
# 输出为2,说明有两次引用(一次来自a的定义,一次来自getrefcount),此时函数func调用已经不存在

下面从使用内存的角度看一下:

import os
import psutil


def show_memory_info(hint):
    """
    显示当前 python 程序占用的内存大小
    :param hint:
    :return:
    """
    pid = os.getpid()
    p = psutil.Process(pid)

    info = p.memory_full_info()
    memory = info.rss / 1024 / 1024
    print('{} 当前进程的内存使用: {} MB'.format(hint, memory))


def func():
    show_memory_info('初始')
    a = [i for i in range(9999999)]
    show_memory_info('创建a之后')


func()
show_memory_info('结束')

输出如下:

初始 当前进程的内存使用: 12.125 MB
创建a之后 当前进程的内存使用: 205.15625 MB
结束 当前进程的内存使用: 12.87890625 MB

可以看出,当前进程初始的内存使用为12.125 MB,当调用了函数func()创建列表a之后,内存占用迅速增加到了205.15625 MB,而在函数调用结束后,内存则返回正常。这是因为,函数内部声明的列表a是局部变量,在函数返回后,局部变量的引用会注销掉,此时列表a所指代对象的引用计数为0,Python 便会执行垃圾回收,因此之前占用的大量内存就又回来了。

循环引用

何为循环引用?简单来说就是两个对象相互引用。看下面一段程序:

def func2():
    show_memory_info('初始')
    a = [i for i in range(10000000)]
    b = [x for x in range(10000001, 20000000)]
    a.append(b)
    b.append(a)
    show_memory_info('创建a,b之后')

func2()
show_memory_info('结束')

输出如下:

初始 当前进程的内存使用: 12.14453125 MB
创建a,b之后 当前进程的内存使用: 396.6875 MB
结束 当前进程的内存使用: 396.96875 MB

可以看出,在程序中,a和b互相引用,并且作为局部变量在函数func2调用结束后,a和b从程序意义上已经不存在,但从输出结果中看到,依然有内存占用,这是为什么呢?因为互相引用导致它们的引用数都不为0。

如果在生产环境下出现了循环引用,又没有其他垃圾回收机制的情况下,经过长时间运行后,程序所占用的内存一定会变得越来越大,如果没有被及时处理,一定会跑满服务器的。

如果不得不使用循环引用的话,我们可以显式调用gc.collect() 来启动垃圾回收:

def func2():
    show_memory_info('初始')
    a = [i for i in range(10000000)]
    b = [x for x in range(10000001, 20000000)]
    a.append(b)
    b.append(a)
    show_memory_info('创建a,b之后')

func2()
gc.collect()
show_memory_info('结束')

输出如下:

初始 当前进程的内存使用: 12.29296875 MB
创建a,b之后 当前进程的内存使用: 396.69140625 MB
结束 当前进程的内存使用: 12.95703125 MB

引用计数机制有高效、简单、实时性(一旦为零就直接做掉)等优点,一旦一个对象的引用计数归零,内存就直接释放了。不用像其他机制等到特定时机。将垃圾回收随机分配到运行的阶段,处理回收内存的时间分摊到了平时,正常程序的运行比较平稳。但是,引用计数也存在着一些缺点,通常的缺点有:

① 逻辑虽然简单,但维护起来有些麻烦。每个对象需要分配单独的空间来统计引用计数,并且需要对引用计数进行维护,这是需要消耗一下资源的。
② 循环引用。这将是引用计数机制的致命伤,引用计数对此是无解的,因此必须要使用其它的垃圾回收算法对其进行补充。

事实上,Python 使用标记清除(mark-sweep)算法和分代收集(generational),来启用针对循环引用的自动垃圾回收。

标记清除解除循环引用

Python采用了 标记-清除(Mark and Sweep)算法,解决容器对象可能产生的循环引用问题。(注意,只有容器类对象才有可能产生循环引用,比如列表、字典、用户自定义类的对象、元组等。而像数字,字符串这类简单类型不会出现循环引用。作为一种优化策略,对于只包含简单类型的元组也不在标记清除算法的考虑之列)
它分为两个阶段:第一阶段是标记阶段,GC会把所有的活动对象打上标记,第二阶段是把那些没有标记的非活动对象进行回收。
那么Python又是如何判断什么样的对象为非活动对象的呢?
对于任何对象集合,我们先建个引用计数副本表,来存它们的引用计数,然后把集合内部的引用都解除掉(内部引用是指这个集合中的某个对象引用了本集合内部的另一个对象),解除的过程中在副本表减少引用计数,解除掉所有的内部引用后,在副本表引用计数依然不为0的,就是根集合,然后开始标记过程,即从跟集合节点逐步恢复引用并增加副本表的引用计数,最后副本表中引用计数为0的,就是垃圾对象了,我们就需要对它们进行垃圾回收。例如:

上面这个集合中的节点有外部进来的连接(到a和到b),也有到外部的连接(c引用了外面某个对象),右边是引用计数表,然后我们拆掉所有内部连接:

那么根集合就是a和b了,然后我们从a和b出发开始标记并恢复引用计数:

从a和b出发可达的节点都被恢复了,引用计数还是0的就是这个集合内部循环引用的垃圾(e和f),如果把所有对象看做一个集合,那么可以回收所有垃圾,也可以将所有对象划分成一个个小的集合,分别回收小集合内的垃圾。
但是每次都需要遍历图,对于Python而言是一种巨大的性能浪费。

分代回收

分代回收是一种以空间换时间的操作方式,Python将内存根据对象的存活时间划分为不同的集合,每个集合称为一个代,Python将内存分为了3代,分别为年轻代(第0代)、中年代(第1代)、老年代(第2代)。它们对应3个链表,它们的垃圾收集频率随对象的存活时间的增大而减小。
新创建的对象都会分配在年轻代,年轻代链表的总数达到上限时,即当垃圾回收器中新增对象减去删除对象达到相应的阈值时,就会对这一代对象启动垃圾回收,把那些可以被回收的对象回收掉,而那些不会回收的对象就会被移到中年代去,依此类推,老年代中的对象是存活时间最久的对象,甚至是存活于整个系统的生命周期内。同时,分代回收是建立在标记清除技术基础之上。事实上,分代回收基于的思想是,新生的对象更有可能被垃圾回收,而存活更久的对象也有更高的概率继续存活。因此,通过这种做法,可以节约不少计算量,从而提高Python的性能。

总结

垃圾回收是Python自带的机制,用于自动释放不会再用到的内存空间,在Python中,主要通过引用计数进行垃圾回收,通过标记清除解决容器对象可能产生的循环引用问题,通过分代回收以空间换时间的方法提高垃圾回收效率。

最后,感谢女朋友在工作和生活中的包容、理解与支持 !

展开阅读全文
打赏
1
4 收藏
分享
加载中
深度好文,过段推荐给大家看看!
2020/12/03 18:10
回复
举报
tigeriaf博主
哈哈,谢谢夸奖,继续努力!
2020/12/04 08:53
回复
举报
更多评论
打赏
2 评论
4 收藏
1
分享
返回顶部
顶部