Python 学习 ---> 内存管理、垃圾回收

面试官:听说你学Python?那你给我讲讲Python如何进行内存管理?
我:???内存管理不太清楚额。。。
面试官:那你知道Python垃圾回收吗?
我:(尴尬一下后,还好我看到过相关博客)Python垃圾回收引用计数为主、标记清除和分代回收为主。
面试官:那你仔细讲讲这三种垃圾回收技术?
我:卒。。。

gc --- 垃圾回收器接口:https://docs.python.org/zh-cn/3.11/library/gc.html

30分钟搞懂Python内存管理&垃圾回收原理:https://www.bilibili.com/video/BV1F54114761

1、内存 管理

内存的管理简单来说:分配(malloc)+回收(free)。先思考一下:如果是你设计,会怎么进行内存管理?答:好,不会设计(我也不会),会的大佬请绕过。我们一起了解看看 Python 是怎么设计的。为了提高效率就是:

  • 如何高效分配?
  • 如何有效回收?

什么是内存

买电脑的配置“4G + 500G / 1T”,这里的 4G 就是指电脑的内存容量,而电脑的硬盘 500G / 1T。

内存(Memory,全名指内部存储器),自然就会想到外存,他们都硬件设备。

内存是计算机中重要的部件之一,它是外存与 CPU 进行沟通的桥梁。计算机中所有程序的运行都是在内存中进行的,因此内存的性能对计算机的影响非常大。

内存就像一本空白的书

首先,您可以将计算机的存储空间比作一本空白的短篇小说。页面上还没有任何内容。最终,会有不同的作者出现。每个作者都需要一些空间来写他们的故事。

由于不允许彼此书写,因此必须注意他们能书写的页面。开始书写之前,请先咨询书籍管理员。然后,管理员决定允许他们在书中写什么。

如果这书已经存在很长时间了,因此其中的许多故事都不再适用。当没有人阅读或引用故事时,它们将被删除以为新故事腾出空间。

本质上,计算机内存就像一本空书。实际上,调用固定长度的连续内存面块是很常见的,因此这种类比非常适用。

  • 作者:就像是不同应用程序或进程,
  • 小说:就像是程序或进程的数据。
  • 内存:就像是书,
  • 决定作者在书中书写位置的管理员就像是各种存储器管理的角色,
  • 删除旧故事为新故事腾出空间的人是垃圾收集者(garbage collector)。

内存管理:从硬件到软件

为什么 4G 内存的电脑可以高效的分析上 G 的数据,而且程序可以一直跑下去。

在这 4G 内存的背后,Python 都帮助我们做了什么?

内存管理是应用程序读取和写入数据的过程。内存管理器确定将应用程序数据放置在何处。

由于内存有限,类比书中的页面一样,管理员必须找到一些可用空间并将其提供给应用程序。提供内存的过程通常称为内存分配。

其实如果我们了解内存管理机制,以更快、更好的方式解决问题。

看完本篇文章,带您稍微了解 Python 内存管理的设计哲学。

对象管理

能我们听过,Python 鼎鼎有名的那句“一切皆对象”。是的,在 Python 中数字是对象,字符串是对象,任何事物都是对象,Cpython 下,而 Python 对象实现的核心就是一个结构体--PyObject。

typedef struct_object{
  int ob_refcnt;
  struct_typeobject *ob_type;
}PyObject;

PyObject 是每个对象必有的内容,可以说是 Python 中所有对象的祖父,仅包含两件事:

  • ob_refcnt:引用计数(reference count)
  • ob_type:指向另一种类型的指针(pointer to another type)

所以,所以 CPython 是用 C 编写的,它解释了 Python 字节码。这与内存管理有什么关系?

CPython 的内存管理

下图的深灰色框现在归 Python 进程所有。

Python 将部分内存用于内部使用和非对象内存。另一部分专用于对象存储(您的 int,dict 等)。请注意,这已被简化。如果您需要全貌,则可以看 CPython 源代码,所有这些内存管理都在其中进行。

CPython 有一个对象分配器,负责在对象内存区域内分配内存。这个对象分配器是大多数魔术发生的地方。每当新对象需要分配或删除空间时,都会调用该方法。

通常,为 list 和 int 等 Python 对象添加和删除数据一次不会涉及太多数据。因此,分配器的设计已调整为可以一次处理少量数据。它还尝试在绝对需要之前不分配内存。

现在,我们来看一下 CPython 的内存分配策略。首先,我们将讨论这三个主要部分以及它们之间的关系。

Python 的内存分配器

内存结构

在 Python 中,当要分配内存空间时,不单纯使用 malloc / free,而是在其基础上堆放 3 个独立的分层,有效率地进行分配。

第 0 层往下是 OS 的功能。

第 -2 层是隐含和机器的物理性相关联的部分,OS 的虚拟内 存管理器负责这部分功能。

第 -1 层是与机器实际进行交互的部分,OS 会执行这部分功能。 因为这部分的知识已经超出了本书的范围,我们就不额外加以说明了。

在第 3 层到第 0 层调用了一些具有代表性的函数,其调用图如下。

第 0 层 通用的基础分配器

以 Linux 为例,第 0 层指的就是 glibc 的 malloc() 这样的分配器,是对 Linux 申请内存的部分。

