说实话,感觉又是很工程化、偏底层的一章。也是自己平时几乎不怎么考虑的。
首先我们得知道,python运行时候是要占用内存的。一般情况下我们跑简单的程序也不用太担心,毕竟内存都够。但是一旦程序大了,有些地方写得不太对的话,就有概率出现爆内存的问题。
OK,那么就要回头看看,python到底如何管理内存的,以及有哪些坑点,需要我们注意。
Python 的内存管理主要依赖引用计数作为即时回收机制,辅以分代垃圾回收(标记-清除)来解决循环引用问题。
引用计数
每个 Python 对象内部都有一个计数器,记录有多少个“名字”指向它。当一个对象引用计数变为 0,对象立即释放。这是 Python 内存管理的第一优先级机制。
下面这个代码就展示了列表对象有多少个引用计数。至于为啥要减去一,主要是因为我们统计引用计数用到的函数也算一次引用计数……
import sys a = [] print(sys.getrefcount(a)-1)常见的增加引用计数:
- 赋值:
b = a- 作为参数传入函数
- 放入容器(list / dict / set)
常见的减少引用计数:
del a(这儿需要注意,del只是接触绑定,不是直接释放内存)- 变量被重新绑定
- 函数结束(局部变量销毁)
- 容器删除元素
看起来似乎挺不错的,有用到某个对象就留着,没人用到了就清零。所以引用计数优点就是实时回收、实现简单可预测、内存占用稳定。但是其也有个致命缺点,就是无法解决循环引用。、
循环引用和gc
啥叫循环引用呢?
a = [] b = [] a.append(b) b.append(a) del a del b
就是如上所示,b引用a,a引用b。按照上述逻辑a列表的对象引用计数为2。常规逻辑来说,我们删除a、删除b之后,引用计数应该为0。但是因为这儿是循环引用,有bug,引用计数不会为0了,这就导致引用计数永远不会释放这个对象的内存,即便在程序里已经删除了对应的变量。
这就需要第二个工具,垃圾回收器gc出来工作了。gc专门处理“引用计数不为0,但从程序角度已经不可达的对象。”
gc主要通过两步走战略来解决循环引用问题:
- 标记(Mark):从根对象出发,标记所有“还能访问到”的对象
- 清除(Sweep):清除没有被标记的对象
“根对象”是啥呢?主要包括
- 全局变量
- 当前栈帧中的局部变量
- 活跃线程相关对象
循环引用但不可达的对象,会在这一步被回收。
当然了,gc也不可能像某洲一样全天24h全量扫盘,而是分代垃圾回收(到底是谁翻译的啊……奇奇怪怪的名字)。
分代是什么意思呢?
- 第 0 代:新创建对象
- 第 1 代:经历过一次 gc 还存活
- 第 2 代:长期存活对象
分代垃圾回收基本策略:
- 年轻代回收频率高
- 老年代回收频率低
此外,gc也不是啥对象都跟踪,主要是容易触发循环引用的list、dict、set等容易跟踪,其他的int、float、str、tuple啥的就默认不参与gc,这可能也是为什么 tuple 在性能上更友好。
内存泄漏
ok,我们了解了引用计数以及gc,那有没有可能出现这两种机制都不回收的情况呢?有的,兄弟,包有的。下面就是常见的python内存泄漏情况。
全局缓存无限增长
全局变量且cache[x] 对 heavy_obj(x) 建立了强引用,引用计数 ≥ 1,永远不为 0。此外cache 是根对象(全局变量),所有 value 都从 cache 可达。因此不会回收。
cache = {}
def f(x):
cache[x] = heavy_obj(x)
闭包引用大对象
def outer():
big = [0] * 10_000_000
def inner():
return big
return inner
这儿inner 是一个函数对象,inner.__closure__ 保存了对 big 的引用。只要 fn 还存在:永远不会归零,big 的引用计数 ≥ 1。gc那儿来看对象也是正常可达。
(3)循环引用 + del 方法(高危)
如果对象定义了 __del__,gc 不会自动回收循环引用对象。这个似乎更少见,del本身好像就没咋见过。有兴趣的朋友可以自己看下。