聊聊 Python 中的内存管理

在 Python 中,有一个东西经常被我们忽略,那就是垃圾回收。似乎在 Python 圈中讨论垃圾回收的内容不多,究其原因,一个是 Python 的垃圾回收比较简单,不成熟,不像 Java 那样迭代了一个又一个的版本,到现在都有4.5 种回收引擎了(现在可能更多,这是我写Python之前的了解);第二个原因是 Java 被用在大型 Web 开发上很多,这些 Web 都是一起来就连续跑个几个月以上的,所以对垃圾回收格外关注,虽然 Python 的用途很广,但是像这种长期运行的情况好像不多,也就这样比较少人关注了。

少人关注不代表没有,例如之前我看过 Instagram 发表的一篇文章:禁用Python的GC机制后,Instagram性能提升10%,虽然这篇文章我个人觉得写得有点模糊,但是又不能说清楚哪里不好,所以我决定先总结总结 Python 的内存管理机制,然后再对这篇文章进行评价。

引用计数

引用计数是 Python 内存管理中非常非常重要的一环,为什么会出现引用计数这个名词呢?这可能要从 Python 的变量实现机制开始说起吧。其实,在 Python 中,可以认为没有变量的存在,我们想一下,在静态语言和 Python 的对比中,有一个很大的不同就是在于 Python 不需要定义变量的类型,为啥呢?我就以 C 语言为例,C语言的变量类型直接决定着这个变量对应的内存大小,例如 char 至少要有一个字节,int 至少要有2个字节,具体依环境而定,而我们无论我们修改多少次变量的值,变量的地址都不会变:

int main() {
    int a = 12;
    cout << &a << endl;
    a = 32;
    cout << &a << endl;
}

但是,在 Python 中,只要我们修改一下变量的值,变量的指向就变了

if __name__ == '__main__':
    a = 1
    print(id(a))
    a = 2
    print(id(a))

所以,在 Python 中,变量的含义是很独特的。刚才说可以认为 Python 没有变量,那是因为 Python 中认为一切皆对象,所以例如数字 1 也是对象,它在内存中也是以一个对象的形式存在,但是,内存中存储的不仅仅是数字的值:1,还存着对象的类型对象的引用计数

是时候说说这个引用计数了,既然没有变量,那么 a=1 这种赋值语句代表什么意思?a=1在 python 中的含义是:给对象 "数字1" 贴一个标签 "a",在贴标签的同时,顺便记录一下被贴了一次标签。这样的话,我们来理解一下下面这两句代码:

a = 1
b = 1
  1. 第一句就是给 "数字1" 贴个标签 "a"
  2. 第二句就是给 "数字1" 贴个标签 "b"

这样,我们就知道 "数字1" 被贴了 2 个标签,分别是 "a" 和 "b",所以我们就说 "数字1" 的引用计数是 2。这是一个中增加引用计数的方式,下面就列举一下 Python 中可以增加引用计数的方式:

  1. 使用 = 等号赋值
  2. 在容器对象中使用对象
  3. 作为函数参数传递的时候

减小引用计数

有增就有减,那么什么时候会减少引用计数呢?我们想想为什么有引用计数,那么答案也就比较清楚了!当一个名字(变量)不引用对象时,那么之前引用的对象的引用计数要相应的减少,所以这里也做了一个简单的总结,以下情况引用计数会减少:

  1. 使用 del 语句,将减少一个计数
  2. 改变引用,给名字设置别的对象
  3. 离开作用域

这里稍作引申几点:

  1. 使用 del 语句,并不会总是真的从内存中删除对象,除了我没说的内存池之外,还可能是对象的引用计数大于 0,也就是说还有其他变量引用着这个对象,所以不能删除
  2. 当一个变量离开作用域的时候,那么引用计数也会相应的减少,那么在全局作用域中的变量引用计数将不会为0,当他们为0 的时候,应该就是程序结束的时候

那么,问题又来了,如果引用计数减到 0 怎么办? Python 的做法是立即从内存中删除掉它(大多数情况是这样的,还有像内存池的特殊情况)。

垃圾回收

为什么要垃圾回收

