详谈 python 的内存管理
python 作为一门高级语言,调用相关变量时,无需实现声明,变量无需指定类型,python 解释器会自动帮你回收,这一切都由
python 内存管理器
承担了复杂的内存管理工作。
这里主要介绍基于 Cpython 实现的内存管理
python 内存管理机制 —— Pymalloc
主要实现方式
- 针对
小对象
, 就是对象大小小于256kb
时,pymalloc 会在内存池中申请内存空间
- 针对
大对象
, 对象大小大于 256kb
时,会执行new/malloc 行为
来申请新的内存空间
python 内存池(memory pool)
为什么要引入内存池?
当创建大量消耗小内存的对象时,频繁调用new/malloc
会导致大量的内存碎片,导致效率降低
内存池的作用就是预先在内存中申请一定数量的,大小相等的内存块(256kb)留作备用
,当有新的内存需求时,就优先从内存池中分配给这个需求。- 减少内存碎片化,提升效率
python 的对象管理主要位于 Level + 1 ~ Level +3 层
Level 3: 该层特定对象内存分配器,主要是
python 内置的对象(int, dict, arr)
都有独立的私有内存池,对象
之间的内存池不共享,例如int
释放的内存,不会被分配给float
使用。Level 2: python 对象分配器:当申请的
内存大小小于256KB
时,内存对象的分配主要由Python 对象
分配器(python’s object allocator)实施Level 1: python 内存分配器:当申请的
内存大小大于256KB
时,由 Python 原生的内存分配器进行分配,本质上是调用 C 标准库中的 malloc/readlloc 等函数
内存池的内存释放
内存释放,当一个对象的引用计算变为 0 时,Python 就会调用它的析构函数(__del__)。调用析构函数并不意味着最终一定会调用
free 来释放内存空间,会导致频繁申请、释放内存空间使得 Python 的执行效率大打折扣。因此在析构时也采用了内存池机制,内存池申请到的内存会被归还到内存池中,
以免频繁地申请和释放动作
接下来就会讲到 Python 的垃圾回收机制。
垃圾回收机制
如果别人问你,什么是 Python 的垃圾回收机制,你可以用一句话概括
Python 的垃圾回收机制采用`引用计数为主,标记-清除和分代回收为辅的策略
Python 一切皆为对象,包括 int、str 等等基本的对象。Cpython 的实现中,有一个结构体为 PyObject, Cpython 中的每个其他对象都在使用它
PyObject 是 Python 中所有对象的祖父,它只包含两件事:
- ob_refcnt: 引用计数器
- ob_type: 指向另一种类型的指针
对于对象来说,无非是对象的创建和删除
引用计数
每个对象都会维护一个
ob_refcnt
来记录当前对象被引用的次数
1 | import sys |
对象引用计数 +1
- 对象被创建
a=1
- 对象被创建
- 对象被引用
a=b
- 对象被引用
- 对象被作为参数,传入函数中 def count(a, b)
- 对象作为一个元素被存储
li = [1,2, a]
- 对象作为一个元素被存储
对象的引用计数 -1
- 对象的引用被显示销毁时,
del a
- 对象的引用被显示销毁时,
- 对象的引用别名被赋予新的对象,
a=26
- 对象的引用别名被赋予新的对象,
- 一个对象离开它的作用域, 例如一个函数执行完成后,内部的
局部变量的引用计数器就会减一
(全局变量不变)
- 一个对象离开它的作用域, 例如一个函数执行完成后,内部的
- 将该元素从容器中删除时,或者容器被销毁
当指向对象的内存的引用计数为 0 时,该内存就会被 Python 虚拟机销毁
特性:
优点:1. 高效。2。 实时性,一旦没有引用,内存就直接释放了。3. 对象有确定的生命周期 4. 易于实现
缺点:1. 维护引用计数消耗资源。2. 无法解决循环引用问题
为什么要使用标记清除
对于一个对象来说,当出现循环引用的时候,例如
a=1, b=a, a=b
的时候,就出现了循环引用的情况,这样该对象的引用计数
的值都会是 1 而不是 0,那么垃圾回收的机制就会失效,针对这种情况,又出现了标记清除的功能来解决循环引用的问题。
什么是标记清除?
[标记清除(Mark-Sweep)] 算法是一种基于追踪回收(tracing GC)技术实现的垃圾回收算法。主要针对一些容器对象,例如
list
、dict
、tuple
对于字符串和数值对象是不可能造成循环引用的问题的(不可变对象,当出现引用时,会另外开辟空间存储,这个在缓存机制中会详细解释)。
那么标记清除
算法是如何运行的?
分为两个阶段:
- 第一阶段是标记阶段,GC 会把所有的[活动对象]打上标记。
- 第二阶段是把那些没有标记的[非活动对象]进行回收。
活动对象和非活动对象的标记:
对象之间通过引用(指针)连在一起,构成一个有相图,对象构成这个有向图的节点,而引用关系构成这个有向图的边。
从根对象(root object)出发,沿着有向边遍历对象,可达的(reachable)对象标记为活动对象,不可达的对象就是要被清除的非活动对象。
根对象就是全局变量、调用栈、寄存器。
从以上的有向图中:
- 活动对象:A, B, C
- 非活动对象:D, E
分代回收
在标记清除对象的回收中,整个应用程序会被暂停,为了减少应用程序暂停的时间,Python 通过分代回收(Generational Conlletion)以空间换时间的方法提高垃圾回收效率
Python 将所有对象分为:
- 年轻代(第 0 代)
- 中年代(第 1 代)
- 老年代(第 2 代)
所有新建的对象默认是第 0 代对象,经过 GC 扫描存活下来的对象将被移至第一代,在第一代的 GC 扫描中存活下来的对象将被移至第二代。
GC 扫描次数 (第 0 代 > 第 1 代 > 第 2 代)
GC 分代回收是如何触发的?
当某个世代中被分配的对象与被释放的对象数量之差到达某一个阈值时,就会触发当前一代的 GC 扫描。
当某一世代被扫描时,比它年轻的一代也会被扫描
当第 2 代的 GC 扫描发生时,第 0,1 代的 GC 扫描也会发生,即为全代扫描。
那么这个阈值是如何确定的?
1 | import gc |
- 700=新分配的对象数量-释放的对象数量,第 0 代 gc 扫描被触发
- 第一个10:第 0 代 gc 扫描发生 10 次,则第 1 代的 gc 扫描被触发
- 第二个10:第 1 代的 gc 扫描发生 10 次,则第 2 代的 gc 扫描被触发
Python 的缓存重用机制
Python 缓存重用机制是为了提高程序执行的效率。Python 解释器启动时从内存空间中开辟出一小部分,用来存储高频使用的数据,这样可以大大减少高频使用的
数据创建时申请内存和销毁的开销
这里基本演示,更加详细的演示,读者可以自行尝试
1 | #范围在 [-5, 256] 之间的小整数 |
OUTPUT:
1 | [-5, 256] 情况下的两个变量: 4479762592 4479762592 |
总结:
- 对于
int
,str
,bool
类型而言:- 对于同一个对象,都是缓存同一个内容来使用
- 对于
大于 256 的整数
, 浮点数- 如果位于同一代码块,则使用相同的缓存内容;反之,则不使用
- 对于
小于 0 的整数
- Python 没有对其进行缓存操作
思考
在 Python 中,主要通过 引用计数
来进行垃圾回收;通过标记-清除
来解决一些容器对象的循环引用问题;
通过分代回收
以空间换时间的方式提高垃圾回收效率。
缓存重用机制,作用类似于内存池,提高 Python 的执行效率
参考
「 python 垃圾回收机制 」- https://andrewpqc.github.io/2018/10/08/python-memory-management/
「 python GC 源代码 」- https://docs.python.org/2/library/gc.html
「 python 垃圾回收需要知道事 」- https://rushter.com/blog/python-garbage-collector/