GC机制

五十岚2020年7月21日
大约 9 分钟

Python 的垃圾回收机制、循环引用、gc 模块、弱引用等

1. 垃圾回收机制

Python 的垃圾回收机制以 引用计数为主,分代收集为辅

若一个对象的引用计数为 0Python 虚拟机就会回收该对象的内存,如下是一个手动销毁对象触发的垃圾回收过程

class ClassA:

    def __init__(self):
        print(f"object born, id:{hex(id(self))}")

    def __del__(self):
        print(f"object del, id:{hex(id(self))}")


def f1():
    c1 = ClassA()
    del c1


f1()
### 输出结果
object born, id:0x1ee7a5d5580
object del, id:0x1ee7a5d5580

当执行 c1 = ClassA() 时,就会创建一个对象,放在一块内存中,c1 变量指向这块内存,此时这块内存如 0x1ee7a5d5580 的引用就是 1

del c1后,c1 变量不再指向 0x1ee7a5d5580 内存,所以这块内存的引用计数 -1 等于 0 ,所以就销毁了这个对象,然后释放内存

1.1 引用计数

引用计数+1 场景
  1. 对象被创建,如: a = 23

  2. 被引用,如: b = a

  3. 对象被作为参数,传入到一个函数中,如: func(a)

  4. 作为一个元素,存储在容器中,如 list1 = [a, a]

引用计数-1 场景
  1. 对象的别名被显式销毁,如 del a
  2. 对象的别名被赋予新的对象,如 a = 24
  3. 一个对象离开它的作用域,如: func() 执行完毕时,func() 中的局部变量 全局变量、子线程、协程等不同
  4. 对象所在的容器被销毁,或从容器中删除对象
查看对象引用计数

sys.getrefcount(object) -> int

  • 参数:待查看的对象
class ClassA:

    def __init__(self):
        print(f"object born, id:{hex(id(self))}")

    def __del__(self):
        print(f"object del, id:{hex(id(self))}")


c1 = ClassA()
d = c1
print(sys.getrefcount(c1))	# 3
del c1

查看 c1 对象的引用计数为3 ,比预期引用计数多 1,这是由于调用 sys.getrefcount 函数时又传入 c1, 因此引用 +1

2.1 循环引用

循环引用是很严重的问题,它会导致内存泄露

class ClassA:

    def __init__(self):
        print(f"object born, id:{hex(id(self))}")

    def __del__(self):
        print(f"object del, id:{hex(id(self))}")


def f2():
    c1 = ClassA()
    c2 = ClassA()
    c1.t = c2
    c2.t = c1
    del c1
    del c2


f2()
print(f"globals: {globals()}")

### 输出结果
object born, id:0x2726f4155b0
object born, id:0x2726f415040
globals{... 'sys': <module 'sys' (built-in)>, 'ClassA': <class '__main__.ClassA'>, 'f2': <function f2 at 0x000002726F42B550>}
object del, id:0x2726f4155b0
object del, id:0x2726f415040

此时发现,再调用完 f2() 后,也依然没有触发 del c1del c2 ,此时全局变量中依然可以发现对 c1c2 的引用

这是由于 执行 c1.t = c2c2.t = c1 后,这两块内存相互引用,引用计数变为了 2

  • del c1后,内存中 c1对象 的引用计数变为 1,由于不为 0,所以内存中 c1对象 不会被销毁
  • 所以 del c2 同理
  • 即使上述两对象都是可以被销毁的,但由于循环引用,导致垃圾回收器不会回收,故引发内存泄露

2. 垃圾回收(gc)

Python 的垃圾回收有 **gc (Garbage Collector interface ) ** 模块,它提供一个接口给开发者设置垃圾回收的选项

上文采用引用计数的方法管理内存的一个缺陷是 循环引用,而 gc 模块其中一个主要功能,就是解决循环引用的问题

Python 有三种情况会触发垃圾回收

  1. 调用 gc.collect()
  2. gc 模块的计数器达到阀值的时候
  3. 程序退出时

示例:

import gc


class ClassA:

    def __init__(self):
        print(f"object born, id:{hex(id(self))}")

    def __del__(self):
        print(f"object del, id:{hex(id(self))}")