Python 中并不是在生成所有对象时都调用 malloc(),而是根据要分配的内存大小来改 变分配的方法。

  • 申请的内存大小如果大于 256 字节,就老实地调用 malloc();
  • 如果小于等 于 256 字节,就要轮到第 1 层和第 2 层出场了。

第 1 层 Python 低级内存分配器

Python 中使用的对象基本上都小于等于 256 字节,并且净是一些马上就会被废弃的对象。请看下面的例子。

for x in range(100):
    print(x)

上述 Python 脚本是把从 0 到 99 的非负整数 A 转化成字符串并输出的程序。这个程序会大量使用一次性的小字符串。

在这种情况下,如果逐次查询第 0 层的分配器,就会发生频繁调用 malloc() 和 free() 的情况,这样一来效率就会降低。

因此,在分配非常小的对象时,Python 内部会采用特殊的处理。实际执行这项处理的就是第 1 层和第 2 层的内存分配器。

当需要分配小于等于 256 字节的对象时,就利用第 1 层的内存分配器。在这一层会事先 从第 0 层开始迅速保留内存空间,将其蓄积起来。第 1 层的作用就是管理这部分蓄积的空间。

第 1 层 处理的信息的内存结构 ( arena、pool、block  )

根据所管理的内存空间的作用和大小的不同,称最小的单位为 block,最终返回给申请者的就是这个 block 的地址。比 block 大的单位的是 pool, pool 内部包含 block,pool 再往上叫作 arena。

也就是说 arena > pool > block,感觉很像俄罗斯套娃吧。为了避免频繁调用 malloc() 和 free(),第 0 层的分配器会以最大的单位 arena 来保留 内存。pool 是用于有效管理空的 block 的单位。arena 这个词有“竞技场”的意思。大家可以理解成竞技场里有很多个 pool,pool 里面漂 浮着很多个 block,这样或许更容易理解一些。

arena

Arenas 是最大的内存块,并在内存中的页面边界上对齐。页面边界是操作系统使用的固定长度连续内存块的边缘。Python 假设系统的页面大小为 256 KB。

Arenas 内有内存池,池是一个虚拟内存页(4 KB)。这些就像我们书中类比的页面。这些池被分成较小的内存块。

给定池中的所有块均具有相同的“大小等级”。给定一定数量的请求数据,大小类定义特定的块大小。

  • 针对小对象(<= 512 bytes),Pymalloc 会在内存池中申请内存空间
  • > 512bytes,则会 PyMem_RawMalloc()和 PyMem_RawRealloc()来申请新的内存空间

例如,如果请求 42 个字节,则将数据放入 48 字节大小的块中。

pool

arena 内部各个 pool 的大小固定在 4K 字节。因为几乎对所有 OS 而言,其虚拟内存的页 面大小都是 4K 字节,所以我们也相应地把 pool 的大小设定为 4K 字节。

第 1 层总结:第 1 层 的任务可以用一句话来总结,那就是“管理 arena”。

第 2 层 Python 对象分配器

第 2 层的分配器负责管理 pool 内的 block。这一层实际上是将 block 的开头地址返回给申请者,并释放 block 等。 那么我们来看看这一层是如何管理 block 的吧。

block

pool 被分割成一个个的 block。在 Python 中生成对象时,最终都会被分配这个 block (在要求大小不大于 256 字节的情况下)。以 block 为单位来划分,这是从 pool 初始化时就决定好的。这是因为我们一开始利用 pool 的时候就决定了“这是供 8 字节的 block 使用的 pool”。pool 内被 block 完全填满了,那么 pool 是怎么进行 block 的状态管理的呢? block 只有以下三种状态。

  1. 已经分配
  2. 使用完毕
  3. 未使用

第 3 层 对象特有的分配器

对象有列表和元组等多种多样的型,在生成它们的时候要使用各自特有的分配器。

分配器的总结

赋值语句内存分析

我们可以通过使用 id()函数 来查看某个对象的内存地址,每个人的电脑内存地址不一样。

a = 1
id(a)   # Output: 4566652048
b = 2
id(b)   # Output: 4566652080
c = 8
id(c)   # Output: 4566652272
d = 8
id(d)   # Output: 4566652272

使用 == 来查看对象的值是否相等,is 判断对象是否是同一个对象

c == d  # Output: True
c is d  # Output: True

e = 888888888
id(e)     # Output: 4569828784
f = 888888888
id(f)     # Output: 4569828880
e == f    # Output: True
e is f    # Output: False

解释:我们可以看到,

  • c == d 输出 True 和 c is d也输出 True,这是因为,对一个小一点的 int 变量赋值,Python 在内存池(Pool)中分配给 c 和 d 同一块内存地址,
  • e == f为 True,值相同;e is f输出 False,并不少同一个对象。

这是因为 Python 内存池中分配空间,赋予对象的类别并赋予其初始的值。从-5 到 256 这些小的整数,在 Python 脚本中使用的非常频繁,又因为他们是不可更改的,因此只创建一次,重复使用就可以了。

e 和 f 数字比较大,所以只能重新分配地址来。其实 -5 到 256 之间的数字,Python 都已经给我安排好了。

>>> i = 256
>>> j = 256
>>> i is j
True
>>> i = 257
>>> j = 257
>>> i is j
False
>>> i = -5
>>> j = -5
>>> i is j
True
>>> i = -6
>>> j = -6
>>> i is j
False

