Python程序在运行的时候,需要在内存中开辟出一块空间用于存放运行时产生的临时变量,计算完成后再将结果输出到永久性存储器中 如果数据量过大,内存空间管理不善就很容易出现OOM(out of memory),程序可能被操作系统中止
对于服务器,这种设计为永不中断的系统来说内存管理则显得更为重要,不然很容易引发内存泄漏。什么是内存泄漏呢?
- 程序本身没有设计好,导致程序未能释放已不再使用的内存
- 并不是内存在物理上消失,而是意味着代码在分配了某段内存后,因为设计错误失去了对这段内存的控制,从而造成了内存的浪费
那么,Python又是怎么解决这些问题的?换句话说,对于不会再用到的内存空间,Python是通过什么机制来回收这些空间的呢?
一、计数引用
Python中一切皆对象。因此,所看到的一切变量本质上都是对象的一个指针
那么,怎么知道一个对象是否永远都不能被调用了呢?
非常直观的一个想法,就是当这个对象的引用计数(指针数)为0的时候,说明这个对象永不可达,自然它也就成为了垃圾需要被回收
1.1 局部变量内存使用和释放
看一个例子:
import os
import psutil
def show_memory_info(hint):
pid = os.getpid()
p = psutil.Process(pid)
info = p.memory_full_info()
memory = info.uss / 1024. / 1024
print('{} memory used: {} MB'.format(hint, memory))
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
func()
show_memory_info('finished')
initial memory used: 47.19140625 MB
after a created memory used: 433.91015625 MB
finished memory used: 48.109375 MB
通过这个示例,可以看到调用函数func() ,在列表a 被创建之后内存占用迅速增加到了433MB,而在函数调用结束后内存则返回正常
这是因为,函数内部声明的列表a是局部变量,在函数返回后局部变量的引用会注销掉,此时,列表a所指代对象的引用数为0,Python便会执行垃圾回收,因此占用的大量内存被释放
1.2 全局变量内存使用和释放
明白这个原理后,稍微修改一下代码:
def func():
show_memory_info('initial')
global a
a = [i for i in range(10000000)]
show_memory_info('after a created')
func()
show_memory_info('finished')
initial memory used: 48.88671875 MB
after a created memory used: 433.94921875 MB
finished memory used: 433.94921875 MB
新的这段代码中,global a 表示将a声明为全局变量 那么,即使函数返回后列表的引用依然存在,于是对象就不会被垃圾回收掉,依然占用大量内存
1.3 主程序引用的变量内存使用和释放
同样,如果把生成的列表返回然后在主程序中接收,那么引用依然存在,垃圾回收就不会被触发,大量内存仍然被占用
def func():
show_memory_info('initial')
a = [i for i in derange(10000000)]
show_memory_info('after a created')
return a
a = func()
show_memory_info('finished')
initial memory used: 47.96484375 MB
after a created memory used: 434.515625 MB
finished memory used: 434.515625 MB
1.4 内部引用计数机制
这是最常见的几种情况 由表及里,下面深入看一下Python内部的引用计数机制。先来看代码:
import sys
a = []
print(sys.getrefcount(a))
def func(a):
print(sys.getrefcount(a))
func(a)
print(sys.getrefcount(a))
2
4
2
其中,sys.getrefcount() 函数可以查看一个变量的引用次数。但是需要注意,getrefcount 本身也会引入一次计数
另一个要注意的是,在函数调用发生的时候会产生额外的两次引用,一次来自函数栈,另一个是函数参数
import sys
a = []
print(sys.getrefcount(a))
b = a
print(sys.getrefcount(a))
c = b
d = b
e = c
f = e
g = d
print(sys.getrefcount(a))
2
3
8
需要注意,a、b、c、d、e、f、g这些变量全部指代的是同一个对象,而sys.getrefcount() 函数并不是统计一个指针,而是要统计一个对象被引用的次数,所以最后一共会有八次引用
理解引用这个概念后,引用释放是一种非常自然和清晰的思想。相比C语言里需要使用free去手动释放内存,Python的垃圾回收显得更加省心省力
1.5 手动释放内存
不过,如果偏偏想手动释放内存,应该怎么做呢?
方法同样很简单,只需要先调用del a 来删除对象的引用,然后强制调用gc.collect() 清除没有引用的对象,即可手动启动垃圾回收
import gc
show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
del a
gc.collect()
show_memory_info('finish')
print(a)
initial memory used: 48.1015625 MB
after a created memory used: 434.3828125 MB
finish memory used: 48.33203125 MB
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
<ipython-input-12-153e15063d8a> in <module>
11
12 show_memory_info('finish')
---> 13 print(a)
NameError: name 'a' is not defined
到这里,是不是觉得垃圾回收非常简单呀?
但是如果此时有面试官问,引用次数为0是垃圾回收启动的充要条件吗?还有没有其他可能性呢?
二、循环引用
2.1 相互引用对象的垃圾回收
别急。不妨先思考这么一个问题:如果有两个对象它们互相引用,并且不再被别的对象所引用,那么它们应该被垃圾回收吗?
观察下面这段代码:
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
b = [i for i in range(10000000)]
show_memory_info('after a, b created')
a.append(b)
b.append(a)
func()
show_memory_info('finished')
initial memory used: 47.984375 MB
after a, b created memory used: 822.73828125 MB
finished memory used: 821.73046875 MB
a和b互相引用,并且作为局部变量,在函数 func 调用结束后a和b这两个指针从程序意义上已经不存在了。但是,很明显依然有内存占用!为什么呢?因为互相引用导致它们的引用数都不为0
试想一下,如果这段代码出现在生产环境中,哪怕a和b一开始占用的空间不是很大,但经过长时间运行后Python所占用的内存一定会变得越来越大,最终撑爆服务器
当然,互相引用还是很容易被发现的,问题不大。可是,更隐蔽的情况是出现一个引用环,在工程代码比较复杂的情况下,引用环还真不一定能被轻易发现
那么,应该怎么做呢?
2.2 垃圾回收循环引用的变量
事实上,Python本身能够处理这种情况,可以显式调用gc.collect() 来启动垃圾回收
import gc
def func():
show_memory_info('initial')
a = [i for i in range(10000000)]
b = [i for i in range(10000000)]
show_memory_info('after a, b created')
a.append(b)
b.append(a)
func()
gc.collect()
show_memory_info('finished')
initial memory used: 49.51171875 MB
after a, b created memory used: 824.1328125 MB
finished memory used: 49.98046875 MB
所以可见,Python的垃圾回收机制并没有那么弱
2.3 垃圾回收算法
Python使用标记清除(mark-sweep)算法和分代收集(generational),来启用针对循环引用的自动垃圾回收
2.3.1 标记清除算法
先来看标记清除算法
先用图论来理解不可达的概念。对于一个有向图,如果从一个节点出发进行遍历并标记其经过的所有节点,那么在遍历结束后,所有没有被标记的节点就称之为不可达节点 显而易见,这些节点的存在是没有任何意义的,自然的需要对它们进行垃圾回收
当然,每次都遍历全图对于Python而言是一种巨大的性能浪费。所以,在Python的垃圾回收实现中,mark-sweep 使用双向链表维护了一个数据结构,并且只考虑容器类的对象(只有容器类对象才有可能产生循环引用)
2.3.2 分代收集算法
分代收集算法,则是另一个优化手段 Python将所有对象分为三代,刚刚创立的对象是第 0 代,经过一次垃圾回收后依然存在的对象,便会依次从上一代挪到下一代,而每一代启动自动垃圾回收的阈值则是可以单独指定的。当垃圾回收器中新增对象减去删除对象达到相应的阈值时,就会对这一代对象启动垃圾回收
事实上,分代收集基于的思想是新生的对象更有可能被垃圾回收,而存活更久的对象也有更高的概率继续存活。因此,通过这种做法可以节约不少计算量,从而提高Python的性能
刚刚面试官的问题可以回答得上来了么? 没错,引用计数是其中最简单的实现,不过切记,引用计数并非充要条件,它只能算作充分非必要条件,至于其他的可能性,循环引用正是其中一种
三、调试内存泄漏
不过,虽然有了自动回收机制,但这也不是万能的,内存泄漏是不想见到的,但是有没有什么好的调试手段呢?
答案当然是肯定的,接下来介绍一个得力助手objgraph
objgraph,一个非常好用的可视化引用关系的包
3.1 show_refs
在这个包中主要推荐两个函数,第一个是show_refs(),它可以生成清晰的引用关系图
通过下面这段代码和生成的引用调用图,能非常直观地发现有两个list互相引用,说明这里极有可能引起内存泄露
import objgraph
a = [1, 2, 3]
b = [4, 5, 6]
a.append(b)
b.append(a)
objgraph.show_refs([a])
3.2 show_backrefs
另一个非常有用的函数是show_backrefs(),其中的示例代码和生成图:
import objgraph
a = [1, 2, 3]
b = [4, 5, 6]
a.append(b)
b.append(a)
objgraph.show_backrefs([a])
相比刚才的引用调用图,这张图显得稍微复杂一些。但是这个API有很多有用的参数,比如层数限制(max_depth)、宽度限制(too_many)、输出格式控制(filename output)、节点过滤(filter, extra_ignore)等,可以查阅对应的文档述
四、总结
了解Python的垃圾回收机制,主要强调下面这几点:
- 垃圾回收是Python自带的机制,用于自动释放不会再用到的内存空间
- 引用计数是其中最简单的实现,不过切记这只是充分非必要条件,因为循环引用需要通过不可达判定来确定是否可以回收
- Python的自动回收算法包括标记清除和分代收集,主要针对的是循环引用的垃圾收集
- 调试内存泄漏方面,objgraph是很好的可视化分析工具
五、思考题
实现一个垃圾回收判定算法,输入是一个有向图,给定起点表示程序入口点,给定有向边,输出不可达节点
经典的dfs(深度优先搜索)遍历,从起点开始遍历,对遍历到的节点做个记号。遍历完成后再对所有节点扫一遍,没有被做记号的就是需要垃圾回收
from typing import Set
class Graph:
def __init__(self, value, nodes=None):
self._value = value
self._nodes: list = [] if nodes is None else nodes
@property
def value(self):
return self._value
@property
def nodes(self):
return self._nodes
def node_add(self, node):
self._nodes.append(node)
def node_adds(self, nodes):
self._nodes.extend(nodes)
def node_del(self, node):
self._nodes.remove(node)
def __str__(self):
return "Graph {} nodes {}".format(self._value, [node.value for node in self.nodes])
def __repr__(self):
return self.__str__()
def dfs(start: Graph, includes: Set[Graph] = None) -> Set[Graph]:
if includes is None:
includes = set()
if start in includes:
return includes
includes.add(start)
for s in start.nodes:
includes.update(dfs(s, includes))
return includes
if __name__ == '__main__':
A = Graph('A')
B = Graph('B')
C = Graph('C')
D = Graph('D')
E = Graph('E')
F = Graph('F')
has_nodes = {A, B, C, D, E, F}
A.node_adds([B, C])
B.node_adds([A, E])
C.node_adds([E])
D.node_adds([F])
F.node_adds([D])
graph_nodes = dfs(A, set())
print(graph_nodes)
print(has_nodes - graph_nodes)
|