Python垃圾回收机制与GC体系深度解析
什么是垃圾回收?为什么需要垃圾回收?
垃圾回收即Garbage collection简称为GC,是Python,Java等高级语言所使用的内存回收机制,由虚拟机帮助我们管理内存,让它自动把我们去追踪和回收内存中的对象。没有作用的对象就是垃圾,虚拟机就是扫地机器人,在某个时机自动帮我们清除垃圾。区别于C和C++这种让用户自己进行内存管理的方式,由虚拟机代用户管理内存。让用户自己进行内存管理的方式固然自由方便,但是对编码者编程功底和程序理解的要求也要更高。而且这样也可能导致大量难以发现并且危害巨大的bug,如造成内存泄漏,产生野指针和悬挂指针等。
python的垃圾回收机制离不开三个概念:引用计数,标记清除和分代回收。
引用计数
这是python虚拟机用来判断一个对象该不该被当成垃圾回收的一种手段,在一个程序中有无数个对象,虚拟机通过引用计数法来判断哪些对象是垃圾,只有没有用处的对象才能被回收,要是回收了存活的对象可能导致程序崩溃或者数据丢失!那怎么判断一个对象是否已经没有用处了呢?很简单,如果一个对象的引用计数为0,就说明它没有被引用,意味着他已经没有用处了,这个时候就可以回收它了。python中的每一个对象都通过ob_refcnt记录引用次数,可以使用sys.getrefcount()查看一个对象的引用次数。
会导致对象引用次数+1的情况:
- 对象被创建,它的引用次数初始化为1
- 对象被其它对象引用
- 对象被作为函数参数传递
- 对象被存储在容器之中
obj = object()
# 这里输出为2的原因是原有对象初始化引用计数为1
# 加上sys.getrefcount(obj) obj被作为函数参数传递+1所以为2
print('对象还未有其他对象引用时的引用计数为:{}'.format(sys.getrefcount(obj)))
# 输出结果:对象还未有其他对象引用时的引用计数为:2
my_list = [obj]
print('将对象放入容器中后对象的引用计数为:{}'.format(sys.getrefcount(obj)))
# 输出结果:将对象放入容器中后对象的引用计数为:3
obj2 = obj
print('其他对象引用后对象的引用计数为:{}'.format(sys.getrefcount(obj)))
# 输出结果:其他对象引用后对象的引用计数为:4
会导致对象引用次数-1的情况:
- 显示删除引用,可以使用del()函数
- 变量超出作用域,如将对象作为参数传入函数,函数执行完成之后,这个局部变量在其作用域就结束了,该变量的引用会被销毁,引用计数减一
- 对象被从容器中移除
- 对象被重新赋值
引用计数法的好处:
实现简单,实时性高,在引用计数为0时,可以立即释放内存,这两点有区于Java中使用的的可达性分析法。
引用计数法的坏处:
首当其冲的就是引用计数法会造成循环引用,导致对象无法被回收。其次便是对象要多维护一个记录引用次数的字段ob_refcnt,会多消耗一些资源。
循环引用:就是在程序中两个或多个对象互相引用,而它们都是没有用处的对象,但是因为它们互相引用了彼此,导致引用计数始终无法为零,这些对象占用的内存就无法被回收,进而导致内存泄漏!
class Node:
def __init__(self):
self.next = None
node1 = Node()
node2 = Node()
node1.next = node2
node2.next = node1
# 形成了循环引用,即使这两个对象在后续过程中不会被其他对象引用,没有用处,
# 引用计数也不会为0,因为这两个对象互相引用,且都无法被回收,就造成了一个循环,导致引用计数始终为2
print(sys.getrefcount(node1)) # 结果为3,实际引用计数应该为2,因为在这里作为函数入参,所以+1
print(sys.getrefcount(node2)) # 结果为3,实际引用计数应该为2,因为在这里作为函数入参,所以+1
如果这样的话,那python的程序不就很容易造成内存泄漏了?其实不然,python中使用了我们下面要介绍的标记清除来解决这个问题。
标记清除(Mark and Sweep)
标记清除算法一个分为两个阶段,标记和清除。
标记阶段:从根对象gc_root(如全局变量、栈中的引用等)出发,递归地标记所有可达对象。我们知道垃圾回收就是要把没有作用,也即把没有被引用的对象清除。引用计数法就是计算对象自身的引用数来识别是不是垃圾对象。但是这会造成循环引用,而标记清除就是python用来解决引用计数法这个缺点的补充。标记清除不通过对维护的ob_refcnt来判断该对象是否为垃圾,而是通过一些我们已知的存活对象(根对象),递归扫描他们的引用,并把扫描到的这些对象标记为存活对象。举个例子就是,一个对象集就可以看作一个森林,已知的存活对象就是森林中的一棵树的根节点,从他开始往下遍历,它的儿子孙子重孙子…节点就都是存活对象。
如下图所示,红色的圆形就表示根对象(已知的存活对象)可以是(如全局变量、栈中的引用等),被标记为黑色的就是存活的对象,没被标记为灰色的就是可以回收的垃圾,标记就是一个从根对象开始往下遍历寻找与自己有直接引用或者是间接引用的存活对象(注意:实际上可能存在很多个根对象,图中只是展示一个示例)。可以从图中看到即使循环引用对象它在引用计数回收中没有被回收,但是到了标记清除中只要他没有关联根对象,无论他是否有引用都视为垃圾。
清除阶段: 清除阶段就比较简单了,遍历一下所有的对象,然后将未被标记的对象进行释放就可以了。
分代回收
分代回收不像上面介绍的两种回收算法,分代回收不是一种算法,而是一种提高回收效率的设计,它的核心思想是基于对象的生命周期来提高垃圾回收的效率,通过对象的生命周期来判断执行回收的时机与范围。
分代的概念:
在我们的程序中,其实大部分对象都是朝生夕死的,即创建完不久,就会成为垃圾了。而有一部分对象可能是存活很久的,可能伴随着我们整个程序的生命周期。可能在多次回收中这些对象都是存活的,我们每一次回收它们可能都是在做无用功,最终的结果都是存活。而我们每一次回收都需要对对象进行标记,还需要暂停我们的用户线程(进入全局安全点,可以理解为在标记和清除的时候停止用户操作,防止对象状态发生变化,导致对象被错误回收或错误保留,具体细节不在这里展开),所以要是每一次都对所有对象都进行回收,就会对我们程序的性能造成很大的影响。因为有一些对象无论回收它们几次,最终的结果都是存活。
为了解决上述问题,python将待回收的对象分为0,1,2三级,对应新生,中年,老年三代。新生代中的对象为刚刚分配的对象,当新生代触发标记清除之后,新生代中被标记的存活对象就会升级到中年代,在中年代触发标记清除之后,中年代中被标记的存活对象就会升级到老年代。将对象分为三代之后,我们就可以解决每次垃圾回收都要遍历所有对象的问题了。每次标记清除只在部分对象中执行,而且不会频繁地去标记清除老生代的对象。在python中设置默认的分代阈值(threshold0
, threshold1
, threshold2
)控制触发回收的频率,通常分别为 (700, 10, 10),
即为当新生代中的对象达到700个之后会触发新生代的垃圾回收,当新生代回收次数达到10次后会触发新生代与中年代的回收,当中年代回收次数达到10次之后,会触发一次三个分代的回收。
所以,分代回收只是提高效率的手段,将所有对象进行分类,对于存活时间可能很长的对象进行gc的频率就会低一点,而对于朝生夕死的对象gc频率就会高一点。当然了,我们也可以在代码中触发标记清除,如下所示:
class Node:
def __init__(self):
self.next = None
node1 = Node()
node2 = Node()
node1.next = node2
node2.next = node1
# 形成了循环引用,即使这两个对象在后续过程中不会被其他对象引用,没有用处,
# 引用计数也不会为0,始终为2
# 删除外部引用
del node1
del node2
# 手动触发垃圾回收
collected = gc.collect()
print(f"Garbage collected: {collected} objects")
# 打印的结果为:Garbage collected: 2 objects 代表回收了两个对象
写在最后:
理解python的垃圾回收主要是要明白,引用计数,标记清除,和分代回收三者之间的关系。在python程序中引用计数回收是时刻在发生的,无论python虚拟机中有几个对象。而标记清除则是为了清除引用计数回收中无法清除的循环引用对象,但是标记清除频繁执行,或者是总是全量扫描所有对象太影响程序性能了,所以有了分代回收。将对象分为0,1,2三代,根据不同的频率来执行标记清除,越年轻的分代标记清除越频繁,以上便构成了python垃圾回收的基本体系。
作者:李云龙炮击平安线程