接着,看对象的内存分析:

li1 = []
li2 = []
li1 == li2  # Output: True
li1 is li2  # Output: False

x = 1
y = x
id(x)  # Output: 4566652048
id(y)  # Output: 4566652048
y = 2
id(y)  # Output: 4566652080

x == y  # Output: False
x is y  # Output: False

2、垃圾回收

Garbage collection ( GC:垃圾收集 )

现在的高级语言如java,c#等,都采用了垃圾收集机制,而不再是c,c++里用户自己管理维护内存的方式。自己管理内存极其自由,可以任意申请内存,但如同一把双刃剑,为大量内存泄露,悬空指针等bug埋下隐患。
对于一个字符串、列表、类甚至数值都是对象,且定位简单易用的语言,自然不会让用户去处理如何分配回收内存的问题。

python 也同 java 一样采用了垃圾收集机制。

双向 环状 链表 refchain

主要关注Include中的".h"文件以及Objects目录中的".c"文件。从Include和Objects中的文件类型就可以看出Python解释器是C语言编写的。在Include文件夹中,全部都是".h"文件。这些C语言头文件中主要存放着宏、函数声明、结构体声明、全局变量等。我们在Python中所有的类都继承自Object,所以在这个C语言的object.h中,我们可以看看是如何实现的。

我们首先看object.h文件内容(小部分):

python是用C语言编写的,在python的底层维护着一个双向环状链表refchain,该链表中存储着我们在python中创建的所有变量指向的对象,也就是说当我们在python中创建一个变量时并赋值时,就会在该链表中添加一个对象。而该对象是一个结构体。结构体中存储着该对象的信息:

_ob_next:上一个对象
_ob_prev:下一个对象
ob_type:对象类型
ob_refcnt:被引用的次数
ob_value:值

在refchain中的所有对象内部都有一个ob_refcnt用来保存当前对象的引用计数器,顾名思义就是自己被引用的次数

当值被多次引用时候,不会在内存中重复创建数据,而是 引用计数器+1

当对象被销毁时候同时会让 引用计数器-1

如果 引用计数器为0,则将对象从 refchain 链表中摘除,同时在内存中进行销毁(暂不考虑缓存等特殊情况)。

引用计数器+1的情况:

  • 对象被创建
  • 另外的变量也指向当前对象
  • 作为容器对象的一个元素(如list)
  • 作为参数传递为函数

引用计数器-1的情况:

  • 变量被显式的销毁(如:del 变量)
  • 指向当前对象的变量重新赋值
  • 从容器中移除
  • 函数执行完成 

可以从上面的源码中看到,两个结构体 PyObject 和 PyVarObject,区别是PyVarObject多一个ob_size属性,这个属性代表的是元素的个数(例如list、dict中元素的个数)。

所以,这两个结构体,分别对应不同类型的数据的头(Python中任何数据的定义,都会有这个头):

PyObject:float

PyVarObject:list、dict、tuple、set、int、str、bool

因为Python中的int是不限制长度的,所以底层实现是用的str,所以int也属于PyVarObject阵营。Python中的bool实际上是0和1,所以也是int,也属于PyVarObject阵营。

floatobject.h

我们以float类型为例,可以看到创建一个float类型的数据,实际上是创建了一个PyFloatObject结构体的实例。

PyFloatObject结构体中包含了一个PyObject_HEAD(这就是object.h中的PyObject),以及一个double ob_fval,这个double变量就是我们存放的值。

我们以Python中的实际操作,来看源码中的过程:

1)python中定义变量 v = 0.3:
    源码流程:
    a.开辟内存(内存大小,是sizeof(PyFloatObject))
    b.初始化
      ob_fval=0.3
      ob_type=float
      ob_refcnt=1
    c.将对象加入双向链表refchain中

2)python执行操作name=v:
    源码流程:ob_refcnt+=1

3)python执行操作del v:
    源码流程:ob_refcnt-=1

4)python执行
    def func(arg):
        print(arg)
     
    func(name)
    源码流程:
    执行时开辟栈:ob_refcnt+=1
    结束时销毁栈:ob_refcnt-=1

5)python执行del name:
    源码流程:ob_refcnt-=1
    在这几次操作中,每次进行ob_refcnt-=1的时候都会判断ob_refcnt是否等于0。
    如果是0,这将其归为垃圾,按理说GC回收器应该将其回收

如果float变量的引用都被删除,引用计数为0以后,按理说GC回收器应该对其进行回收。

1.free_list缓存链表

但编译器认为,用户经常都要定义float类型的变量,所以他将该PyFloatObject对象从refchain链表中拿出来,并且放到另一个单向链表中,这个单向链表就是缓存(叫free_list)

我们做个验证:

>>> v = 8.9
>>> name = v
>>> del v
>>> id(name)
1706304905888
>>> del name
>>> xx = 9.0
>>> id(xx)
1706304905888
>>>

可以看到,name的id为1706304905888,删除name后,由创建了一个float变量xx,结果xx的id还是为170630490588。这就验证了缓存的机制。

为什么要使用缓存(free_list)?

  因为回收内存空间和开辟内存空间都要消耗时间,所以,如果将空间放到缓存中,有新的float变量被定义的话,直接从缓存中拿到地址,重新进行一次初始化,并将新的值赋给ob_fval即可。

2.free_list最大长度

