浅谈 Python 中的内存管理
背景
在其他语言中,比如 C 语言,经常能听到内存管理的问题,比如内存泄漏,内存溢出等等。而在 Python 中,这些问题很少在一些入门初级教程中提到,甚至有些教程甚至不提及这些问题。但是,这些问题在 Python 中也是存在的,只是 Python 中的内存管理机制使得这些问题很少被摆到台面上让用户操作。在这里我以个人粗浅的理解来谈谈 Python 中的内存管理。
Python 基本不让用户直接管理内存,比如预先分配内存,或者用完之后就释放,既然无需我们手动做,那么这些事情是如何发生的呢,其实对于使用 python 来进行一些简单的数据处理,我们可以并不在乎,但是,知道还是好的。
指针?在哪?
pointers ,指针,是在 C/C++ 里的重要概念。指针也就是内存地址,指针变量是用来存放内存地址的变量,指针描述了数据在内存中的位置,标示了一个占据存储空间的实体,在这一段空间起始位置的相对距离值。在 C/C++ 语言中,指针一般被认为是指针变量,指针变量的内容存储的是其指向的对象的首地址,指向的对象可以是变量(指针变量也是变量),数组,函数等占据存储空间的实体。
在 python 中,也有一个类似的概念叫做 namespace
,命名空间,可以理解为所有变量、关键字、函数的集合,例如所有内置函数 print()
、min()
,关键字 True
、None
这些始终在命名空间中。
当我们创建了一个新变量,如 wwj_string = "Fuck World!"
时,这个变量的名称会被添加到所在范围(作用域)的命名空间中,在本文中为了简单,假设都发生在全局命名空间中(有关作用域介绍,见下方 [tips](# 作用域 -variable-scope))。
在该例子中,wwj_string 就是指针,内存中的对象是一个值为 "FUck World!" 的字符串。
上文提到的指针,并不是 C/C++ 中的指针,更类似于引用,其中细节见 这里 👉
当我们创建一个 list: my_lst = ['string, 42]
如上图所示,my_lst 是一个指针,指向一个 list 对象,该对象包含两个指针,分别指向两个对象,一个是字符串对象,一个是 int 对象。
指针混淆(alias)
有时候两个指针指向同一个对象,这时候我们会说这两个指针是混淆的,例如:
a = ["string", 42]
>>>a
['string', 42]
b = a
b[0] = "new string"
>>>b
['new string', 42]
>>>a
['new string', 42]
在这个例子中,我们定义了 list a,然后将 a 赋值给 b,这时候 a 和 b 指向同一个对象,当我们修改 b 的第一个元素时,a 也会被修改,因为 a 和 b 指向同一个对象。
实际上,我们在第 5 行,并没有定义创建一个新的 list 对象,而是创建了一个指针,指向了 a 指向的对象,这时候 a 和 b 指向同一个对象。
所以当我们改变 b[0] 时,a[0] 也会被改变。
浅拷贝与深拷贝 copy
那么若我们想要在不影响原始对象的条件下创建一个新的对象,我们可以用 copy
方法,
c = a.copy()
c[0] = "new string"
>>>c
['new string', 42]
>>>a
['string', 42]
但是,如果我们 list 中的元素也是个 list 呢?
a = [[1, 2, 3]]
>>>a
[[1, 2, 3]]
b = a.copy()
b[1][0] = 42
>>>b
[[42, 2, 3]]
>>>a
[[42, 2, 3]]
这就和上一个例子不一样,但是我们仔细把逻辑理清楚:
相当于说,我们的 copy
方法,只拷贝了一层,而没有拷贝更深层的对象。这就是所谓的浅拷贝 shallow copy
对应的,还有个方法是深拷贝 deep copy
,它会递归地创建它遇到的每个对象的船新版本,有了上述例子,这个就很好理解了。
from copy import deepcopy
c = deepcopy(a)
c[0][0] = 42
>>>c
[[42, 2, 3]]
>>>a
[[1, 2, 3]]
在上述例子中,我们 a,c 两个 list 中的 a[0][1],a[0][2] 和 c[0][1],c[0][2] 都是同一个对象,因为 python 存在一些内存优化机制,防止创建不需要的、相同的不可变对象,但是如果该对象为用户定义的类时,深拷贝会创建对象的新实例。
作用域 Variable Scope
python 中会存在多个同名的实例,但是只要他们在不同的作用域中,他们就是单独的,不会相互干扰。
但是存在的问题就是,加入在代码中引用变量 w
,而且你写了好多个变量都叫 w
,python 怎么知道你想用的是哪个 w
呢?
这里就对应了作用域这个概念,解释器会根据 w
被定义时的位置,以及你在代码中引用 w
的位置,来决定,具体来说,是按照如下的顺序:
- Local:你在函数里面引用 W,就优先会在函数内部找
- Enclosing:如果 local 里找不到,就去其他封闭函数里找
- Global:如果前两个找不到,就去全局作用域里找
- Built-in:最后,去内置作用域找
以上被称为 LGEB 规则 ,意味着如上的搜索顺序,如果都找不到,就抛出 NameError Exception
。
来个简单例子就能理解:
x= 'global'
def f():
def g():
print(x)
g()
f()
>>>golbal
x= 'global'
def f():
x = 'enclosing'
def g():
print(x)
g()
f()
>>>enclosing
x= 'global'
def f():
x = 'enclosing'
def g():
x = 'local'
print(x)
g()
f()
>>>local
不可变对象
在大部分的 python 基础教程中,都说列表是可变的,元组是不可变的(我们暂且先认同这个说法 😊)来看这个例子:
a= (43, 'hello')
a[0] = 42
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
很好理解,因为元组是不可变对象。但是,如果其中的元素是可变对象呢?
a = ([1, 2], 'hello')
a[0].append(3)
>>>a
([1, 2, 3], 'hello')
事实证明是可以的,因为我们并没有改变 a[0],这是个指针,我们更改的是指针对应的对象的值,并没有违背元组不可变的原则。
+= 操作符
实际上,除了 append
方法,我们还可以用 +=
操作符来对一个列表进行添加操作,简单的例子如下:
my_lst = [1, 2, 3]
my_lst += [4, 5]
>>>my_lst
[1, 2, 3, 4, 5]
这里在执行 +=
操作时,实际上存在两个步骤:
- 创建对象,对于 list 这种可变对象,就在原对象上改变,对于不可变对象,比如字符串,会创建一个新的对象,这个创建对象的操作,对应着
+
操作符。 - 赋值,重新分配指针,指向所需的对象,对应着
=
操作符。
这就意味着,在可变对象上,执行该操作符是比较冗余的,如以下例子:
my_string = 'hello'
my_string += ' world'
>>>my_string
'hello world'
字符串是不可变的,所以 =
操作符会创建一个全新的字符串对象,然后将 my_string
指向它。
那么如果结合 +=
操作符和元组中的列表元素呢?试一下:
a = ([1, 2], 'hello')
a[0] += [3]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
TypeError: 'tuple' object does not support item assignment
>>>a
([1, 2, 3], 'hello')
ridiculous!?,明明报错,但是却实现了? 仔细分析,对照上文说过的两步操作,可以发现,错误发生在第二步,也就是我们不能直接分配给 a[0],因为它是元组的元素。但是为什么最终实现了呢?因为我们的第一步是没毛病的,我们在原对象上(list)进行了改变,这是我们预期的结果。所以第一步是 OK 的,但是执行第二步的时候,把指针分配给 a[0] 的时候,就报错了。所以我们分别得到了: 预期的变化 和 报错 。
什么是 =
?
上文提到了 深拷贝和浅拷贝,那我们证明确实,深拷贝之后的对象,是不同的对象,而不是一个新指针呢?也就是说,我们如何知道两个对象是内存中实际上是同一个对象?在一些入门教程中,有两个方法:is
和 ==
is 方法
对象的 id
python 中有一个 built-in 的方法叫 id
,可以返回某个对象的内存。但是内存的分配,却要看 python 的解释器的实现,比如说,默认的 CPython,会使用对象的内存地址作为返回。
但是其他的编译器,比如 Skulpt,是一个 Python-JavaScript 编译器,用于在浏览器中运行 Python,但是这种情况下,就不能提哦为每个对象提供稳定公开的对象地址作为 id。
使用很简单,如下,我们创建一个 lista
,并赋值给 b
,创建了新的指针,检查两者的 id,会发现是相同的。当我们用浅拷贝方法创建 c
,则和 a
有不同的 id
a = ['hello','world']
b = a
>>>id(a)
4498576064
>>>id(b)
4498576064
c = a.copy()
>>>id(c)
4499390144
is 方法的实现
在基本了解 python 的 id
方法之后,可以发现 is 方法的实现很简单,就是比较两者的 id。
==
方法
先用上文的例子来干一下,看看该方法的返回和 is
方法的返回有何不同。
a = ['hello','world']
b = a
c = a.copy()
>>> a == b
True
>>> a is b
True
>>> a == c
True
>>> a is c
False
这个例子中就很好看出,这两个方法是不一样的,实际上 a 和 c 的内存地址不相同,但是它们的值是相同的,所以 ==
返回了 True
,而 is
返回了 False
。
当我们使用 ==
时,调用的是一个叫 __eq__
的魔法方法 (magic method),python 中所以都是对象,那么在定义对象的类的时候,就会
定义好两个实例的 __eq__
方法,当然了,也可以重写 __eq__
方法,写成用 is
判断。
所谓生命周期
一旦当我们创建了一个对象之后,在不需要它之后,我们该如何结束,如何将其从内存中释放出来? 首先我们来看另一个魔法方法:
__del__
方法
该方法是在对象被销毁之前调用的,但并非执行从内存中移除该对象的工作,我们可以在这个方法中做一些清理工作,比如说关闭文件,关闭数据库连接等等,举个简单例子:
class MyClass:
def __init__(self, name):
self.name = name
def __del__(self):
print(f"Deleting {self.name}!")
我们定义了一个类,当这个类的某个实例被销毁之前,会打印该实例的 name
,这能让我们知道,这个实例是否被销毁了。(但这并不总是正确的)
那么,CPython 是如何判断一个对象是否应该被销毁呢?两种方式:引用计数和垃圾回收。
引用计数 Reference counting
当我们有一个指向某个实例的指针,那么这个实例就有了一个 引用 ,对于该实例,CPython 会跟踪有多少引用指向它,如果计数器为 0,那么这个实例就可以被销毁了。在创建上面那个类的前提下,举如下例子:
>>> wwj = MyClass('wwj')
>>> del wwj
Deleting wwj!
这个例子中,我们实例化了一个新对象 MyCLass('wwj')
,并创建了指向它的指针 wwj
。当我们 del wwj
时,我们是删掉了该引用,所有该实例的引用计数为 0,所以 CPython 解释器决定从内存中删除它。在此之前,我们自定义的 __del__
方法会被调用,即打印出我们看见的消息。
当然这个例子中,我们只创建了一个引用,如果我们创建了多个引用呢?瞅瞅
>>> wwj = MyClass('wwj')
>>> wwj_bro = wwj
>>> del wwj
>>> del wwj_bro
Deleting wwj!
不多解释了,显而易见。再来点复杂的?我们给对象赋予一个属性,这个属性也是一个对象
>>> wwj = MyClass('wwj')
>>> xh = MyClass('xh')
>>> wwj.love = xh
>>> xh.love = wwj # 有点肉麻,忍忍
>>> del wwj
>>> del xh
本来预计的是,wwj
,xh
两个实例都会被删除,但是并没有,因为我们给 wwj
和 xh
都赋予了一个属性,这个属性也是一个对象,而这个对象也有一个引用(套娃 🪆),所以这两个实例并没有被删除,即不会打印出消息。如果有些迷茫,可以看看下面的图:
在删除 wwj
和 xh
后,变成了下图,我们删除了两个对象的命名空间中的指针,但却没有删除该对象包含的另一个对象的指针,此时两个对象无法从命名空间中访问,并且引用计数不为 0。
所以这也是引用计数方法的局限性,那么 Python 的设计者又不傻,所以有了大名鼎鼎的垃圾回收机制,Carbage Collection
。
垃圾回收 Garbage Collection
当然了,垃圾回收器 GC,是 Python 的内置方式,通常情况下我们无需手动调用。但是我们也可以将其从标准库中作为模块导入,这样我们就能看看它是如何工作的了。
检测嵌套对象
GC 会跟踪内存中存在的各种对象,但并不是所有对象,举几个例子:
>>> import gc
>>> gc.is_tracked(1)
False
>>> gc.is_tracked('wwj')
False
>>> gc.is_tracked([1, 2, 3])
True
>>> gc.is_tracked({'a': 1, 'b': 2})
True
可以发现,被追踪的对象是需要包含指针的。所以我们自己定义的实例化对象是可以被追踪的,因为可以对其添加属性(指针)。
>>> wwj = MyClass('wwj')
>>> gc.is_tracked(wwj)
True
那么我们回到 [引用计数](# 引用计数 -reference-counting) 无法解决的问题上来:GC 是如何知道嵌套对象形成的?其 get_reference
方法之后,可以返回一个对象的所有引用,我们来看看:
>>> a_list = [1, 2, 3, 'wwj']
>>> gc.get_referents(a_list)
[1, 2, 3, 'wwj']
>>> wwj = MyClass('wwj')
>>> xh = MyClass('xh')
>>> wwj.love = xh
>>> xh.love = wwj
>>> gc.get_referents(wwj)
[{'name': 'wwj', 'love': <__main__.MyClass object at 0x106d133d0>}, <class '__main__.MyClass'>]
我们可以看到 wwj
对象包含以下内容的指针,第一个是其属性的字典,第二个是其类对象本身的指针。其中第一个包含两个,第一个是 name
属性,第二个是 love
属性,而 love
属性的值是指向 xh
实例的指针。很明显的嵌套对象。
那么,当 GC 运作时,它会检查所有可被追踪的对象,是否能从 namespace
中访问:即寻找指向该对象的所有指针,以及这些指针指向的对象中的指针,有点绕口,可以理解为创建一个全局的引用树,具体更细节讲解请看 python 官方文档对其描述。
之后,GC 发现存在无法从命名空间访问的对象,那么它可以清除这些对象。
再回到上一个例子:
>>> wwj = MyClass('wwj')
>>> xh = MyClass('xh')
>>> wwj.love = xh
>>> xh.love = wwj
>>> del wwj
>>> del xh
>>> gc.collect() # 手动调用 GC
Deleting wwj!
Deleting xh!
4
在输出中,我们终于看到了两个自定义的打印消息,和一个数字 4,这个数字是 GC 清除的对象的数量。
又有点奇怪了,为什么是 4 个,不是删除了两个实例对象?其实每个实例对象也指向一个字符串对象,也就是 name
,所以是 4 个。
浅谈就到这吧,再深究下去我就不会了,等我再学学,再来补充。
最后的话
实际上,上述内容,我在日常 coding 中基本不会用到,我知道这些或者不知道这些,对我业务上的开发也基本没有影响。但是我既然学了 Python,如果只会写点小脚本和 CRUD,也挺没面子的,哪有脸皮说自己是写代码的 😅,还是那句话吧,知道总比不知道好。