python垃圾回收机制、内存管理

  • Post author:
  • Post category:python


一、垃圾回收机制

python中对于垃圾回收机制总结的一句话为:


引用计数器为主、标记清除和分代回收为辅

1、双向环状链表refchain

我们知道python是用C语言编写的,在python的底层维护着一个双向环状链表


refchain


,该链表中存储着我们在python中创建的所有变量指向的对象,也就是说当我们在python中创建一个变量时并赋值时,就会在该链表中添加一个对象。而该对象是一个结构体。结构体中存储着该对象的信息:

  • _ob_next:上一个对象
  • _ob_prev:下一个对象
  • ob_type:对象类型
  • ob_refcnt:被引用的次数
  • ob_value:值

2、引用计数器

在refchain中的所有对象内部都有一个


ob_refcnt


用来保存当前对象的引用计数器,顾名思义就是自己被引用的次数,如下:

num = 1
str1 ='sdf'
str2 =st1
如上,内存中有值1,sdf,他们的引用计数器分别为1,2

当值被多次引用时候,不会在内存中重复创建数据,而是


引用计数器+1


当对象被销毁时候同时会让


引用计数器-1


如果


引用计数器为0


,则将对象从


refchain


链表中摘除,同时在内存中进行销毁(

暂不考虑缓存等特殊情况

)。



  • 引用计数器+1的情况


    • 对象被创建
    • 另外的变量也指向当前对象
    • 作为容器对象的一个元素(如list)
    • 作为参数传递为函数


  • 引用计数器-1的情况


    • 变量被显式的销毁(如:del 变量)
    • 指向当前对象的变量重新赋值
    • 从容器中移除
    • 函数执行完成
age = 18
number = age  # 对象18的引用计数器 + 1
del age          # 对象18的引用计数器 - 1
def run(arg):
    print(arg)
run(number)   # 刚开始执行函数时,对象18引用计数器 + 1,当函数执行完毕之后,对象18引用计数器 - 1 。
num_list = [11,22,number] # 对象18的引用计数器 + 1

3、标记清除



标记清除的目的:

基于引用计数器进行垃圾回收非常方便和简单,但他还是存在


循环引用/交叉感染


的问题,导致无法正常的回收一些数据,例如:

l1 = [1,2,3] # refchain中创建一个列表对象,计数器为1.
l2 = [4,5,6] # refchain中再创建一个列表对象,引用计数器为1.
l1.append(l2)   #把l2追加到l1中,l2指向的对象的引用计数器+1,最终为2
l2.append(l1)   #把l1追加到l2中,l1指向的对象的引用计数器+1,最终为2
del l1          # 引用计数器-1
del l2          # 引用计数器-1

对于上述代码会发现,指向


del


操作之后,没有变量指向那两个列表对象,但由于循环引用的问题,他们的引用计数器不为0,所以他们的状态:


永远不会被使用、也不会被销毁


。项目中如果这种代码太多,就会导致内存一直被消耗,直到内存被耗尽,程序崩溃。

为了解决循环引用的问题,引入了


标记清除


技术,专门针对那些可能存在循环引用的对象进行特殊处理,可能存在循环应用的类型有:


列表、元组、字典、集合、自定义类等那些能进行数据嵌套的类型。



标记清除的作用:

创建


特殊链表


专门用于保存


列表、元组、字典、集合、自定义类等






,之后再去检查这个链表中的对象是否存在循环引用,如果存在则让双方的引用


计数器均 – 1



