Garbage collection in Python

相比于C这种需要你手动malloc和free的语言,Python实现了自动内存管理。一般而言,手动内存管理显然容易出问题,比如忘记free导致的内存泄漏,提前free导致的悬空指针。那么自动内存管理的垃圾回收是怎么一回事呢,简单来说:引用计数,标记-清除,分代回收。

引用计数

引用拷贝

我们先要考虑什么叫引用。在python中赋值语句是在建立对象的引用值,而非复制对象。因此python变量更像是指针,而不是数据存储区域。当你写出a = 1的时候,实际上是变量a通过引用指向了一个int对象3。

再举个经典例子,nums = [1]然后nums.append(nums)如果是直观上的赋值操作,那结果应该是[1, [1]]但实际运行下就会发现[1, [...]]这是因为一个object的自身引用造成的不断递归死循环。如果想完成原先想法的话,可以用nums.append(nums[:])或者nums.append(nums.copy())这样就完成了一次浅拷贝。不过浅拷贝只是当前层的拷贝,如果拷贝的对象中存在引用的话,只会拷贝相关引用,而不会拷贝深层的对象。

1
2
3
4
5
a = [1, [1]]
b = a.copy()
b[0] = 0
b[1][0] = 0
print(a) # [1, [0]]

如果想复制当前层以及所有子层对象的话,需要使用深拷贝deepcopy(本质上算是种递归拷贝)

引用传参

python的对象分为不可变对象 int float long str tuple 等,可变对象 list set dict 等。

这里的不可变不是指值的不可变,而是说,对于不可变类型的变量,如果要更改变量,会创建一个新值,把变量绑定到新值上,而旧值如果没有被引用就等待垃圾回收。(不可变的类型可以计算hash值,作字典的key)

可变类型数据对对象操作的时候,不需要再在其他地方申请内存,只需要在此对象后面连续申请(+/-)即可,也就是它的内存地址会保持不变,但区域会变长或者变短。

内置函数id()可以查看python对象的内存地址。(None值也有内存地址)
在python里, is 判断两个对象的id(即内存地址)是否一样, == 判断两个对象的值是否相等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
a = 1
id(a) # 1609788160
a = 2
id(a) # 1609788192
b = 1
id(b) # 1609788160

a = [0]
id(a) # 2031362701064
a[0] = 1
id(a) # 2031362701064
id(a[0]) # 1609788160

a = None
id(a) # 1609344144
b = None
id(b) # 1609344144

所以python函数的传参没有传值还是传引用一说,Python参数传递只有“传对象引用”的方式。实际上,这种方式相当于传值和传引用的一种综合。如果函数收到的是一个可变对象的引用,就能修改对象的原始值,相当于传引用来传递对象;如果函数收到的是一个不可变对象的引用,就不能直接修改原始对象,相当于传值来传递对象。

引用计数

对象的引用计数+1的情况

  1. 对象被创建
    1
    2
    3
    4
    5
    6
    import sys
    a = 23
    sys.getrefcount(a) # 6
    class MyName():
    pass
    sys.getrefcount(MyName()) # 1

这里int对象23其实并未被新建,在Python启动解释器时会将常用对象自动创建并加载到内存中等待调用;
MyName()结果为1,是因为sys.getrefcount(MyName())函数也算一个引用。

  1. 对象被引用

    1
    2
    3
    4
    5
    a = 3.1415926
    b = a
    c = b
    sys.getrefcount(a) # 4
    sys.getrefcount(c) # 4
  2. 对象被作为参数,传入到一个函数中

    1
    2
    3
    4
    5
    def func(c):
    print(sys.getrefcount(c))
    a = 3.1415926
    sys.getrefcount(a) # 2
    func(a) # 4

多加一层函数包装感觉应该只加1才对,即func参数c对3.1415926的引用,为什么会加2呢?实际上在运行func(a)时,会先将函数func和变量a压栈,此时函数栈保存了入参对3.1415926的引用。

  1. 对象作为一个元素,存储在容器中

    1
    2
    3
    a = 3.1415926   # 增加一个引用  count = 1
    b = a # 增加一个引用 count = 2
    nums = [a, b] # 增加两个引用 count = 4

    对象的引用计数-1的情况

  2. 对象的别名被赋予新的对象

    1
    2
    3
    4
    a = 3.1415926   # 增加一个引用  count = 1
    b = a # 增加一个引用 count = 2
    b = -3.1415926 # 原对象减少一个引用 count = 1
    sys.getrefcount(a) # 2
  3. 对象的别名被显式销毁

    1
    2
    3
    4
    a = 3.1415926   # 增加一个引用  count = 1
    b = a # 增加一个引用 count = 2
    del b # 减少一个引用 count = 1
    sys.getrefcount(a) # 2
  4. 一个对象离开它的作用域

    1
    2
    a = 3.1415926   # 增加一个引用  count = 1 
    sys.getrefcount(a) # 增加一个引用count=2 -> 打印 2 -> 减少一个引用count=1
  5. 对象所在的容器被销毁,或从容器中删除对象

    1
    2
    3
    a = 3.1415926 # 增加一个引用  count = 1 
    list_ = [a,1,2,3] # 增加一个引用 count = 2
    del list_ # 减少一个引用 count = 1