注意,这里的单向链表(free_list)只是针对PyFloatObject类型的。而且这个链表有最大长度100。可以在floatobject.c中看到相关定义:

#ifndef PyFloat_MAXFREELIST
// 定义free_list的最大长度
#define PyFloat_MAXFREELIST 100
#endif
// 用numfree来表示当前free_list有多长
static int numfree = ;
// free_list指针
static PyFloatObject *free_list = NULL;

例如同时有1000个float变量的引用计数变为0,则归入free_list的只有100个,其余900个可能会被回收。

在float中,free_list的最大长度是100,而在其他的数据类型中,最大长度可能不一样。

例如list的free_list的最大长度为80:

#ifndef PyList_MAXFREELIST
#define PyList_MAXFREELIST 80
#endif
static PyListObject *free_list[PyList_MAXFREELIST];
static int numfree = ;

dict也为80:

#ifndef PyDict_MAXFREELIST
#define PyDict_MAXFREELIST 80
#endif
static PyDictObject *free_list[PyDict_MAXFREELIST];
static int numfree = ;
static PyDictKeysObject *keys_free_list[PyDict_MAXFREELIST];
static int numfreekeys = ;

3.其他优化机制

也不是所有的数据类型都使用free_list缓存机制,例如int用的是小数据池进行优化:

#ifndef NSMALLPOSINTS
#define NSMALLPOSINTS 257
#endif
#ifndef NSMALLNEGINTS
#define NSMALLNEGINTS 5
#endif

垃圾回收机制

来看一下 Python 中的垃圾回收技术:

  • "引用计数" 为主
  • "标记清除"、"分代回收" 为辅

如果一个对象的引用计数为 0,Python 解释器就会回收这个对象的内存,但引用计数的缺点是不能解决循环引用的问题,所以我们需要标记清除和分代回收。

循环引用一般发生在列表、字典、对象等容器类对象,他们之间可以互相嵌套,例如:

a = [1, 2]
b = [4, 5]
# b的引用计数会加1,变为2
a.append(b)

# a的引用计数变为0
del a
# b的引用计数变为1,但是已经无法访问b,所以就形成了内存泄漏
del b

在这种情况下, 内存发生了泄漏,就要利用标记清除来解决循环引用的问题。

标记清除

标记清除的目的

基于引用计数器进行垃圾回收非常方便和简单,但他还是存在循环引用/交叉感染的问题,导致无法正常的回收一些数据,例如:

l1 = [1,2,3] # refchain中创建一个列表对象,计数器为1.
l2 = [4,5,6] # refchain中再创建一个列表对象,引用计数器为1.
l1.append(l2)   #把l2追加到l1中,l2指向的对象的引用计数器+1,最终为2
l2.append(l1)   #把l1追加到l2中,l1指向的对象的引用计数器+1,最终为2
del l1          # 引用计数器-1
del l2          # 引用计数器-1
对于上述代码会发现,指向del操作之后,没有变量指向那两个列表对象,但由于循环引用的问题,他们的引用计数器不为0,所以他们的状态:永远不会被使用、也不会被销毁。项目中如果这种代码太多,就会导致内存一直被消耗,直到内存被耗尽,程序崩溃。

为了解决循环引用的问题,引入了标记清除技术,专门针对那些可能存在循环引用的对象进行特殊处理,可能存在循环应用的类型有:列表、元组、字典、集合、自定义类等那些能进行数据嵌套的类型。

针对那些容器类的对象,在Python中会将他们单独放到一个双向链表(非refchain)中,做定期扫描。参考:https://www.cnblogs.com/saolv/p/8411993.html

#第一组循环引用#
a = [1,2]
b = [3,4]
a.append(b)
b.append(a)
del a

##

#第二组循环引用#

c = [4,5]
d = [5,6]
c.append(d)
d.append(c)
del c
del d
#至此,原a和原c和原d所引用的对象的引用计数都为1,b所引用的对象的引用计数为2,
e [7,8]
del e

现在说明一下标记清除:代码运行到上面这块了,此时,我们的本意是想清除掉c和d和e所引用的对象,而保留a和b所引用的对象。但是c和d所引用对象的引用计数都是非零,原来的简单的方法只能清除掉e,c和d所引用对象目前还在内存中。

  假设,此时我们预先设定的周期时间到了,此时该标记清除大显身手了。他的任务就是,在a,b,c,d四个可变对象中,找出真正需要清理的c和d,而保留a和b。

  首先,他先划分出两拨,一拨叫root object(存活组),一拨叫unreachable(死亡组)。然后,他把各个对象的引用计数复制出来,对这个副本进行引用环的摘除。

  环的摘除:假设两个对象为A、B,我们从A出发,因为它有一个对B的引用,则将B的引用计数减1;然后顺着引用达到B,因为B有一个对A的引用,同样将A的引用减1,这样,就完成了循环引用对象间环摘除。

  摘除完毕,此时a的引用计数的副本是0,b的引用计数的副本是1,c和d的引用计数的副本都是0。那么先把副本为非0的放到存活组,副本为0的打入死亡组。如果就这样结束的话,就错杀了a了,因为b还要用,我们把a所引用的对象在内存中清除了b还能用吗?显然还得在审一遍,别把无辜的人也给杀了,于是他就在存活组里,对每个对象都分析一遍,由于目前存活组只有b,那么他只对b分析,因为b要存活,所以b里的元素也要存活,于是在b中就发现了原a所指向的对象,于是就把他从死亡组中解救出来。至此,进过了一审和二审,最终把所有的任然在死亡组中的对象通通杀掉,而root object继续存活。b所指向的对象引用计数任然是2,原a所指向的对象的引用计数仍然是1