def f3():
    c1 = ClassA()
    c2 = ClassA()
    c1.t = c2
    c2.t = c1
    del c1
    del c2
    time.sleep(1)
    print(f"当前垃圾回收列表:{gc.garbage}\n")
    print(f"显式执行垃圾回收, 回收引用: {gc.collect()}\n")
    print(f"显式执行后的回收列表:{gc.garbage}\n")
    time.sleep(10)


if __name__ == '__main__':
    gc.set_debug(gc.DEBUG_LEAK)  # 设置gc模块的日志
    f3()

### 输出结果
object born, id:0x146dfe04fd0
object born, id:0x146dfe04fa0
gc: collectable <ClassA 0x00000146DFE04FD0>
gc: collectable <ClassA 0x00000146DFE04FA0>
gc: collectable <dict 0x00000146DFAF4F40>
gc: collectable <dict 0x00000146DFDE1640>
当前垃圾回收列表:[]

object del, id:0x146dfe04fd0
object del, id:0x146dfe04fa0
显式执行垃圾回收, 回收引用: 4

显式执行后的回收列表:[<__main__.ClassA object at 0x00000146DFE04FD0>, <__main__.ClassA object at 0x00000146DFE04FA0>, {'t': <__main__.ClassA object at 0x00000146DFE04FA0>}, {'t': <__main__.ClassA object at 0x00000146DFE04FD0>}]
  • gc.garbage: 垃圾回收后的对象会放在 gc.garbage 列表里面
  • gc.collect() :会返回不可达(未能回收)的对象数目,此时为 4 ,表示是c1 c2两个对象、及其循环引用的属性t

其他API

gc.set_debug(flags)

设置 gcdebug 日志,一般设置为 gc.DEBUG_LEAK

gc.collect(*args, **kwargs) -> int # real signature unknown

  • 参数:如果没有参数,则运行完整收集,可选参数可以是指定要收集的代数的整数,默认传入 2
    • 0: 代表检查 第1代 对象
    • 1: 代表检查 第1、2代 对象
    • ....
    • 无参: 执行一个 full collection
    • 代数编号无效: 引发 ValueError 错误
  • 返回值:不可达对象的数量

2.2 gc模块自动垃圾回收

必须要 import gc模块,并且 is_enable()=True 才会启动自动垃圾回收

该作用就是发现并处理不可达的垃圾对象,垃圾回收 = 垃圾检查 + 垃圾回收

Python 中,采用分代收集的方法,把对象分为三代

  1. 对象在创建的时候,放在一代中
  2. 如果在一次一代的垃圾检查中,该对象存活下来,就会被放到二代中
  3. 在一次二代的垃圾检查中,该对象存活下来,就会被放到三代中

因此 gc 模块里面会有一个长度为 3 的列表的计数器,可通过

gc.get_count()

获取当前自动执行垃圾回收的计数器,返回一个长度为 3 的元组

print(gc.get_count())  # (35, 6, 1)
c1 = ClassA()
c2 = ClassA()
print(gc.get_count())  # (37, 6, 1) 此时分配内存数目 +2

(35, 6, 1) 其中35 是指距离上一次一代垃圾检查,Python 分配内存的数目减去释放内存的数目,注意是内存分配,而不是引用计数的增加 ,每位对应每一代的垃圾检查内存分配的次数

自动垃圾回收阀值

gc.get_threshold()

函数说明: 获取到的长度为 3 的元组,即 gc 模块获取到自动回收的频率,如: (700,10,10) 表示当前阀值

print(gc.get_threshold())	# (700, 10, 10)
gc.set_threshold(700, 3, 0)
print(gc.get_threshold())	# (700, 3, 0)

### 输出结果
(700, 10, 10)
(700, 3, 0)

关于 (700, 10, 10) 阈值和计数规则

  • 当计数器从 (699,3,0) 增加到 (700,3,0) 时,gc 模块就会执行gc.collect(0), 即检查一代对象的垃圾,并重置计数器为 (0,4,0)
  • 当计数器从 (699,9,0) 增加到 (700,9,0)gc 模块就会执行gc.collect(1) 即检查一、二代对象的垃圾,并重置计数器为 (0,0,1)
  • 当计数器从 (699,9,9) 增加到 (700,9,9)gc 模块就会执行gc.collect(2) 即检查一、二、三代对象的垃圾,并重置计数器为 (0,0,0)

