深入python内存管理

面试中被问到python的内存管理,只是说是python有自己的内存管理机制,有自己的垃圾回收机制,却不能详细作答,面试官表示很遗憾。建议我代码的业务逻辑需要想,但是学习需要深入底层,也有助于扩宽自己的知识面,对自己之后的学习路径有帮助,哈哈,感谢面试官帮我指出自己的不足。

回家马上查资料,先解决这个问题。

首先看看各种python常见面试题上的答案:

python内存管理是由私有堆空间管理的,所有的python对象和数据结构都存储在私有堆空间中。程序员没有访问堆的权限,只有解释器才能操作。为python的堆空间分配内存的是python的内存管理模块进行的,核心api会提供一些访问该模块的方法供程序员使用。python自有的垃圾回收机制回收并释放没有被使用的内存供别的程序使用。

如果仅仅问道这,上面的答案也足够了,但是面试官想要了解到更多,可能会衍生一些别的问题,那上面的答案就不够了。

以下内容: 作者:Vamei 出处:http://www.cnblogs.com/vamei 欢迎转载,也请保留这段声明。谢谢!

语言的内存管理是语言设计的一个重要方面。他是决定语言性能的重要因素。无论是C的手动管理还是java的垃圾回收,都成为语言重要的特征。下面已python语言为例子,说明一门动态类型的面向对象的语言的内存管理方式。


对象的内存使用

赋值语句是语言最常见的功能了。但即使是最简单的赋值语句,也可以很有内涵. 首先看看python的赋值语句:

1
a = 1

整数“1”为一个对象,存储在内存空间中。a是一个引用。利用赋值语句,将引用a指向对象1。Python是动态类型的语言,对象与引用分离。文章作者比较形象的解释就是:Python像使用“筷子”那样,通过引用来接触和翻动真正的食物——对象。

下面就是一系列的实验了,建议亲自尝试 可以通过python的内置函数id(),来探索对象在内存的存储。

1
2
3
4
5
>>> a = 1
>>> id(a)
140035503539424  # 内存地址的十进制表示
>>> hex(id(a))
'0x7f5c8e71c4e0'  # 内存地址的十六进制表示

在python中整数和短小的字符,python都会缓存这些对象,以便重复使用,当我们创建多个等于1的引用的时候,实际是让所有引用都指向同一个对象:

1
2
3
>>> b = 1
>>> id(b)
140035503539424  # 等于上面id(a)的值

对比可以看出a和b实际是指向同一个对象的不同引用。 为了校验两个引用指向同一个对象,我们可以用“is”来判断。is用于判断两个引用所指的对象是否相同。

 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
a = 1
b = 1
print(a is b)
True

a = "good"
b = "good"
print(a is b)
True

a = "very good morning"
b = "very good morning"
print(a is b)
True   # 原文在此处就是False,但是我的为True,通过查资料发现是python版本原因
# Python2.3简单整数缓存范围是(-1,100),Python2.5.4以后简单整数缓存范围至少是(-5,256)。所有的短字符也都在缓存区。

a = "为了校验两个引用指向同一个对象,我们可以用“is”来判断。is用于判断两个引用所指的对象是否相同。"
b = "为了校验两个引用指向同一个对象,我们可以用“is”来判断。is用于判断两个引用所指的对象是否相同。"
print(a is b)
False  # 增加了字符串的长度,结果也是False

a = []
b = []
print(a is b)
False

根据上面的运行结果,可以看到由于python缓存了整数和短字符串,因此每个对象只存有一份。比如所有的1的引用都指向同一对象。即使使用赋值语句,也只是创造了新的引用,而不是对象本身,长的字符串和其他对象可以有多个相同对象,可以使用赋值语句创建出新的对象。

在python中,每个对象都有存有指向该对象的应用总数,即引用计数(reference count) 呢 我们可以使用sys包中的getrefcount(),来查看某个对象的引用计数。需要注意的是,当使用某个引用作为参数,传递给getrefcount()时,会创建一个临时引用,所以结果会比预期多1。

1
2
3
4
5
6
7
8
from sys import getrefcount

a = [1, 2, 3]
print(getrefcount(a))
# 2
b = a
print(getrefcount(b))
# 3

由于上述原因,getrefcount()返回的结果分别是2,3,而不是期望的1。


对象引用对象

python的一个容器对象(container),比如列表字典等,可以包含多个对象。实际上,容器对象中包含的并不是对象本身,而是指向各个元素对象的引用。

1
2
3
4
5
6
7
8
9
class from_obj(object):
    def __init__(self, to_obj):
        self.to_obj = to_obj
b = [1,2,3]
a = from_obj(b)
print(id(a.to_obj))
print(id(b))
# 140035473779144
# 140035473779144

可以看到a引用了对象b。

对象引用对象是python最基本的构成方式。即使是a = 1这一赋值方式,实际上是让词典的一个键值“a”的元素引用整数对象1。该词典对象用于记录所有的全局引用。该词典引用了整数对象1。我们可以通过内置函数globals()来查看该词典。 当一个对象a被另一个对象b引用时,a的引用计数将增加1。

1
2
3
4
5
6
7
from sys import getrefcount
a = [1, 2, 3]
print(getrefcount(a))
b = [a, a]
print(getrefcount(a))
# 2
# 4

由于对象b引用了a两次,所以a的引用计数加2。

容器对象引用可能构成很复杂的拓扑结构。我们可以用objgraph包来绘制其引用关系。 objgraph是python的一个第三方包。objgraph官网

pip install objgraph

使用objgraph需要安装xdot。根据自己的系统发行版本安装。

1
2
3
4
5
6
7
sudo pacman -S xdot或者 sudo apt install xdotsudo yun install xdot
x = [1, 2, 3]
y = [x, dict(key1=x)]
z = [y, (x, y)]