扫描后存活组的对象,将放到另外一个链表中去,一共有3个这样的链表,代表3代。

标记清除的作用

创建特殊链表专门用于保存 列表、元组、字典、集合、自定义类等对象,之后再去检查这个链表中的对象是否存在循环引用,如果存在则让双方的引用计数器均 - 1  ,此时对于垃圾回收存在两个两个链表:

  • 1、存储所有定义变量指向对象的双向环状链表refchain
  • 2、存储可能存在循环引用的列表、元组、字典、集合、自定义类等对象的链表。
  • 注意(如列表等可能存在循环引用的对象既会存储在双向环状链表中也会存储在该链表中)

标记清除的缺点:

  • 1、标记清除只会在python内存触发某种条件后才会被作用(即什么时候扫描链表?)
  • 2、标记清除会循环整个链表,并去检查是否存在循环引用,对此时间效率非常低

分代回收

分代回收就是指维护容器类对象的三个链表,3个链表对应三层。对最底层的链表扫描10次,才对上层的链表扫描一次。

这其实是为了节省性能,尽量少扫描对象。

认为没有问题经常使用的对象放入上一层,减少扫描次数。

所以,在Python的内存管理中,一共维护着4个链表,其中一个链表refchain用来管理一般的数据类型,例如float等。而另外3个链表组成分代,管理容器类数据类型。

  • 分代回收的目的:解决标记清除的两个缺点
  • 分代回收的作用:对标记清除中的链表进行优化,将那些可能存在循环引用的对象拆分到3个链表,链表分为:0/1/2三代,每代都可以存储对象和阈值,当达到阈值时,就会对相应的链表中的每个对象做一次扫描,将有循环引用的对象的计数器各自减1并且销毁引用计数器为0的对象

三个链表的阈值:

  • 0代链表:0代链表中的对象达到700个时扫描一次
  • 1代链表:0代链表被扫描10次时,1代链表扫描一次
  • 2代链表:1代链表被扫描10次时,2代链表扫描一次

三个链表中都有两个属性:count,threshold:

  • 0 代,count表示0代链表中对象的数量,threshold表示0代链表对象个数阈值,超过则执行一次0代扫描检查。
  • 1 代,count表示0代链表扫描的次数,threshold表示0代链表扫描的次数阈值,超过则执行一次1代扫描检查。
  • 2 代,count表示1代链表扫描的次数,threshold表示1代链表扫描的次数阈值,超过则执行一2代扫描检查。

情景模拟 :

根据C语言底层并结合图来讲解内存管理和垃圾回收的详细过程。

第一步:当创建对象age=19时,会将对象添加到refchain链表中。

第二步:当创建对象num_list = [11,22]时,会将列表对象添加到 refchain 和 generations 0代中。

第三步:新创建对象的数量达到0代链表上的对象数量大于阈值700时,要对0代对象链表上的对象进行扫描检查。

当0代链表中存储对象的数量大于阈值后,底层不是直接扫描0代,而是先判断2、1是否也超过了阈值。

  • 如果2、1代未达到阈值,则扫描0代,并让1代的 count + 1 。
  • 如果2代已达到阈值,则将2、1、0三个链表拼接起来进行全扫描,并将2、1、0代的count重置为0.
  • 如果1代已达到阈值,则讲1、0两个链表拼接起来进行扫描,并将所有1、0代的count重置为0.

垃圾回收机制的总结:

        在python中维护着一个双向环状链表refchain,这个链表存储着我们在程序中创建的所有变量指向的对象,每个对象都维护着一个计数器,如果该对象被其他对象引用则计数器加一,如果引用该对象的变量被销毁或者被重新赋值,则计数器减一。最后当计数器为0时,则从refchain中移除,内存中销毁该对象,释放内存。

        但是对于 列表、字典等可以由多个元素组成的对象之间可能存在循环引用/交叉感染的问题,为了解决这个问题,python内部又引入了清除标记、分代回收的技术。在python底层划分出四个链表。refchain、0代链表、1代链表、2代链表。refchain中存储所有的对象,而0,1,2代对象中存储可能存在循环引用的对象。如果达到0、1、2代链表的阈值时则会扫描链表查看是否存在循环引用,如果存在则涉及的对象的计数器减一。

什么是引用计数

  • 每个对象都有存有指向该对象的引用总数
  • 查看某个对象的引用计数 sys.getrefcount()
  • 可以使用 del 关键字删除某个引用

import sys

l = []
print(sys.getrefcount(l)) # Output: 2
l2 = l
l3 = l
l4 = l3
print(sys.getrefcount(l)) # Output: 5
del l2
print(sys.getrefcount(l)) # Output: 4
i = 1
print(sys.getrefcount(i)) # Output: 171
a = i
print(sys.getrefcount(i)) # Output: 172

当对象的引用计数达到零时,解释器会暂停并取消分配它 ( 以及仅可从该对象访问的所有对象 )。即满足引用计数为 0 的时候,会启动垃圾回收。

Python 内部的引用计数机制