set_threshold(threshold0, threshold1=None, threshold2=None)

**函数说明:**设置垃圾回收的阈值。将 threshold0 设置为 0 将禁用垃圾回收

参数:

  • threshold: int 设置第一代垃圾回收的阈值,若设置为 0,则禁止第一代垃圾回收
  • threshold1: int 设置第二代垃圾回收的阈值,若未指定则使用默认值
  • threshold2: int 设置第三代垃圾回收的阈值,若未指定则使用默认值

**返回值:**无

解释: 这个函数用于设置 Python 中自动垃圾回收的阈值。Python 自带的垃圾回收器会在对象数量达到一定程度时自动启动,清除不再被引用的对象。通过设置阈值,我们可以控制垃圾回收的时机和频率。其中,第一代垃圾回收是最频繁的,第三代垃圾回收是最少的。将某一代的阈值设置为 0 就可以禁用该代的垃圾回收

注意: 如果循环引用中,两个对象都定义了__del__() 方法,gc 模块不会销毁这些不可达对象,因为 gc 模块不知道应该先调用哪个对象的 __del__() ,故安全起见,gc 会把对象放到 gc.garbage 中,但是不会销毁对象,因此出现上文所示的打印

总结:

  1. 项目中避免循环引用

  2. 引入 gc 模块,启动 gc 模块的自动清理循环引用的对象机制

  3. 由于分代收集,所以把需要长期使用的变量集中管理,并尽快移到二代以后,减少 GC 检查时的消耗

  4. gc 模块唯一处理不了的是循环引用的类都有 __del__() ,故项目中要避免定义 __del__() 方法,如果一定要使用该方法,同时导致循环引用,需要代码显式调用 gc.collect()gc.garbage 里面的对象的显式回收调

  5. 启动 gc 模块的自动清理功能会带来一些性能上的损失,因此在需要关注性能的场景下需要谨慎使用该功能

2. 弱引用

场景: 经常会使用 cache dict 这类功能,需要将一些信息存入一个全局的缓存字典中,如下

players = {}


class Player:
    # 玩家类,若该玩家ID,不在缓存字典,则写入全局缓存
    def __init__(self):
        for i in range(1000):
            if i not in players:
                self.id = i
                break
        players[self.id] = self


def game():
    """ 游戏初始化两个玩家 """
    player1 = Player()
    player2 = Player()
    # p1 and p2 do something


for _ in range(10):
    game()

print(len(players))

### 输出结果:
# 20

此时发现有 20 个缓存的玩家,尽管游戏结束,也一直存放于全局中未回收

当游戏源源不断进行,随着时间增长,缓存中的对象也会随之增长

2.1 weakref 弱引用

如果 player 实例 已经没有任何游戏的部分引用它了,即object 身上其他的引用都没有了,就可以触发引用垃圾回收被 release 掉了

weakref.WeakValueDictionary()

通常对字典的值使用 WeakValueDictionary() ,和字典的使用一致,但 对每个字典的值是弱引用,当该值上的其他引用归零时,会将值和key一起扔掉 ,若打印时,则需要将其转为 dict

import weakref

players = weakref.WeakValueDictionary()


class Player:
    # 玩家类,若该玩家ID,不在缓存字典,则写入全局缓存
    def __init__(self):
        for i in range(1000):
            if i not in players:
                self.id = i
                break
        players[self.id] = self


def game():
    """ 游戏初始化两个玩家 """
    player1 = Player()
    player2 = Player()
    # p1 and p2 do something


for _ in range(10):
    game()

print(len(players), dict(players))
### 输出结果:
# 0 {}

weakref.WeakKeyDictionary()

对字典的键进行弱引用,但对于 int、str 等 object,无法进行弱引用

有不少的 C level 的 builtin object 都是不支持弱引用的,对其(如:一个整数 )做弱引用也没有任何意义的

基本上,在 Python level 定义的 object 都是支持弱引用的,每个 instance 实例的方法、集合、生成器等都是可以被弱引用的

2.2 关于 __slots__

class 中定义了 __slots__ = ["id"] ,若还想让其支持弱引用,需要增加 "__weakref__" 这一项,它会保存弱引用相关的一些信息

class Player:
    class Player:
    __slots__ = ["id", "__weakref__"]
	
上次编辑于: 2023/6/11 05:01:15