此时对于垃圾回收存在两个两个链表



  • 1、

    存储所有定义变量指向对象的


    双向环状链表refchain



  • 2、

    存储可能存在循环引用的列表、元组、字典、集合、自定义类等对象的链表。

  • 注意(



    如列表等可能存在循环引用的对象既会存储在双向环状链表中也会存储在该链表中





标记清除的缺点:

1、标记清除只会在python内存


触发某种条件后


才会被作用(即什么时候扫描链表?)

2、标记清除会循环整个链表,并去检查是否存在循环引用,对此


时间效率非常低

4、分代回收



分代回收的目的:


解决标记清除的两个缺点



分代回收的作用:

对标记清除中的链表进行优化,将那些可能存在


循环引用


的对象拆分到


3个链表


,链表分为:


0/1/2三代


,每代都可以存储对象和阈值,当达到阈值时,就会对相应的链表中的每个对象做一次扫描,


将有循环引用的对象的计数器各自减1


并且


销毁引用计数器为0的对象



三个链表的阈值:


  • 0代链表:

    0代链表中的对象达到700个时扫描一次

  • 1代链表:

    0代链表被扫描10次时,1代链表扫描一次

  • 2代链表:

    1代链表被扫描10次时,2代链表扫描一次


三个链表中都有两个属性:count,threshold:

  • 0代,count表示

    0代链表中对象的数量

    ,threshold表示

    0代链表对象个数阈值

    ,超过则执行一次0代扫描检查。
  • 1代,count表示

    0代链表扫描的次数

    ,threshold表示

    0代链表扫描的次数阈值

    ,超过则执行一次1代扫描检查。
  • 2代,count表示

    1代链表扫描的次数

    ,threshold表示

    1代链表扫描的次数阈值

    ,超过则执行一2代扫描检查。


情景模拟 :

根据C语言底层并结合图来讲解内存管理和垃圾回收的详细过程。


第一步:当创建对象

age=19

时,会将对象添加到refchain链表中。


第二步:当创建对象

num_list = [11,22]

时,会将列表对象添加到 refchain 和 generations 0代中。


第三步:新创建对象的数量达到0代链表上的对象数量大于阈值700时,要对0代对象链表上的对象进行扫描检查。

当0代链表中存储对象的数量大于阈值后,底层不是直接扫描0代,而是先判断2、1是否也超过了阈值。

  • 如果2、1代未达到阈值,则扫描0代,并让1代的 count + 1 。
  • 如果2代已达到阈值,则将2、1、0三个链表拼接起来进行全扫描,并将2、1、0代的count重置为0.
  • 如果1代已达到阈值,则讲1、0两个链表拼接起来进行扫描,并将所有1、0代的count重置为0.


5、垃圾回收机制的总结:

在python中维护着一个双向环状链表


refchain


,这个链表存储着我们在程序中创建的所有变量指向的


对象


,每个对象都维护着一个


计数器


,如果该对象被其他对象引用则


计数器加一


,如果引用该对象的变量


被销毁或者被重新赋值


,则


计数器减



。最后当


计数器为0


时,则从


refchain中移除,内存中销毁该对象,释放内存。

但是对于 列表、字典等可以由多个元素组成的对象之间可能存在


循环引用/交叉感染


的问题,为了解决这个问题,python内部又引入了


清除标记、分代回收


的技术。在python底层划分出


四个链表。refchain、0代链表、1代链表、2代链表


。refchain中存储所有的对象,而0,1,2代对象中存储可能存在循环引用的对象。如果达到0、1、2代链表的阈值时则会扫描链表查看是否存在循环引用,如果存在则涉及的对象的计数器减一。

二、内存管理

从上文大家可以了解到当对象的引用计数器为0时,就会被销毁并释放内存。而实际上他不是这么的简单粗暴,因为反复的创建和销毁会使程序的执行效率变低。Python中引入了“缓存机制”机制。

例如:引用计数器为0时,不会真正销毁对象,而是将他放到一个名为


free_list


的链表中,之后会再创建对象时不会在


重新开辟内存


,而是在free_list中将之前的对象来并重置内部的值来使用

1、float类型

维护的free_list链表最多可缓存100个float对象

num = 3.14 #开辟内存来存储float对象,并将对象添加到refchain链表。
del num  #引用计数器-1,如果为0则在rechain链表中移除,不销毁对象,而是将对象添加到float的free_list中.
num2 = 2.5 # 此时不会开辟新的内存空间,而是去float的free_list中取出一个对象(内存地址空间),赋值并使用

优先去free_list中获取对象,并重置为值为2.5,如果free_list为空才重新开辟内存。

注意:引用计数器为0时,会先判断free_list中缓存个数是否满了,未满则将对象缓存,已满则直接将对象销毁


。以下的list、tupel、dict的free_list皆是如此

2、 list类型

维护的free_list数组最多可缓存80个list对象。

>>> li = [1,2,3]
>>> id(li)
2220235498688
>>> del li
>>> li2=['adf','csadf' ]
>>> id(li2)
2220235498688
>>>

3、tuple类型

维护一个free_list数组且数组容量20,数组中元素可以是链表且每个链表最多可以容纳2000个元组对象。元组的free_list数组在存储数据时,是按照元组


可以容纳的个数



为索引

找到free_list数组中对应的链表,并添加到链表中。如下:

>>> tup1=(1,2) 
>>> id(tup1)
2220238433664
>>> del tup1  # 因为元组的数量为2,所以在删除该变量时,会把这个能存储2个元组的对象添加到free_list[2]的列表中
>>> tup2 =(3,4) # 不会重新的开辟新的内存地址来存储当前的元组,而是去维护元组的free_list[2]中取出一个对象(内存地址)赋值并使用
>>> id(tup2)
2220238433664
>>>

4、dict

维护的free_list数组最多可缓存80个dict对象

>>> dict1 = {'a':1}
>>> id(dict1)
2220235380672
>>> del dict1
>>> dict2 = {'b':2}
>>> id(dict2)
2220235380672
>>>

5、int (小内存池)

对于int类型,这里比较特殊,python底层并不是为此维护一个free_list。而是在编译器启动的时候,就会在内存中创建


存储着[-5,256]的对象(内存地址空间)


,而这些对象的引用计数器永远不会为零,所以永远不会被销毁或者被添加到链表中。当我需要定义一个[-5,256]的对象时,如num=5,在内存中不会开辟新的内存,而是直接将内存中


存储着5的那个对象返回

>>> num1 =-5
>>> id(-5)
2220233681008
>>> del num1
>>> num2 = -5
>>> id(num2)
2220233681008
>>>

对于除开[-5,256]的整型数字,则是声明即开辟空间存储

>>> num2= 257
>>> id(num2)
2220238243440
>>> num3 =257
>>> id(num3)
2220238243472
>>>

6、str类型


情况1:

如同int类型一样,内部将所有的ascii码缓存起来,以后使用时就不再反复创建

>>> str1='A'
>>> id(str1)
2220235792688
>>> str2 ='A'
>>> id(str2)
2220235792688
>>>


情况2:

Python内部还对字符串做了驻留机制,针对那些只含有


字母、数字、下划线


的字符串,如果内存中已存在则不会重新再创建而是使用原来的地址里(


不会像free_list那样一直在内存存活,只有内存中有才能被重复利用


)。

>>> str1 ='python'
>>> id(str1)
2220238443248
>>> str2 ='python'
>>> id(str2)
2220238443248
>>> str3='pytho'
>>> id(str3)
2220238443312
>>>



版权声明:本文为adminwg原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。