Python 垃圾回收机制

GC 作为现代编程语言的自动内存管理机制,用于找到内存中无用的垃圾资源,并清除这些垃圾并把内存让出来给其他对象使用。

python 中的垃圾回收采用以引用计数机制为主,标记-清除分代收集两种机制为辅的策略。

引用计数

python 默认的垃圾回收机制是引用计数。

原理:

  1. 每个对象维护一个 ob_ref 字段,用来记录该对象当前被引用的次数。
  2. 每当新的引用指向该对象时,它的引用计数 ob_ref + 1;每当该对象的引用失效时计数 ob_ref - 1。
  3. 当对象的引用计数为 0,该对象立即被回收,对象占用的内存空间将被释放。

缺点: 不能解决对象的循环引用问题

计数器 +1 的情况

  • 对象被创建,如 a = 23
  • 对象被引用,如 b = a
  • 对象被作为参数,传入到一个函数中,如 func(a)
  • 对象作为一个元素,存储在容器中,如 list1 = [a,a]

计数器 -1 的情况

  • 对象的别名被显式销毁,如 del a
  • 对象的别名被赋予新的对象,如 a = 24
  • 一个对象离开它的作用域,如 f 函数执行完毕时,func 函数中的局部变量(全局变量不会)
  • 对象所在的容器被销毁,或从容器中删除对象

Python 源码中,通过 Py_INCREF 和 Py_DECREF 两个宏来管理对象的引用计数,代码在 object.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import sys
class A():
def __init__(self):
print('object born id:%s' % str(hex(id(self))))

def func(c):
print('obejct refcount is: ',sys.getrefcount(c)) # getrefcount()方法用于返回对象的引用计数

if __name__ == '__main__':
a = A() # 生成对象
func(a)

b = a # 增加引用
func(a)

del b # 销毁引用对象b
func(a)

运行结果:

1
2
3
4
object born id:0x2e1694e6eb8
obejct refcount is: 4
obejct refcount is: 5
obejct refcount is: 4

循环引用

循环引用是一个对象直接或者间接引用自己本身,引用链形成一个环。

A 和 B 相互引用,而再没有外部引用 A 与 B 中的任何一个,它们的引用计数虽然都为 1,但显然应该被回收。

1
2
3
4
5
6
a = { } # a 的引用计数为 1
b = { } # b 的引用计数为 1
a['b'] = b # b 的引用计数 + 1
b['a'] = a # a 的引用计数 + 1
del a # a 的引用 - 1,最后 a 的引用为 1
del b # b 的引用 - 1, 最后 b 的引用为 1

执行完 del 后,a、b 已经没有任何引用指向这两个对象,但是这两个对象各包含一个对方对象的引用,虽然最后两个对象都无法通过其它变量来引用这两个对象了。
这对 GC 来说是两个垃圾对象,但是他们的引用计数并没有减少到零。因此用引用计数法,他们不会被回收,会一直驻留在内存中,就会造成了内存泄漏(内存空间在使用完毕后未释放)。

为了解决对象的循环引用问题,python 引入了标记-清除和分代回收两种 GC 机制。

标记清除

标记清除(Mark—Sweep)算法是一种基于追踪回收(tracing GC)技术实现的垃圾回收算法。

python 的循环引用一般发生在容器对象(内部可持有对其他对象的引用)之间, 因此标记清除法主要处理的一些容器对象,如 list、dict、tuple,instance 等,python 使用一个双向链表将这些容器对象组织起来。

两个阶段:

  1. 标记阶段,GC 把所有的活动对象打上标记(垃圾检测)
  2. 将没有标记的对象(即非活动对象)回收

GC 通过有向连通图来判断活动对象和非活动对象。对象构成有向图的节点,引用关系构成有向图的边,从根对象出发,沿着有向边遍历对象,可达的对象标记为活动对象,不可达的对象即要被清除的非活动对象。根对象是全局变量、调用栈、寄存器。

  1. 对于每一个容器对象, 设置一个 gc_refs 值, 并将其初始化为该对象的引用计数值.
  2. 对于每一个容器对象, 找到所有其引用的对象, 将被引用对象的 gc_refs 值减 1.
  3. 执行完步骤 2 以后所有 gc_refs 值还大于 0 的对象都被非容器对象引用着, 至少存在一个非循环引用. 因此 不能释放这些对象, 将他们放入另一个集合.
  4. 在步骤 3 中不能被释放的对象, 如果他们引用着某个对象, 被引用的对象也是不能被释放的, 因此将这些 对象也放入另一个集合中.
  5. 此时还剩下的对象都是无法到达的对象. 现在可以释放这些对象了.

缺点:清除非活动的对象前必须顺序扫描整个堆内存,哪怕只剩下小部分活动对象也要扫描所有对象。

分代回收

分代回收是以空间换时间的操作方式,是建立在标记清除技术基础之上,也是用于处理容器对象。

python 将内存根据对象的存活时间划分为不同的集合,每个集合称为一个代。 分为 3 “代”: 年轻代(第 0 代)、中年代(第 1 代)、老年代(第 2 代),对应的是 3 个链表,它们的垃圾收集频率根据对象的存活时间的增大而减小。

新创建的对象都分配在第 0 代,第 0 代链表的总数达到上限时,python 垃圾收集机制就会被触发,把可以被回收的对象回收掉,而不会回收的对象被移到第 2 代去,依此类推,第 3 代中的对象是存活时间最久的对象,甚至是存活于整个系统的生命周期内。

垃圾回收

三种情况会触发垃圾回收:

  1. import gc 模块,显式调用 gc.collect()
  2. 当 gc 模块的计数器达到垃圾回收的阀值时,自动执行
  3. 程序退出时

gc 模块

gc 模块提供一个接口给开发者设置垃圾回收的选项,gc 的一个主要功能就是解决循环引用的问题。

常用函数:

  1. gc.set_debug(flags): 设置 gc 的 debug 日志,一般设置为 gc.DEBUG_LEAK

  2. gc.collect([generation]) 显式垃圾回收,可以输入参数,0 代表只检查第一代的对象,1 代表检查一,二代的对象,2 代表检查一,二,三代的对象,不指定默认为 2。 返回不可达对象的数目。

  3. gc.set_threshold(threshold0[, threshold1[, threshold2]) 设置自动执行垃圾回收的频率。

  4. gc.get_count() 获取当前自动执行垃圾回收的计数器,返回一个长度为 3 的列表

注意:
如果循环引用中,两个对象都定义了 __del__ 方法,gc 模块不会销毁这些不可达对象,因为 gc 模块不知道应该先调用哪个对象的 __del__ 方法,所以为了安全起见,gc 模块会把对象放到 gc.garbage 中,但是不会销毁对象,需要显式调用 gc.garbage 里面的对象的 __del__ 来打破僵局。

python 内存泄露

内存泄露的几种情况:

  1. 存在循环引用,gc 不能释放
  2. 存在全局对象,该对象不断的变大,占据内存
  3. 使用了 c 或者 c++ 扩展,扩展内存溢出了

查找内存泄露: 使用 gc 、objgraph

objgraph 的实现调用了 gc 的这几个函数:gc.get_objects(), gc.get_referents(), gc.get_referers(),然后构造出对象之间的引用关系。

如果我们怀疑一段代码、一个模块可能会导致内存泄露,那么首先调用一次 obj.show_growth(), 然后调用相应的函数,最后再次调用 obj.show_growth(),看看是否有增加的对象。

参考

Python 垃圾回收机制
使用gc、objgraph干掉python内存泄露与循环引用!