Python 中一切皆对象。因此,一切变量,本质上都是对象的一个指针。

import os
import psutil


# 显示当前 python 程序占用的内存大小
def show_memory_info(hint):
    pid = os.getpid()  # 进程ID
    p = psutil.Process(pid)  # 返回进程对象,不传 pid 默认会获取当前的pid
    info = p.memory_full_info()  # pfullmem 对象
    memory = info.uss / 1024. / 1024
    print(f'{hint} memory used: {memory} MB')


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: 6.7578125 MB
after a created memory used: 391.0 MB
finished memory used: 7.7265625 MB
"""

示例中调用函数 func(),在列表 a 被创建之后,内存占用迅速增加到了 391MB,而在函数调用之后,内存返回正常。

这是因为函数内部声明的列表a是局部变量,在函数返回后,局部变量的引用会注销掉;此时,列表a所指代对象的引用计数为0,python便会执行垃圾回收,因此之前占用的大量内存被释放了。

import os
import psutil


def show_memory_info(hint):
    pid = os.getpid()
    p = psutil.Process()
    info = p.memory_full_info()
    memory = info.uss / 1024 / 1024
    print(f'{hint} memory used: {memory} MB')


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: 8.4765625 MB
after a created memory used: 395.86328125 MB
finished memory used: 395.86328125 MB
"""

global a 将 a 声明为全局变量。那么,即使函数返回后,列表的引用依然存在,于是对象就不会被垃圾回收掉,依然占用大量内存。

同样,如果我们把生成的列表返回,然后在主程序中接收,那么引用依然存在,垃圾回收就不会触发,大量内存仍然被占用着:

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')

上面是最常见的几种情况。

下面分析 python 内部的引用计数机制

import sys

a = []
# 两次引用,一次来自 a,一次来自 getrefcount
print(sys.getrefcount(a))


def func(a):
    # 四次引用,a,python 的函数调用栈,函数参数,和 getrefcount
    print(sys.getrefcount(a))


func(a)
# 两次引用,一次来自 a,一次来自 getrefcount,函数 func 调用已经不存在
print(sys.getrefcount(a))

sys.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))  # 八次

理解了引用这个概念后,引用释放是一种非常自然和清晰的思想。相比C语言里需要自己使用free去手动释放内存,python 自带垃圾回收,如果想手动回收可以先 del a 来删除一个对象;然后强制调用 gc.collect(),即可手动启动垃圾回收。

import gc
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(f'{hint} memory used: {memory} MB')


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')

当引用计数为0时,对象生命就结束了。

引用计数机制的优点:

  • 简单
  • 实时性:一旦没有引用,内存就直接释放了。不用像其他机制等到特定时机。实时性还带来一个好处:处理回收内存的时间分摊到了平时。

引用计数机制的缺点:

  • 维护引用计数消耗资源
  • 循环引用 list1与list2相互引用,如果不存在其他对象对它们的引用,list1与list2的引用计数也仍然为1,所占用的内存永远无法被回收,这将是致命的。对于如今的强大硬件,缺点1尚可接受,但是循环引用导致内存泄露,注定python还将引入新的回收机制。(标记清除和分代收集)

Python 的循环引用

但是引用计数不能解决循环引用的问题,就如下的代码不停跑就能把电脑内存跑满:

>>> a = []
>>> b = []
>>> while True:
...     a.append(b)
...     b.append(a)
...

[1]    31962 killed     python

如果有两个对象,它们互相引用,并且不再被别的对象引用,那么它们应该被垃圾回收么?

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(f'{hint} memory used: {memory} MB')


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: 8.97265625 MB
after a, b created memory used: 776.91796875 MB
finished memory used: 776.9765625 MB
"""

这里 a 和 b 互相引用,并且,作为局部变量,在函数 func 调用结束后,a 和 b 这两个指针从程序意义上已经不存在了。但是,很明显,依然有内存占用。因为互相引用,导致它们的引用数都不为0。

如果这段代码运行在生产环境中,哪怕 a 和 b 一开始占用的空间不是很大,但经过长时间的运行后,所占内存会越来越大,最终会撑爆服务器。

互相引用还是很容易发现的,更隐蔽的情况是出现一个引用环,在工程代码比较复杂的情况下,引用环很难被发现。

解决这类问题,我们可以通过手动垃圾回收,即显式的调用 gc.collect(), 来启动垃圾回收。

import gc
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(f'{hint} memory used: {memory} MB')


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: 8.453125 MB
after a, b created memory used: 783.90625 MB
finished memory used: 9.37109375 MB
"""

虽然 a,b 的引用计数不为0,但是我们也可以通过 gc.collect() 进行垃圾回收

python 使用 标记清除(mark-sweep)算法和分代收集(generational), 来启用针对循环引用的自动垃圾回收。

标记清除

        先用图论来理解不可达的概念。对于一个有向图,如果从一个节点出发进行遍历,并标记其经过的所有节点;那么,在遍历结束后,所有没有被标记的节点,我们称之为不可达节点。显然,这些节点的存在没有任何意义,我们就需要对它们进行垃圾回收。

        当然,每次都遍历全图,对于python而言是一种巨大的性能浪费。所以在python垃圾回收实现中,mark-sweep 使用双向链表维护了一个数据结构,并且只考虑容器类的对象(只有容器类对象才有可能产生循环引用)。