上面提到了,当一个对象的引用计数为 0 的时候将会被释放,这很科学。我们可能会感觉有了引用计数之后,我们内存中的对象都可以很有效地利用起来了,然而,事实上这是不对的。在实际编码过程中,不管有意无意,我们总是会有一些循环引用的情况,例如下面这个例子:

root = Node("3")
left = Node(root, "1")
right = Node(root, left, "2")

这正常是 OK 的,但是,当有时我们在代码逻辑中一不小心,出现了类似这样的代码的话:

left.root = root

很无语吧,这就很尴尬了,这个时候我们的图就变成了这样:

这成了什么情况,这种情况就是 root 和 left 的引用计数将不可能为 0,即时你主动 del 掉 root 和 left 也是没用的,那么这样的话,这两个对象既不是在程序生命周期内都残留在内存中了?

垃圾回收算法是怎样

在 Python 中,有两种垃圾回收的机制,分别是:

  1. 引用计数
  2. 标记-删除

其中就是我们最前面介绍的引用计数方法了,当一个对象的引用计数为 0 的时候,那么就将这个对象从内存中释放。但是,如果遇到前面所说的那种情况,也就是说有两个或两个以上的元素互相引用的话,那么就行不通的。基于这个考虑,Python 的策略是 tracing,也就是跟踪对象的状态。

先从简单的说起吧,在 Pyhton 内部,维护了三个列表,分别是:

  1. generation 0
  2. generation 1
  3. generation 2

每个列表中都存放着从程序运行开始到现在创建的对象列表,然后每个列表都存在一个上限值,表示这个列表最多能够存放多少对象,当一个列表中的对象数量超过这个上限值的时候, Python 将会执行 GC 算法,对这个列表以及编号比这个列表小的列表进行 GC 回收,注意,这里是有大小关系的,例如 generation1generation0大,当 generation1 列表满的时候,会被执行 GC 算法,同时 generation 0 因为编号比 1 小,所以也需要执行GC算法;

generation1 执行完 GC 算法之后,没被 GC 掉的对象都将升级放到 generation2 中,同样得,对于每个列表的操作都是,GC 完之后,存活下来的对象都能升级!例外地,generation2 中的对象因为没得升了,只能留在原地!

那么具体的 GC 算法执行过程是怎样的呢?下面就介绍一下:

  1. 寻找根对象(root object)的集合,所谓的root object即是一些全局引用和函数栈中的引用。这些引用所用的对象是不可被删除的。而这个root object集合也是垃圾检测动作的起点
  2. 从root object集合出发,沿着root object集合中的每一个引用,如果能到达某个对象A,则A称为可达的(reachable),可达的对象也不可被删除。这个阶段就是垃圾检测阶段
  3. 当垃圾检测阶段结束后,所有的对象分为了可达的和不可达的(unreachable)两部分,所有的可达对象都必须予以保留,而所有的不可达对象所占用的内存将被回收,这就是垃圾回收阶段

那么,问题又来了,假如一对循环引用可以从 root 对象抵达,那么 GC 算法如何判断呢?这就是 Python 的另外一个有意思的实现,循环引用检测机制:

在 Python 中,每个对象除了引用计数 之外,还保存着一个 实际引用计数 的值,这个值在 GC Tracing 开始时是和 引用计数 一致的,假设检测到 a -> b 这个引用,那么就将 b 的 实际引用计数 减少1,当遍历完之后,找到那些 实际引用计数 不为 0 的元素,他们的关联对象都是需要保留的,其他无关的对象都是应该删除的。

一些额外的知识点

  1. 引用计数的增减不是线程安全的
  2. 引用计数 GC 实现简单,当 RC 为 0 时直接删除对象即可
  3. 引用计数 GC 比较麻烦,对象的赋值重新赋值都需要对 RC 进行增减
  4. 引用计数 GC 无法处理循环引用
  5. __del__ 不在调用 del 时执行,而是对象在内存中被删除的时候

对文章的评价

todo

Reference

  1. 禁用Python的GC机制后,Instagram性能提升10%
  2. Python 内存管理方式和垃圾回收算法【一般】
  3. Python深入06 Python的内存管理
  4. [Python]内存管理
  5. Python源码剖析

· EOF ·

最近爬虫很是凶猛,标注下文章的原文地址: https://liuliqiang.info/post/memory-manager-in-python/