Python 垃圾回收

垃圾回收(Garbage Collection,GC)是 Python 自带的机制,用于自动释放不会再用到的内存空间。在实现上,以 引用计数 机制为主,以 标记清除分代收集 两种算法为辅。

必要时可使用 objgraph 可视化分析工具,定位内存泄露。

函数的参数传递机制

当调用函数时,基本的参数传递机制有三种形式:值传递、指针传递、引用传递

值传递(pass by value):形参是实参的拷贝,会作为局部变量额外申请内存空间,改变形参的值并不会影响外部实参的值。

指针传递(pass by pointer):形参为指向实参地址的指针,当对形参的指向进行操作时,就相当于对实参本身进行操作。(C、C++ 专属)

引用传递(pass by reference):形参相当于是实参的“别名”,实际传递的还是对象地址,对形参的操作其实就是对实参的操作。

Python 的对象引用

在 Python 中函数传递参数时 既非传值也不是传引用,而是采用 对象引用传递(pass by object reference) 的方式。实际上,这种方式相当于传值和传引用的一种综合——当函数参数为可变对象时,传递的是引用;当函数参数为不可变对象时,传递的是值。

不可变对象(Immutable Object):长度大小固定,无法增加删除或者改变元素,如 数值 Int / Float、字符串 String、元组 Tuple
可变对象(Mutable Object):长度大小不固定,可以随意地增加删除或者改变元素,如 列表 List、字典 Dict、集合 Set

示例:对象引用练习

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 在下面的代码中,l1、l2和l3都指向同一个对象吗?
l1 = [1, 2, 3]
l2 = [1, 2, 3]
l3 = l2

print(id(l1)) # 2175559002624
print(id(l2)) # 2175559002752
print(id(l3)) # 2175559002752

---------
# 在下面的代码中,最后d的输出是什么?
d = {}


def func(d):
d['a'] = 10
d['b'] = 20
d = {'a': 1, 'b': 2}


func(d)
print(d) # {'a': 10, 'b': 20}

对象的赋值和拷贝

浅拷贝(shallow copy):重新分配一块内存,创建一个新的对象,其中的元素是原对象中子对象的引用

深拷贝(deep copy):重新分配一块内存,创建一个新的对象,并且将原对象中的元素,以递归的方式,通过创建新的子对象拷贝到新对象中。因此,新对象和原对象没有任何关联。

== 操作符比较对象之间的值是否相等;
is操作符比较对象的身份标识是否相等,即它们是否为同一个对象,是否指向同一个内存地址;
使用 id(object) 方法,获得一个对象的身份标识(在内存中唯一)。

示例一:创建浅拷贝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import copy

l1 = [1, 2, 3]
l2 = l1 # 对象引用传递
l3 = l1[:] # 使用切片操作符创建浅拷贝(仅适用于可变对象)
l4 = list(l1) # 使用数据类型本身的构造器创建浅拷贝
l5 = copy.copy(l1) # 使用copy函数创建浅拷贝

print(l2 == l1) # True
print(l3 == l1) # True
print(l4 == l1) # True
print(l5 == l1) # True

print(l2 is l1) # True
print(l3 is l1) # False
print(l4 is l1) # False
print(l5 is l1) # False

在浅拷贝中,如果原对象中的元素不可变,那倒无所谓;但如果元素可变,浅拷贝通常会带来一些副作用,尤其需要注意。

示例二:浅拷贝的副作用

1
2
3
4
5
6
7
8
9
10
11
12
13
import copy

l1 = [[1, 2], (30, 40)]
l2 = list(l1) # 创建浅拷贝,l2中的元素和l1中的元素指向同一个列表和元组对象

l1.append(100) # l1和l2不共享内存地址,操作后l1改变,l2不变
l1[0].append(3) # 列表为可变对象,此处修改了l1和l2共同指向的同一个列表,l1、l2同时改变
print(l1) # [[1, 2, 3], (30, 40), 100]
print(l2) # [[1, 2, 3], (30, 40)]

l1[1] += (50, 60) # 元组为不可变对象,此处会在l1中创建一个新元组,l1改变,l2不变
print(l1) # [[1, 2, 3], (30, 40, 50, 60), 100]
print(l2) # [[1, 2, 3], (30, 40)]

如果想避免浅拷贝的副作用,完整地拷贝一个对象,可以使用深度拷贝。另外,深度拷贝中会维护一个字典,记录已经拷贝的对象及其 ID,来提高效率并防止无限递归的发生。