标记清除算法作为 Python 的辅助垃圾收集技术,主要处理的是一些容器对象,比如 list、dict、tuple,instance 等,因为对于字符串、数值对象是不可能造成循环引用问题。标记清除分代回收就是为了解决循环引用而生的。

它分为两个阶段:

  • 第一阶段:是标记阶段,GC 会把所有的活动对象打上标记,
  • 第二阶段:是把那些没有标记的对象非活动对象进行回收。

对象之间通过引用(指针)连在一起,构成一个有向图,对象构成这个有向图的节点,而引用关系构成这个有向图的边。从根对象(root object)出发,沿着有向边遍历对象,可达的(reachable)对象标记为活动对象,不可达的对象就是要被清除的非活动对象。根对象就是全局变量、调用栈、寄存器。

在上图中,可以从程序变量直接访问块 1,并且可以间接访问块 2 和 3。程序无法访问块 4 和 5。

  • 第一步将标记块 1,并记住块 2 和 3 以供稍后处理。
  • 第二步将标记块 2,
  • 第三步将标记块 3,但不记得块 2,因为它已被标记。扫描阶段将忽略块 1,2 和 3,因为它们已
  • 被标记,但会回收块 4 和 5。

标记清除算法作为 Python 的辅助垃圾收集技术,主要处理的是一些容器对象,比如 list、dict、tuple 等,因为对于字符串、数值对象是不可能造成循环引用问题。

Python 使用一个双向链表将这些容器对象组织起来。不过,这种简单粗暴的标记清除算法也有明显的缺点:清除非活动的对象前它必须顺序扫描整个堆内存,哪怕只剩下小部分活动对象也要扫描所有对象。

分代回收(自动)

分代收集算法是另一个优化手段。

python 将所有对象分为三代。刚刚创立的对象是第0代;经过一次垃圾回收后,依然存在的对象,便会依次从上一代挪到下一代。而每一代启动自动垃圾回收的阈值,则是可以单独指定的。当垃圾回收器中新增对象(新建的对象)减去删除对象(手动调用del删除的对象、函数运行结束释放的对象等)达到相应的阈值时,就会对这一代对象启动垃圾回收。

事实上,分代收集基于的思想是,新生的对象更有可能被垃圾回收,而存活更久的对象也有更高的概率继续存活。因此,通过这种做法,可以节约不少计算量,从而提高python的性能。

引用计数是其中最简单的实现,引用计数并非充要条件。还有其他的可能性,比如循环引用就是其中之一。

分代回收是建立在标记清除技术基础之上的,是一种以空间换时间的操作方式。

  • Python 将所有的对象分为 0,1,2  三代
  • 所有的新建的对象都是 0 代对象
  • 当某一代对象经历过垃圾回收,依然存活,那么它就被归入下一代对象。

同时,分代回收是建立在标记清除技术基础之上。分代回收同样作为 Python 的辅助垃圾收集技术处理那些容器对象。

Python 运行时,会记录其中分配对象(object allocation)和取消分配对象(object deallocation)的次数。

当两者的差值高于某个阈值时,垃圾回收才会启动

查看阈值 gc.get_threshold()

import gc
print(gc.get_threshold()) # Output: (700, 10, 10)

get_threshold()返回的(700, 10, 10)返回的两个 10。也就是说,每 10 次 0 代垃圾回收,会配合 1 次 1 代的垃圾回收;而每 10 次 1 代的垃圾回收,才会有 1 次的 2 代垃圾回收。理论上,存活时间久的对象,使用的越多,越不容易被回收,这也是分代回收设计的思想。

手动回收

  • gc.collect() 手动回收
  • objgraph 模块中的 count() 记录当前类产生的实例对象的个数

import gc
result = gc.collect()

print(result)

import objgraph

import sys
import gc
import objgraph


class Person(object):
    pass


class Cat(object):
    pass


p = Person()
c = Cat()
p.name = 'admin'

c.master = p
print(sys.getrefcount(p))
print(sys.getrefcount(c))

del p
del c

gc.collect()
print(objgraph.count('Person'))
print(objgraph.count('Cat'))
 

当定位到哪个对象存在内存泄漏,就可以用 show_backrefs 查看这个对象的引用链。

内存池(memory pool)机制

频繁 申请、消耗 会导致大量的内存碎片,致使效率变低。

内存池的概念就是在内存中申请一定数量的,大小相等的内存块留作备用。

内存池池由单个大小类的块组成。每个池维护一个到相同大小类的其他池的双向链接列表。这样,即使在不同的池中,该算法也可以轻松找到给定块大小的可用空间。

当有新的内存需求时,就会先从内存池中分配内存留给这个需求。内存不够再申请新的内存。

内存池本身必须处于以下三种状态之一:

  • 已使用
  • 已满
  • 或为空。

优点:减少内存碎片,提高效率。

3、python 内存管理

从上文大家可以了解到当对象的引用计数器为0时,就会被销毁并释放内存。而实际上他不是这么的简单粗暴,因为反复的创建和销毁会使程序的执行效率变低。Python中引入了“缓存机制”机制。
例如:引用计数器为0时,不会真正销毁对象,而是将他放到一个名为free_list的链表中,之后会再创建对象时不会在重新开辟内存,而是在free_list中将之前的对象来并重置内部的值来使用

1、float类型