import objgraph
objgraph.show_refs([z], filename='ref_topo.png')

ref_topo.png

两个对象可能互相引用,从而构成所谓的引用环(reference cycle)

1
2
3
4
5
>>> a = []
>>> b = [a]
>>> a.append(b)
>>> a
[[[...]]]

即使是一个对象,只需要自己引用自己,也能构成引用环。

1
2
3
4
>>> a = []
>>> a.append(a)
>>> print(getrefcount(a))
3

引用环会给垃圾回收机制带来很大的麻烦,我将在后面详细叙述这一点。


引用减少

某个引用对象的引用计数可能减少。比如使用del关键字删除某个引用

1
2
3
4
5
6
7
>>> a = [1, 2, 3]
>>> b = a
>>> print(getrefcount(b))
3
>>> del a
>>> print(getrefcount(b))
2

del也可以删除容器中的元素,比如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
>>> a = [1,2,3]
>>> del a[0]
>>> print(a)
[2, 3]

>>> b = {"q": 1, "w":2}
>>> b
{'q': 1, 'w': 2}
>>> del b["q"]
>>> b
{'w': 2}

如果某个引用指向对象a,当这个引用被重新定向到其他对象b的时候,对象a的引用计数会减少

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
>>> from sys import getrefcount
...
... a = [1, 2, 3]
... b = a
... print(getrefcount(b))
...
... a = 1
... print(getrefcount(b))
3
2

垃圾回收

当python中的对象越来越多,他占据的内存也会越来越大。不过不需要担心太多,python会在适当的时候启动垃圾回收机制(garbage collection),将没用的对象清除,在许多语言中都有垃圾回收机制,比如Java和Ruby。

从基本原理来说,当一个对象的引用计数降为0的时候,说明没有任何引用指向对象,这时候该对象就成为需要被清除的垃圾了。比如某个新建对象,分配给某个引用,引用数为1,当引用被删除之后,引用数为0,那么该对象就可以被垃圾回收。

1
2
a = [1,2,3]
del a

del a之后已经没有任何引用指向[1,2,3]了,用户不可能通过任何方式接触或者动用这个对象,这个对象如果继续待在内存里,就成了不健康的数据。当python的垃圾回收机制启动的时候,python扫描到这个引用为0的对象,就会将它所占据的内存清空。

然而清理过程是个费力的过程。垃圾回收的时候,python不能进行其他的任务,频繁的垃圾回收,会大大降低python的工作效率。如果内存中的对象不多,就没必要总启动垃圾回收。所以python只会在特定的条件下,自动启动垃圾回收。当python运行的时候,会记录其中分配对象和取消分配对象的次数,两者的差值高于某个阈值的时候,垃圾回收才会启动。 我们可以通过gc模块的get_threshold()来查看该阈值。

1
2
3
>>> import gc
>>> gc.get_threshold()
(700, 10, 10)

返回值中,后面的两个10,是与分代回收相关的阈值,分代回收在后面会讲到。700既是垃圾回收的启动阈值。可以通过gc中的set_threshold()来重新设定。 也可以手动使用gc.collect()启动垃圾回收机制。


分代回收

python同时使用了分代(generation)回收的策略。这一策略的基本假设是,存活时间越久的对象,越不可能在后面的程序中变成垃圾。我们的程序往往会产生大量的对象,许多对象很快产生和消失,但也有长期存在被使用的对象,出于信任和效率,对于这样一些对象,我们相信他的用处,所以减少在垃圾回收中扫描他们的频率。

python将所有的对象分为0,1,2三代,所有新建的对象都是0代对象,当某一代对象经历过垃圾回收之后,依然存活,那就归入到下一代中,垃圾回收启动时,一定会扫描所有的0代对象。如果0代对象经历过一定次数的垃圾回收,那么就启动对0待和1代的扫描清理,当1代也经历了一定数量的垃圾回收,那就启动对0,1,2,即所有的对象进行扫描。

上面gc.get_threshold()返回的(700,10,10)中后面的两个数,意义就是,每经过10次对0代的垃圾回收,就会配合启动一次对1代的扫描,没经过10次对1代的扫描,才会启动一次对2代的垃圾回收。

同样可以用set_threshold()来调整,比如对2代对象进行更频繁的扫描。

1
2
import gc
gc.set_threshold(700, 10, 5)

孤立的引用环

引用环的存在会给垃圾回收带来很大的困难,这些引用环可能构成无法使用,但是引用计数不为0的一些对象。

1
2
3
4
5
a = []
b = [a]
a.append(b)
del a
del b

上面我们先创建了两个表对象,并引用对方,构成一个引用环。删除了a,b引用之后,这两个对象不可能再从程序中调用,就没有什么用处了。但是由于引用环的存在,这两个对象的引用计数都没有降到0,不会被垃圾回收。孤立的引用环

为了回收这样的引用环,Python复制每个对象的引用计数,可以记为gc_ref。假设,每个对象i,该计数为gc_ref_i。Python会遍历所有的对象i。对于每个对象i引用的对象j,将相应的gc_ref_j减1。遍历后的结果在结束遍历后,gc_ref不为0的对象,和这些对象引用的对象,以及继续更下游引用的对象,需要被保留。而其它的对象则被垃圾回收。


总结

python作为一种动态类型的语言,其对象和引用分离,这与面向过程的编程语言有很大的区别。为了有效的释放内存,python内置了垃圾回收的支持。python采用了一种相对简单的垃圾回收机制,即引用计数,并因此需要解决孤立引用环的问题。Python与其它语言既有共通性,又有特别的地方。对该内存管理机制的理解,是提高Python性能的重要一步。

updatedupdated2022-01-252022-01-25