Python Garbage Collection
概述
Python 的垃圾回收以引用计数机制(Reference Counting)为主,并配合使用标记 - 清除(Mark and Sweep)和分代回收(Generational Collection)等 GC 策略。
一、引用计数 Reference Counting
Python 中主要依赖引用计数来完成垃圾回收
原理
Python 的每个对象内部都会维护一个引用计数器,程序运行过程中会实时更新引用计数器的值,以此来反映引用当前对象的数量。当有新引用指向该对象时,引用计数器加1;当指向对象的引用失效时,引用计数器减1。当引用计数器数值为0时,该对象的内存就会被释放掉。可以通过 sys 包的 getrefcount() 来获取一个名称所引用的对象当前的引用计数(ps:这里 getrefcount() 本身会使得引用计数加一)。
1 | sys.getrefcount(a) |
引用计数加1的情况:
- 对象被创建,e.g. a=1
- 对象被引用,e.g. b=a
- 对象作为参数,被传入函数
- 对象作为一个元素,被放入容器中
引用计数减1的情况:
- 对象别名被显示销毁 del
- 对象别名被赋予新的对象
- 一个对象离开他的作用域
- 对象所在的容器被销毁或者是从容器中删除对象
优点
- 逻辑简单、高效执行、实时性高,一旦某个对象的引用计数归零,内存就会被释放,无需像其他 GC 方法需要等到某个时机执行
- GC 时间被打平,对运行中的程序影响较小
缺点
- 逻辑简单但有一定的实现成本,每个对象都需要独立的空间维护引用计数器,加大了对空间的负担
- 尽管 GC 时间被打平,但在一些场景下也会比较慢。在释放一个大对象时,如字典,需要对引用的所有对象循环嵌套调用,时间花销较大
- 引用计数无法避免循环引用的问题,必须通过其他 GC 策略来辅助解决,这也导致一些高级语言如 Java 的 GC 没有采用引用计数机制
循环引用
当两个对象互相引用、且不存在任何外部引用时,两个对象的引用计数会始终为1,永远不会被垃圾回收。举个例子:
1 | # list1和list2引用的对象引用计数加1 |
为了解决循环引用问题,Python 又引入了以下两种 GC 策略。
二、标记 - 清除 Mark and Sweep
Python 采用标记 - 清除算法来解决容器对象可能产生的循环引用问题。(ps:只有容器对象才有可能产生循环引用,如列表、字典、自定义的类、元组等等。像整型、字符串等基础数据类型并不会出现循环引用。)
基本原理
标记 - 清除是一种基于 Tracing 的垃圾回收算法,主要分为两个阶段:第一阶段是标记,把所有存活对象打上标记;第二阶段是清除,对没有标记的失活对象进行回收。
- 标记阶段:遍历所有对象,如果是可达的,说明还有对象引用它,则该对象被标记(ps:GC root 一般是一些全局变量、调用栈、寄存器等不可被删除的对象)
- 清除阶段:再次遍历对象,如果发现某个对象没有被标记为可达,则将其回收
详细说明
在标记 - 清除算法中,为了追踪容器对象,需要每个容器对象维护两个额外的指针,用来将容器对象组成一个双向链表(double-linked lists),指针分别指向前后两个容器对象,方便插入和删除操作。Python 解释器(CPython)维护了两个这样的双向链表,一个链表存放着需要被扫描的容器对象(Object to Scan),另一个链表存放着临时不可达对象(Unreachable)。下面举例说明标记-清除的详细过程:
1 | import gc |
该例子中,link1、link2、link3 形成引用环,同时 link1 还被名称 A 引用。link4 自引用,也构成一个引用环。在垃圾收集器中,每一个节点除了有一个记录当前引用计数的变量 ref_count 还有一个 gc_ref 变量,这个 gc_ref 是 ref_count 的一个副本,所以初始值为 ref_count 的大小。
- GC 启动时,遍历 Object to Scan 链表中的容器对象,并且将当前对象所引用的所有对象的 gc_ref 减 1。这一步相当于解除了循环引用的影响,因为只有真正被外部引用的对象才满足 gc_ref > 1。
- GC 再次扫描所有的容器对象,如果对象的 gc_ref 为0,则该对象被标记为 GC_TENTATIVELY_UNREACHABLE ,并且被移至 Unreachable 链表中(link3、link4)。(下图为处理了 link3 和 link4 的时刻,link1 和 link2 还没被处理)
- 紧接着2,如果对象的 gc_ref 不为0,那么这个对象就会被标记为 GC_REACHABLE(link1)。同时当 GC 发现一个节点是可达的,则会从该节点出发可以到达的所有节点标记为 GC_REACHABLE。如果被标记为 GC_REACHABLE 的节点在 Unreachable 链表中,则需要将其移回 Object to Scan 链表中(link3)。
- 第二次遍历结束后,存在于 Unreachable 链表中的对象就是真正需要被释放的对象,GC 随即释放。(link4)
Notice
标记 - 清除 执行时会 STW (stop the world),整个应用程序会被暂停直到标记 - 清除结束后才会恢复。
三、分代回收 Generational Collection
由于 标记 - 清除 会 STW,为了限制垃圾回收的时间,Python 通过引入分代回收来优化自身的垃圾回收机制,以空间换时间的方式来提高垃圾回收效率。
先验知识
- 分代回收思想基于一个统计事实:对于程序,存在一定比例的内存块的生存周期比较短;而剩下的内存块,生存周期会比较长,甚至会从程序开始一直持续到程序结束。生存期较短对象的比例通常在 80%~90% 之间。
- 简而言之,对象存在时间越长,越可能不是垃圾,应该越少去收集。这样在执行 标记-清除 算法时可以有效减小遍历的对象数,从而提高垃圾回收的速度。
原理
- Python GC 给对象定义了三种世代(0,1,2),每一个新生对象在 generation 0 中;如果它在一轮 GC 扫描中活了下来,那么它将被移至 generation 2,在那里他将较少的被扫描;如果它又活过了一轮 GC,它又将被移至 generation 2,在那里它被扫描的次数将会更少。
- GC = 垃圾检查 + 垃圾回收。垃圾检查触发时机:世代中的对象达到一定的数目。
- 每个世代都有自己的阈值,且越老年代的阈值会越小(阈值越小表示垃圾检查的频率越小)。
- 阈值解释(以 (700, 10, 10) 为例):
- 第一个参数 700 表示距离上一次 generation 0 垃圾检查,Python 分配内存数目减去释放内存的数目,如果超过 700 就会触发 generation 0 的垃圾检查
- 第二个参数 10 表示距离上一次 generation 1 垃圾检查,generation 0 垃圾检查的次数
- 第三个参数 10 表示距离上一次 generation 2 垃圾检查,generation 1 垃圾检查的次数
- 通过以下两个函数可以查询和调整阈值:
- 阈值解释(以 (700, 10, 10) 为例):
1 | gc.get_threshold() # (threshold0, threshold1, threshold2). default (700, 10, 10) |
垃圾回收触发时机
- 调用
gc.collect()
,需要先导入gc
模块。 - 自动垃圾回收:当
gc
模块的计数器达到阀值的时候(上述情况)。 - 程序退出的时候。