浅析Python垃圾回收机制

概述

程序是指在执行的过程中动态的申请内存空间,随着程序的运行不再需要使用这些内存空间。这时如果不释放这些空间,就会驻留内存成为无用的垃圾,也就是造成了内存泄漏。
垃圾回收机制:GC,垃圾回收机制的存在,使得开发人员可以把更多的精力关注业务逻辑,而不是内存中垃圾的回收,因此GC的存在帮助了程序开发人员管理内存。
Python中的垃圾回收以引用计数为主,标记清除和分代回收为辅,同时还有缓存机制。

一、引用计数

1、环状双向链表refchain

在Python程序执行时,会创建一个环状双向链表refchain, 程序执行过程中创建的任何一个对象最终都会加入到这个双向链表中。
比如:

name = 'weilanhanf 
age = 100 
hobby = ["eat", "sleep"]

以上三个不同类型的对象,首先对进行初始化,然后进行一下封装成双向链表节点,根据数据类型,会封装一些不同的属性。然后插入到双向链表中

封装的属性包括:[上一个对象的引用,下一个对象的引用,对象类型,引用个数]
name = 'weilanhanf'
new_name = name  # 引用个数属性+1

封装的属性包括:[上一个对象的引用,下一个对象的引用,对象类型,引用个数, value=100]
age = 100

封装的属性包括:[上一个对象的引用,下一个对象的引用,对象类型,引用个数, items, 元素个数等]
hobby = ["eat", "sleep"]

在Python中,一切都是对象。在Cpython中,每个Py对象又都对应个一个C语言结构体,其都有相同的属性:PyObject(包含4个属性:前后引用,引用计数器,数据类型)。如果是容器类型,还会有额外的ob_size(元素个数)等属性。

2、类型结构体

比如浮点类型

data = 3.14
CPython内部对象创建,包含属性:
_obj_next = rechain中上一个对象
_obj_pre = rechain中下一个对象
ob_refcat = 1
ob_type = float
ob_fval = 3.14

其他类型对象也类似,都对应着一个相似的结构体对象

3、引用计数器

如程序中有如下代码

v1 = 3.14
v2 = 999
v3 = (1, 2, 3 )

python解释器初始化的时候,就会生成refchain链表。执行Python程序的时候,底层会逐个创建refchain链表节点对象,对象会根据程序中变量类型初始化属性,然后加入到refchain链表中。节点对象中维护一个refcnct的值,也就是引用计数器,记录对该对象的引用个数。当有其他对象增加引用,引用计数器的值+1。当减少引用,值-1。

v1 = 3.14

# 增加引用
v4 = v1  

# 减少引用
del v4
del v1

当有一个对象的引用计数器值为0,意味着对象无用,这个对象在内存中认为是垃圾,需要进行销毁。从rechain链表中移除,然后进行缓存或者回收其所占用的内存,详细见如下的缓存机制。

4、循环引用问题

引用计数通过记录对象是否被引用。但是可能存在容器对象中的元素分别又是别的容器对象,这样就会产生循环引用问题。如下:

v1 = [11,22,33]  # refchain中创建一个列表对象。由于v1=对象,所以列表引对象用计数器为1.
v2 = [44,55,66]  # 把v2追加到v1中,则v2对应的[44,55,66]对象的引用计数器加1,最终为2.

v1.append(v2)  # 把v2追加到v1中,则v2对应的[44,55,66]对象的引用计数器加1,最终为2.
v2.append(v1)  # 把v2追加到v1中,则v2对应的[44,55,66]对象的引用计数器加1,最终为2.

del v1  # 引用计数器-1
del v2  # 引用计数器-1

del操作之后,v1,v2的引用都为1。虽然删除引用了,默认无用。但是两个数数组还在链表中,常驻内存,成为垃圾占用内存。
所以python引用标记清除解决这个循环引用存在的问题。

二、标记清除

目的:为了解决循环引用产生的问题。
实现:在Python的底层再维护一个链表,专门存放可能存在循环引用的对象(列表,字典,元组等)。
也就是除了refchain双向循环链表之外还要在维护一个链表,暂且称为链表A。当创建的容器对象,还要再添加到第二个链表A中。

在执行的过程中,某些情况下,会去扫描循环引用的链表A中的每个元素,检查是否有循环引用。如果有,让循环引用的双方的引用计数器-1,如果引用计数器为0,则认为是垃圾,从链表移除,进行回收。
使用标记清除也会有两个问题:

  • 什么时候扫描存放容器类型对象的链表A?
  • 扫描链表然后在检测是否有循环引用本身会很耗时,怎么解决?

