python内存机制与垃圾回收、调优手段

时间:2021-08-14 05:21:49

一、python的内存机制

python中的内存机制 如下所示:

    _____   ______   ______       ________
   [ int ] [ dict ] [ list ] ... [ string ]       Python core         |
+3 | <----- Object-specific memory -----> | <-- Non-object memory --> |
    _______________________________       |                           |
   [   Python's object allocator   ]      |                           |
+2 | ####### Object memory ####### | <------ Internal buffers ------> |
    ______________________________________________________________    |
   [          Python's raw memory allocator (PyMem_ API)          ]   |
+1 | <----- Python memory (under PyMem manager's control) ------> |   |
    __________________________________________________________________
   [    Underlying general-purpose allocator (ex: C library malloc)   ]
 0 | <------ Virtual memory allocated for the python process -------> |

   =========================================================================
    _______________________________________________________________________
   [                OS-specific Virtual Memory Manager (VMM)               ]
-1 | <--- Kernel dynamic storage allocation & management (page-based) ---> |
    __________________________________   __________________________________
   [                                  ] [                                  ]
-2 | <-- Physical memory: ROM/RAM --> | | <-- Secondary storage (swap) --> |

解释:

  • -1,-2层主要由操作系统进行操作。

  • 第0层是由C语言中的malloc,free等内存分配和释放函数进行内存操作

  • 第1层则是在第0层的基础之上对其提供的接口进行了统一的封装。

    这是因为:虽然不同的操作系统都提供标准定义的内存管理接口,但是对于某些特殊的情况,不同的操作系统都有不同的行为,比如说调用malloc(0),有的操作系统会返回NULL,表示内存申请失败;然而有的操作系统会返回一个貌似正常的指针,但是这个指针所指的内存并不是有效的。为了广泛的移植性,Python必须保证相同的语义一定代表相同的运行行为。

  • 第2层是内存池,由Python的接口函数PyMem_Malloc函数实现。

  Python为了避免频繁的申请和删除内存所造成系统切换于用户态和核心态的开销,从而引入了内存池机制,专门用来管理小内存的申请和释放。当对象小于256K时有该层直接在内存池中分配内存,大于则退化由低层来进行分配,如由malloc函数进行分配。整个小块内存的内存池可以视为一个层次结构,其一共分为4个层次,从下之上分别是block、pool、arena和内存池。需要说明的是:block、pool和area都是代码中可以找到的实体,而最顶层的内存池只是一个概念上的东西,表示Python对于整个小块内存分配和释放行为的内存管理机制。
  (1) block:最小的内存单元,大小为8的整数倍。有很多种类的block,不同种类的block都有不同的内存大小,申请内存的时候只需要找到适合自身大小的block即可,当然申请的内存也是存在一个上限,如果超过这个上限,则退化到使用最底层的malloc进行申请。

  (2) pool:一个pool管理着一堆有固定大小的内存块,其大小通常为一个系统内存页的大小。

  (3) arena:多个pool组合成一个arena。

  (4) 内存池:一个整体的概念。

python内存池设计参考文章:https://blog.csdn.net/zhzhl202/article/details/7547445

  • 第3层是最上层,也就是我们对Python对象的直接操作。直接面向用户,它提供给我们int,list,string,dict等方法。

二、python的垃圾回收

  Python中的垃圾回收是以引用计数为主,分代收集为辅。引用计数的缺陷是循环引用的问题,为了解决循环引用的问题,又有了标记 - 清除技术。

  在Python中,如果一个对象的引用数为0,Python虚拟机就会回收这个对象的内存。

1. 引用计数

1.1 原理:

  当一个对象的引用被创建或者复制时,对象的引用计数加1;当一个对象的引用被销毁时,对象的引用计数减1,当对象的引用计数减少为0时,就意味着对象已经再没有被使用了,可以将其内存释放掉。

1.2 优缺点:

  • 优点:引用计数有一个很大的优点,即实时性,任何内存,一旦没有指向它的引用,就会被立即回收,而其他的垃圾收集技术必须在某种特殊条件下才能进行无效内存的回收。

  • 缺点:但是它也有弱点,引用计数机制所带来的维护引用计数的额外操作与Python运行中所进行的内存分配和释放,引用赋值的次数是成正比的,这显然比其它那些垃圾收集技术所带来的额外操作只是与待回收的内存数量有关的效率要低。同时,引用技术还存在另外一个很大的问题 — 循环引用,因为对象之间相互引用,每个对象的引用都不会为0,所以这些对象所占用的内存始终都不会被释放掉。(这也标记-清除计数存在的意义。)

1.3 一个例子:

# encoding=utf-8


class ClassA():
    def __init__(self):
        print('object born    id:%s' % str(hex(id(self))))     # hex()将10进制整数转换成16进制,以字符串形式表示。

    def __del__(self):
        print('object del     id:%s' % str(hex(id(self))))


def func():
    c1 = ClassA()
    del c1


func()

程序输出:

object born    id:0x194921515f8
object del     id:0x194921515f8

  c1=ClassA()会创建一个对象,放在0x194921515f8内存中,c1变量指向这个内存,这时候这个内存的引用计数是1。del c1后,c1变量不再指向0x194921515f8内存,所以这块内存的引用计数减一,等于0,所以就销毁了这个对象,然后释放内存。

1.4 两种情况:

** 1.4.1 导致引用计数+1的情况:**

  • 对象被创建,例如a=3,b=ClassA()
  • 对象被引用,例如b=a
  • 对象被作为参数,传入到一个函数中,例如func(a)。实际上在函数内部可以看到引用计数是+2
  • 对象作为一个元素,存储在容器中,例如list1=[a,a]

** 1.4.2 导致引用计数-1的情况:**

  • 对象的别名被显式销毁,例如del a
  • 对象的别名被赋予新的对象,例如a=24
  • 一个对象离开它的作用域,例如f函数执行完毕时,func函数中的局部变量(全局变量不会)
  • 对象所在的容器被销毁,或从容器中删除对象

1.5 一个特殊的实例:

def f1(n):
    print("in function:", sys.getrefcount(n) - 1)


x = 22
print("init x:", sys.getrefcount(x) - 1)
a = 22
print("after a:", sys.getrefcount(x) - 1)
b = a
print("after b:", sys.getrefcount(x) - 1)
f1(x)
print("after function:", sys.getrefcount(22) - 1)

运行结果:

init x: 12
after a: 13
after b: 14
in function: 16
after function: 14

  可以看到,调用函数后再函数内部引用计数是+2,原因是: 多的那一个引用是函数栈保存了入参对形参的引用,这导致计数+2。

这个结论 参考文章:https://www.cnblogs.com/hellcat/p/10450785.html

2. 标记清除

  标记-清除只关注那些可能会产生循环引用的对象,显然,像是int、stringt这些不可变对象是不可能产生循环引用的,因为它们内部不可能持有其它对象的引用。在Python中, 所有能够引用其他对象的对象都被称为容器(container)。Python中的循环引用总是发生在container对象之间,也就是能够在内部持有其它对象的对象,比如list、dict、class等等。

前面提到过,循环引用使得内存无法被回收,即造成了内存泄漏。下面看一个实例:

class ClassA():
    def __init__(self, x=None):
        self.t = x
        print('object born    id:%s' % str(hex(id(self))))


def f2():
    c1=ClassA()
    c2=ClassA()
    c1.t=c2
    c2.t=c1
    del c1
    del c2

执行f2(),会产生一个循环引用,即是del c1、c2,内存还是没有被释放,如果进程中存在大量的这种情况,那么进程占用的内存会不断增大。

object born    id:0x1a29f609390
object born    id:0x1a29f609400

  创建了c1,c2后,0x1a29f609390(c1对应的内存,记为内存1),0x1a29f609400(c2对应的内存,记为内存2)这两块内存的引用计数都是1,执行c1.t=c2和c2.t=c1后,这两块内存的引用计数变成2.
在del c1后,内存1的对象的引用计数变为1,由于不是为0,所以内存1的对象不会被销毁,所以内存2的对象的引用数依然是2,在del c2后,同理,内存1的对象,内存2的对象的引用数都是1。删除了c1,c2之后,这两个对象不可能再从程序中调用,就没有什么用处了。但是由于引用环的存在,这两个对象的引用计数都没有降到0,导致垃圾回收器都不会回收它们,所以就会导致内存泄露。

2.1 原理:

  为了记录下所有的容器对象, Python将每一个 容器都链到了一个双向链表中, 之所以使用双向链表是为了方便快速的在容器集合中插入和删除对象. 有了这个 维护了所有容器对象的双向链表以后, Python在垃圾回收时使用如下步骤来寻找需要释放的对象:

  • (1) 对于每一个容器对象, 设置一个gc_refs值, 并将其初始化为该对象的引用计数值
  • (2) 对于每一个容器对象, 找到所有其引用的对象, 将被引用对象的gc_refs值减1
  • (3) 执行完步骤2以后所有gc_refs值还大于0的对象都被非容器对象引用着, 至少存在一个非循环引用. 因此 不能释放这些对象, 将他们放入另一个集合
  • (4) 在步骤3中不能被释放的对象, 如果他们引用着某个对象, 被引用的对象也是不能被释放的, 因此将这些 对象也放入另一个集合中
  • (5) 此时还剩下的对象都是无法到达的对象. 现在可以释放这些对象了

2.2 优缺点:

  • 优点:当然是解决了循环引用的问题。

  • 缺点:标记和清除的过程效率不高。

3. 分代回收

  Python同时采用了分代(generation)回收的策略。这一策略的基本假设是,存活时间越久的对象,越不可能在后面的程序中变成垃圾。我们的程序往往会产生大量的对象,许多对象很快产生和消失,但也有一些对象长期被使用。出于信任和效率,对于这样一些“长寿”对象,我们相信它们的用处,所以减少在垃圾回收中扫描它们的频率。

3.1 原理:

  将系统中的所有内存块根据其存活时间划分为不同的集合,每一个集合就成为一个“代”,Python默认定义了三代对象集合,垃圾收集的频率随着“代”的存活时间的增大而减小。也就是说,活得越长的对象,就越不可能是垃圾,就应该减少对它的垃圾收集频率。

  Python默认定义的对象分为0,1,2三代。所有的新建对象都是0代对象。当某一代对象经历过垃圾回收,依然存活,那么它就被归入下一代对象。垃圾回收启动时,一定会扫描所有的0代对象。如果0代经过一定次数垃圾回收,那么就启动对0代和1代的扫描清理。当1代也经历了一定次数的垃圾回收后,那么会启动对0,1,2,即对所有对象进行扫描。

4. 三种情况触发垃圾回收:

  • 1、调用gc.collect()
  • 2、GC达到阀值时
  • 3、程序退出时

5. 小整数对象池与intern机制

  由于整数使用广泛,为了避免为整数频繁销毁、申请内存空间,引入了小整数对象池。[-5,257)是提前定义好的,不会销毁,单个字母也是。

那对于其他整数,或者其他字符串的不可变类型,如果存在重复的多个,例如:

m1 = "mark"
m2 = "mark"
m3 = "mark"
m4 = "mark"
m5 = "mark"
m6 = "mark"

print(m1 is m5)

它的运行结果是:True

如果每次声明都开辟出一段空间,很显然不合理,这个时候python就会使用intern机制,靠引用计数来维护。

总结:

  • 1、小整数[-5,257):共用对象,常驻内存
  • 2、单个字符:共用对象,常驻内存
  • 3、单个单词等不可变类型,默认开启intern机制,共用对象,引用计数为0时销毁。

三、调优手段

引用文章:https://blog.csdn.net/zxmzhaoxuan/article/details/82492515

1. 手动垃圾回收

  对Python的垃圾回收进行调优的一个最简单的手段便是关闭自动回收, 根据情况手动触发. 例如在用Python开发游戏时, 可以在一局游戏的开始关闭GC, 然后在该局游戏结束后手动调用一次GC清理内存. 这样能完全避免在游戏过程中因此 GC造成卡顿. 但是缺点是在游戏过程中可能因为内存溢出导致游戏崩溃.

2. 调高垃圾回收阈值

  相比完全手动的垃圾回收, 一个更温和的方法是调高垃圾回收的阈值. 例如一个游戏可能在某个时刻产生大量的子弹对象(假如是2000个). 而此时Python的垃圾回收的threshold0为1000. 则一次垃圾回收会被触发, 但这2000个子弹对象并不需要被回收. 如果此时 Python的垃圾回收的threshold0为10000, 则不会触发垃圾回收. 若干秒后, 这些子弹命中目标被删除, 内存被引用计数机制 自动释放, 一次(可能很耗时的)垃圾回收被完全的避免了.

  调高阈值的方法能在一定程度上避免内存溢出的问题(但不能完全避免), 同时可能减少可观的垃圾回收开销. 根据具体项目 的不同, 甚至是程序输入的不同, 合适的阈值也不同. 因此需要反复测试找到一个合适的阈值, 这也算调高阈值这种手段 的一个缺点.

3. 避免循环引用

  一个可能更好的方法是使用良好的编程习惯尽可能的避免循环引用. 两种常见的手段包括: 手动解循环引用和使用弱引用.

3.1 手动解循环引用

  手动解循环引用指在编写代码时写好解开循环引用的代码, 在一个对象使用结束不再需要时调用. 例如:

class A(object):
    def __init__(self):
        self.child = None
 
    def destroy(self):
        self.child = None
 
class B(object):
    def __init__(self):
        self.parent = None
 
    def destroy(self):
        self.parent = None
 
def test3():
    a = A()
    b = B()
    a.child = b
    b.parent = a
    a.destroy()
    b.destroy()
 

3.2 使用弱引用

  弱引用指当引用一个对象时, 不增加该对象的引用计数, 当需要使用到该对象的时候需要首先检查该对象是否还存在. 弱引用的实现方式有多种, Python自带一个弱引用库weakref, 其详细文档参加这里. 使用weakref改写我们的代码:

def test4():
    a = A()
    b = B()
    a.child = weakref.ref(b)
    b.parent = weakref.ref(a)

  除了使用Python自带的weakref库以外, 通常我们也可以根据自己项目的业务逻辑实现弱引用. 例如在游戏开发中, 通常很多对象都是有 其唯一的ID的. 在引用一个对象时我们可以保存其ID而不是直接引用该对象. 在需要使用该对象的时候首先根据ID去检查该对象是否存在.