维护的free_list链表最多可缓存100个float对象 

  • num = 3.14 #开辟内存来存储float对象,并将对象添加到refchain链表。
  • del num  #引用计数器-1,如果为0则在rechain链表中移除,不销毁对象,而是将对象添加到float的free_list中.
  • num2 = 2.5 # 此时不会开辟新的内存空间,而是去float的free_list中取出一个对象(内存地址空间),赋值并使用

优先去free_list中获取对象,并重置为值为2.5,如果free_list为空才重新开辟内存。

注意:引用计数器为0时,会先判断free_list中缓存个数是否满了,未满则将对象缓存,已满则直接将对象销毁。以下的list、tupel、dict的free_list皆是如此

2、list类型

维护的free_list数组最多可缓存80个list对象。

>>> li = [1,2,3]
>>> id(li)
2220235498688
>>> del li
>>> li2=['adf','csadf' ]
>>> id(li2)
2220235498688
>>>

3、tuple类型

维护一个free_list数组且数组容量20,数组中元素可以是链表且每个链表最多可以容纳2000个元组对象。元组的free_list数组在存储数据时,是按照元组可以容纳的个数为索引找到free_list数组中对应的链表,并添加到链表中。如下:

>>> tup1=(1,2) 
>>> id(tup1)
2220238433664
>>> del tup1  # 因为元组的数量为2,所以在删除该变量时,会把这个能存储2个元组的对象添加到free_list[2]的列表中
>>> tup2 =(3,4) # 不会重新的开辟新的内存地址来存储当前的元组,而是去维护元组的free_list[2]中取出一个对象(内存地址)赋值并使用
>>> id(tup2)
2220238433664
>>>

4、dict

维护的free_list数组最多可缓存80个dict对象

>>> dict1 = {'a':1}
>>> id(dict1)
2220235380672
>>> del dict1
>>> dict2 = {'b':2}
>>> id(dict2)
2220235380672
>>>

5、int (小内存池)

对于int类型,这里比较特殊,python底层并不是为此维护一个free_list。而是在编译器启动的时候,就会在内存中创建存储着[-5,256]的对象(内存地址空间),而这些对象的引用计数器永远不会为零,所以永远不会被销毁或者被添加到链表中。当我需要定义一个[-5,256]的对象时,如num=5,在内存中不会开辟新的内存,而是直接将内存中存储着5的那个对象返回

>>> num1 =-5
>>> id(-5)
2220233681008
>>> del num1
>>> num2 = -5
>>> id(num2)
2220233681008
>>>
对于除开[-5,256]的整型数字,则是声明即开辟空间存储 

>>> num2= 257
>>> id(num2)
2220238243440
>>> num3 =257
>>> id(num3)
2220238243472
>>>

6、str类型 

情况1:如同int类型一样,内部将所有的ascii码缓存起来,以后使用时就不再反复创建

>>> str1='A'
>>> id(str1)
2220235792688
>>> str2 ='A'
>>> id(str2)
2220235792688
>>>
 

情况2:Python内部还对字符串做了驻留机制,针对那些只含有字母、数字、下划线的字符串,如果内存中已存在则不会重新再创建而是使用原来的地址里(不会像free_list那样一直在内存存活,只有内存中有才能被重复利用)。

>>> str1 ='python'
>>> id(str1)
2220238443248
>>> str2 ='python'
>>> id(str2)
2220238443248
>>> str3='pytho'
>>> id(str3)
2220238443312
>>>

4、调试内存泄漏

安装 工具

安装 Graphviz

Graphviz 是一个开源工具,可以运行在Windows系统和Linux系统上。适用于大多数平台的二进制文件可以在 Graphviz 主页 上找到。AIX 二进制文件可以在perzl.org 上找到。

Graphviz 画图的一些总结:https://www.cnblogs.com/shuqin/p/11897207.html

安装 python 依赖包

pip install graphviz

pip install xdo

调试内存泄漏

虽然有了自动回收机制,还是会出现内存泄露的情况。

可以通过 objgraph(一个可视化引用关系的包)。在这个包中,主要关注两个函数,

  • 第一个是 show_refs():它可以生成清晰的引用关系图。需要手动下载安装graphviz,然后将其 bin 目录放入到环境变量中,才能出来图片。在 jupyter notebook 中可以直接显示图片。但是在pycharm中会显示图片地址,需要自己去手动打开。
  • 另一个非常有用的函数是 show_backrefs() 

通过下面这段代码和生成的引用调用图,你能非常直观的发现,有两个list互相引用,说明这里极有可能引起内存泄漏。这样一来,再去代码层排查就容易多了。

import objgraph
import os

os.environ["PATH"] += os.pathsep + r'C:\Program Files\Graphviz\bin'
a = [1, 2, 3]
b = [4, 5, 6]
a.append(b)
b.append(a)
objgraph.show_refs([a])

使用 show_backrefs() 函数

import objgraph
import os
os.environ["PATH"] += os.pathsep + r'C:\Program Files\Graphviz\bin'
a = [1, 2, 3]
b = [4, 5, 6]
a.append(b)
b.append(a)
objgraph.show_backrefs([a])

这个代码显示的图片比之前的复杂的多。show_backrefs() 有很多有用的参数,比如层数限制(max_depth)、宽度限制(too_many)、输出格式控制(filename output)、
节点过滤(filter, extra_ignore)等。

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值