三、分代回收

为了解决标记清除中的两个问题,将存放可能存在循环引用的对象的链表A分成了3个链表:

  • 0代链表:0代中对象达到700个,扫描0代链表
  • 1代链表:0代链表扫描10次,则1代链表扫描1次
  • 2代链表:1代链表扫描10次,则2代链表扫描1次

所有的容器对象先添加的时候,都要先放到0代链表中,然后依次往1代,2代中添加。当0代链表达到700,进行扫描0代链表。如果链表存在循环引用的对象,其引用计数器-1。如果计数器为0,进行垃圾回收。如果不为0,则放入到1代链表中,此次1代链表记录0代链表扫描次数+1。
同时解决了标记回收的两个问题:
什么时候扫描:当链表达到阈值时候进行扫描
扫描耗时问题:分成三条链表,减少耗时

四、Python的GC小结

综上,Python中的垃圾回收以引用计数为主,标记清除和分代回收为辅。
程序执行的过程中维护了一个refchain的双向环状链表,这个链表中存储程序创建的所有对象,每种类型的对象中都有一个ob_ refcnt引用计数器的值会根据引用个数动态+1、-1。最后当引用计数器变为0时会进行垃圾回收(对象销毁、refchain中移除)。
但是,那些可以有多个元素组成的对象可能会存在循环引用的问题,为了解决这个问题Python又引入了标记清除和分代回收。执行过程中,维护了4个链表,

refchain
2代链表
1代链表
0代链表

分代链表当达到各自的阈值时,就会触发扫描链表进行标记清除的动作。

五、缓存机制

Python中,不同类型的对象有自己的缓存机制。

1、 小数据池

有时候在创建同一个对象,可能会出现,其地址相同的现象。比如:

v1 = 7
v2 = 9
v3 = 9

此时为同一个对象,变量指向内存地址一致
id(v2) == id(v3)  # True

这就是因为开辟了小对象池的原因,v2和v3指向的是同一块内存空间。
为了避免重复创建和销毁一些常见对象,Python会维护一个对象池,或者说是小数据池,其中包括一些int和短字符对象。比如启动解释器时候,Python创建的整型小数据池包括:-5,-4,... 257,不包括257和-5,开区间。
小数据对象池中对象中的引用计数器在创建时添加引用默认为1,所以无论程序中自己编写的变量添加引用或者减少引用,小数据池中对象的引用计数器都不会为0,也都不会被回收,程序执行过程中永远驻留内存。

2、free_list缓存

free_list缓存适用于float/list/tuple/dict等类型。
当一个以上类型的对象的引用计数器为0的时候,其实不会从refchain链表剔除然后立即回收,而是添加到一个free_list链表中当作缓存,以备后续使用。如果有新的对象被创建,不用再重新开辟内存,而是从free_list中取缓存。

v1 = 3.14  # 开辟内存,创建对象,初始化引用计数器等值,加入到refchain中

del v1  # 从refcain中移除,然后将节点对象添加到free_list中

v9 = 999.99  # 不会立即开辟空间创建对象,如果free_list不为空,则是从free_list中获取缓存,然后初始化从缓存中获取的对象,然后在添加到refchain中

同样free_list也是有上限的,如果free_list满,比如阈值为80,则从refchain中移除的对象不会缓存到free_list中,而是立即销毁。

六、Golang的GC小结

Golang中的GC随着版本的提高,GC机制也越来越高效。

  • V1.3 普通标记清除法,整体过程需要STW(Stop The World),效率低下
  • V1.5 三色标记法,堆空间启用写屏障,栈空间不启动,全部扫描之后,还需要一次STW扫描栈,效率一般
  • V1.8 三色标记法,读和写屏障机制,栈空间不启动,堆空间启动,整体过程几乎不需要STW,效率较高
    V1.8表示1.8版本,对比Golang的也就是,通常的GC都要将程序中对象用链表或者其他的数据结构组织起来,然后根据对象之间的引用链,逐个扫描对象,标记对象是否为内存垃圾然后决定是否回收。

:以上仅为自己的学习笔记,如有相同或者不对地方,轻喷,谢谢谢谢。

上一篇:动手学深度学习v2__26 网络中的网络 NiN(草稿待补充)


下一篇:buu-re-SimpleRev