示例三:创建深拷贝

1
2
3
4
5
6
7
8
9
import copy

l1 = [[1, 2], (30, 40)]
l2 = copy.deepcopy(l1) # 使用deepcopy函数创建深拷贝
l1.append(100)
l1[0].append(3)

print(l1) # [[1, 2, 3], (30, 40), 100]
print(l2) # [[1, 2], (30, 40)]

示例四:无限嵌套列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 在下面的代码中,最后x和len(x)的输出是什么?
x = [1]
x.append(x)

print(x) # [1, [...]]
print(len(x)) # 2

---------
# 以下代码的输出是什么?
import copy

x = [1]
x.append(x)

y = copy.deepcopy(x)

print(x == y) # RecursionError: maximum recursion depth exceeded in comparison

题目一中,x 指向一个列表,列表的第一个元素为 1。执行了 append 操作后,第二个元素又反过来指向 x,即指向了 x 所指向的列表,因此形成了一个无限嵌套的循环:[1, [1, [1, [1, …]]]]。不过,虽然 x 是无限嵌套的列表,但 x.append(x) 的操作,并不会递归遍历其中的每一个元素。它只是扩充了原列表的第二个元素,并将其指向 x,因此不会出现 stack overflow 的问题。虽然 x 是无限嵌套的列表,但 x 实际只有 2 个元素组成,第一个元素为 1,第二个元素为指向自身的列表,因此 len(x) 返回 2。

题目二中,x 是一个无限嵌套的列表,y 是 x 的深度拷贝,按道理来讲 x == y 应该是为 True 的。但使用比较操作符 “==” 进行比较的时候,“==” 操作符会递归地遍历对象的所有值,并逐一比较。此时,因为 x、y 是无限嵌套列表,当达到递归深度时程序便会抛出 RecursionError 异常。

del 语句和引用计数

为了简化内存管理,Python 通过 引用计数 机制实现自动垃圾回收功能。Python 中的每个对象都有一个对应的引用计数,用来记录该对象在不同场所分别被引用了多少次。每引用一次对象,相应的引用计数就会加 1,每销毁一次对象,则相应的引用就会减 1。

执行 del 语句 可以删除对象的引用(不会删除对象),使对象的引用计数减少。当对象的引用计数为 0 时,Python 内部的自动垃圾回收机制(GC)会将此对象所占用的内存收回,此时才真正从内存中删除这个对象。

使用 sys.getrefcount(object) 方法,获得一个对象的引用计数。(此函数调用时会增加一次引用计数)

示例:释放循环引用的对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import gc


class A(object):
def __init__(self, x):
self.a = x

def __del__(self):
print('del class A')


class B(object):
def __init__(self):
self.b = A(self) # A和B互相引用
# del self.b # 若注释掉此行,test函数退出后,B对象不会释放

def __del__(self):
print('del class B')


def test():
B()


test()
# gc.collect() # 显式启动垃圾回收,也可以解决循环引用问题
input('Enter any key to exit.')

标记清除和分代收集

Python 使用 标记清除算法(mark-sweep)分代收集算法(generational),来启用针对循环引用的自动垃圾回收。

标记清除

我们先用图论来理解不可达的概念。对于一个有向图,如果从一个节点出发进行遍历,并标记其经过的所有节点;那么,在遍历结束后,所有没有被标记的节点,我们就称之为不可达节点。显而易见,这些节点的存在是没有任何意义的,自然的,我们就需要对它们进行垃圾回收。

当然,每次都遍历全图,对于 Python 而言是一种巨大的性能浪费。所以,在 Python 的垃圾回收实现中,mark-sweep 使用双向链表维护了一个数据结构,并且只考虑容器类的对象(只有容器类对象才有可能产生循环引用)。

分代收集

Python 将所有对象分为三代。刚刚创立的对象是第 0 代;经过一次垃圾回收后,依然存在的对象,便会依次从上一代挪到下一代。而每一代启动自动垃圾回收的阈值,则是可以单独指定的。当垃圾回收器中新增对象减去删除对象达到相应的阈值时,就会对这一代对象启动垃圾回收。

分代收集基于的思想是:新生的对象更有可能被垃圾回收,而存活更久的对象也有更高的概率继续存活。因此,通过这种做法,可以节约不少计算量,从而提高 Python 的性能。