python垃圾回收 (GC) 机制

作者: python 发布时间: 2022-10-16 浏览: 939 次 编辑

Python 能够自动进行内存分配和释放,但了解 python 垃圾回收 (garbage collection, GC) 的工作原理可以帮助你写出更好更快的 Python 程序。Python 使用两种算法进行垃圾回收,分别是引用计数 (Reference Counting) 和分代回收 (Generational garbage collection)。

引用计数

引用计数,简而言之就是如果没有变量引用某一对象,那么该对象将会被回收。Python 中的每个变量都是对对象的引用,而不是对象本身。例如,赋值语句只是给右侧对象或右侧变量所对应的对象建立一个引用;一个对象都可以有许多引用。

核心概念:变量是指向一个对象的指针;有n个变量指向某一个对象,那该对象的引用计数则为n,又称该对象有n个引用

import sys
a = [1, 2, 3]
b = a # 赋值操作本身不会对数据进行复制,仅仅是建立引用关系

print(id(a), id(b))  # 4483101696 4483101696,变量a b的id相同,说明a b指向同一对象
print(sys.getrefcount(a))  # 3, 其中调用getrefcount函数会使引用+1 

为了跟踪每个对象的引用次数,每个对象都有一个名为引用计数的额外属性,当创建或删除指向对象的指针时,该属性的值会相应的增加或减少。以下三种情况会使对象的引用次数增加:

  • 赋值运算
  • 参数传递
  • 将对象附加到容器对象中

如果某对象引用计数属性的值为零,CPython 会自动调用该对象特定的内存释放函数。如果该对象还包含对其他对象的引用,那么所包含的其他对象的引用计数也会自动减少。因此,可以依次释放其他对象。

值得注意的是,在函数、类和代码块(如if-else代码块)之外声明的变量称为全局变量(global variables)。通常,这些变量会一直存在直到 Python 进程结束。因此,全局变量引用的对象的引用计数永远不会下降到零。在python进程中,所有全局变量都存储在一个字典中,可以通过调用globals()函数来获取全局变量。那反过来呢?在代码块内(例如,在函数或类中)定义的变量则具有一个局部作用域,可以通过调用locals()函数来获取局部变量。当 Python 解释器执行完一个代码块时,它会破坏在块内创建的局部变量及其引用。

我们举个例子:

import sys

foo = []
print(sys.getrefcount(foo)) # 2 references, 1 from the foo var and 1 from getrefcount


def bar(a):
    print(sys.getrefcount(a))


bar(foo) # 4 references, from the foo var, function argument, getrefcount and Python's function stack
print(sys.getrefcount(foo)) # 2 references, the function scope is destroyed

当你想删除全局或局部变量时,可以使用删除变量及其引用(而不是对象本身)的 del语句。这在 jupyter notebook中工作时通常很有用,因为在jupyter notebook中所有单元格变量都是全局变量。CPython 使用引用计数的主要原因是历史原因,现在有很多关于这种技术的弱点的争论。比如,有人认为现代的垃圾回收算法可以更高效,无需使用引用计数。引用计数算法存在很多问题,例如循环引用、线程锁定以及额外内存和性能开销。必须指出的是,引用计数是 Python 无法摆脱全局解释锁 (GIL) 的原因之一。

分代回收

上面讲到了引用计数的缺点包括循环引用、线程锁定以及额外内存和性能开销。线程锁定,所以在python中使用多线程;额外内存和性能开销,我们也认了;但是循环引用的问题不解决的话,就会造成内存泄露问题。因此,python引入了分代回收的算法专门来解决循环引用的问题。

引用计数算法非常有效和直接,但它无法检测循环引用,所以python在引用计数的基础上,还需要分代回收。引用计数是 Python必需的功能,不能禁用;而分代回收是可选的,可以手动设置。


如上图示例所示,lst对象指向自身,而且Object 1Object 2 相互引用。在这两种情况下,这些对象的引用数永远至少为1。我们可以用代码来演示一下:

import gc
import sys
import ctypes

# 通过内存地址去访问没有引用的对象(unreachable objects)
class PyObject(ctypes.Structure):
    _fields_ = [("refcnt", ctypes.c_long)]


gc.disable()  # 禁用分代回收算法
lst = []
lst.append(lst)
lst_address = id(lst)
del lst

object_1, object_2 = {}, {}

object_1['obj2'] = object_2
object_2['obj1'] = object_1
obj_address = id(object_1)
del object_1, object_2

# 手动对象回收
# gc.collect()

# 获取对象引用数量
print(PyObject.from_address(obj_address).refcnt)
print(PyObject.from_address(lst_address).refcnt)

# 或者通过以下方式获取引用数量
# tmp = PyObject.from_address(obj_address)
# print(sys.getrefcount(tmp))

# # output
1
1
2

在上面的示例中,del语句删除了对我们对象的引用(引用计数减 1)。 Python 执行 del语句后,我们的对象不再可以从 Python 代码访问。但是,这些对象仍然存在于内存中。发生这种情况是因为它们仍在相互引用,并且每个对象的引用计数为 1。因为我们前面通过gc.disable()禁用了分代回收,因为循环引用对象无法释放;这时,我们可以通过调用gc.collect()手动触发对象回收。

我们知道,在python中对象分为可变对象和不可变对象。不可变对象包括int, float, complex, strings, bytes, tuple, range 和 frozenset;可变对象包括list, dict, bytearray和 set。循环引用仅存在于container对象(比如,list, dict, classes, tuples),python垃圾回收算法主要追踪可变对象及不可变对象tuple。如果tuple, dict包含的元素都是不可变对象,那么回收算法可以不对该对象进行追踪。

垃圾回收的触发

不同于引用计数,循环引用的垃圾回收不是实时作用的,而是定期运行。垃圾回收器将container对象分成三代(0, 1, 2),每个新对象都从第一代开始。如果一个对象在一个垃圾回收轮次中幸存下来,它将移至较旧(更高)的一代。较低代的回收频率高于较高代,因为大多数新对象往往会被先销毁。这样分代回收的策略能提高性能并减少垃圾回收带来的暂停时间。

为了决定何时进行一轮垃圾回收,每一代都有一个单独的计数器和阈值。计数器存储自上次收集以来的对象分配数减去释放数的差值。每次分配新的容器对象时,CPython 都会检查第0代的计数器是否超过阈值(通过gc.get_count()获得三代对象计数器存储的数值)。如果超过阈值,Python 将触发垃圾回收。我们可以通过gc.get_threshold()和gc.set_threshold()查看、设置阈值:

import gc
gc.get_threshold()  # (700, 10, 10) 分别对应三代计数器的阈值
gc.set_threshold(threshold0=800, threshold0=10, threshold0=10)  # 当threshold0设置为0时,禁用循环GC

在写程序时,可以通过将调试标志设置为gc.DEBUG_SAVEALL,从而将所有unreachable对象添加到gc.garbage 中,帮助提升程序质量:

import gc

gc.set_debug(gc.DEBUG_SAVEALL)

print(gc.get_count())
lst = []
lst.append(lst)
list_id = id(lst)
del lst
gc.collect()
for item in gc.garbage:
    print(item)