当引用计数为0时,系统会收回这个对象,完成垃圾回收。但是存在一种问题,循环引用。

循环引用

当出现引用链成环的时候,会导致引用计数永远不为0,造成内存泄漏。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import objgraph 
class Person:
pass
class Animal:
pass
print(objgraph.count("Person")) # 0
print(objgraph.count("Animal")) # 0

P = Person()
A = Animal()
print(objgraph.count("Person")) # 1
print(objgraph.count("Animal")) # 1

P.pet = A
A.master = P
del P
del A
# 正常情况下,如果删除了P和A,应该为0,但由于循环引用,结果为1
print(objgraph.count("Person")) # 1
print(objgraph.count("Animal")) # 1

这种时候计数就无法解决了,也为我们引入了下面的主题: 标记-清除 & 分代回收。

标记-清除

标记清除就是用来解决循环引用的问题的只有容器对象才会出现引用循环,比如列表、字典、类、元组。

  • 标记阶段,遍历所有的对象,如果可达(reachable),即还有对象引用它,则标记该对象为可达;
  • 清除阶段,再次遍历对象,如果发现某个对象未标记为可达,则就将其回收。

为了追踪容器对象,每个容器对象维护两个额外的指针,将所有容器对象组成一个双端链表,指针分别指向前后两个容器对象,方便插入和删除操作。python维护了两个这样的双端链表,一个链表”Object to Scan”存放着需要被扫描的容器对象,另一个链表”Unreachable”存放着临时不可达对象。链表中每个节点除了有记录当前引用计数的变量ref_count还有一个gc_ref变量,gc_ref是ref_count的一个副本,所以初始值为ref_count的大小。

gc启动的时候,会从根节点root逐个遍历”Object to Scan”链表中的容器对象,并使当前对象引用的所有对象的gc_ref--,将”Objects to Scan”链表中的所有对象遍历一遍,相当于解除了循环引用对引用计数的影响。

接着,gc会再次扫描所有的容器对象,如果对象的gc_ref值为0,那么这个对象就被标记为UNREACHABLE,并移至”Unreachable”链表中。如果对象的gc_ref不为0,那么这个对象就会被标记为REACHABLE。同时当gc发现有一个节点是可达的,他会递归式的将从该节点出发可以到达的所有节点标记为REACHABLE。

第二次遍历完成之后,存在于”Unreachable”链表中的对象就是真正需要被释放的对象,gc随即释放之。

上面描述的垃圾回收的阶段,会暂停整个应用程序,等待标记清除结束后才会恢复应用程序的运行。

分代回收

随着程序运行,Python解释器保持对新创建的对象,以及因为引用计数为零而被释放掉的对象的追踪。理论上,创建 == 释放数,但如果存在循环引用的话,肯定是创建 > 释放数,当创建数与释放数的差值达到规定的阈值的时候,就会采用分代回收机制。

分代回收将对象分为三代(generation 0,1,2),根据弱代假说(对象存活时间越久,越不可能是垃圾)新生的对象被放入0代,如果该对象在第0代的一次gc垃圾回收中活了下来,那么它就被放到第1代里面;如果第1代里面的对象在第1代的一次gc垃圾回收中活了下来,它就被放到第2代里面。

gc.set_threshold(threshold0[,threshold1[,threshold2]])设置gc每一代垃圾回收所触发的阈值。
gc.get_threshold()是获取三者的值,默认值为(700,10,10).

从上一次第0代gc后,如果分配对象的个数减去释放对象的个数大于threshold0,那么就会对第0代中的对象进行gc垃圾回收检查。 从上一次第1代gc后,如过第0代被gc垃圾回收的次数大于threshold1,那么就会对第1代中的对象进行gc垃圾回收检查。同样,从上一次第2代gc后,如过第1代被gc垃圾回收的次数大于threshold2,那么就会对第2代中的对象进行gc垃圾回收检查。