Python – 垃圾回收;内存优化

垃圾回收机制我想对于软件开发的同学并不陌生,程序运行过程中会申请大量的内存空间,而对于一些无用的内存空间如果不及时清理的话会导致内存使用殆尽(内存溢出),导致程序崩溃,因此管理内存是一件重要且繁杂的事情,而python解释器自带的垃圾回收机制把程序员从繁杂的内存管理中解放出来。

就像电脑运行一段时间会变慢,相信大家对于这种情况的处理都有各自的方法,如: 关闭不用的程序、结束掉进程、关闭一些服务、重启电脑等等

一、Python垃圾回收机制

垃圾回收机制(简称GC)是Python解释器自带一种机,专门用来回收不可用的变量值所占用的内存空间。

Python垃圾回收机制策略

Python采用的是引用计数机制为主,标记-清除和分代收集两种机制为辅的策略

Garbage collection(GC)

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

Python的GC模块主要运用了“引用计数”(reference counting)来跟踪和回收垃圾。在引用计数的基础上,还可以通过“标记-清除”(mark and sweep)解决容器对象可能产生的循环引用的问题,并且通过“分代回收”(generation collection)以空间换取时间的方式来进一步提高垃圾回收的效率。

(一)引用计数

Python里每一个东西都是对象,它们的核心就是一个结构体:PyObject

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

PyObject是每个对象必有的内容,其中ob_refcnt就是做为引用计数。当一个对象有新的引用时,它的obrefcnt就会增加,当引用它的对象被删除,它的ob_refcnt就会减少

#define Py_INCREF(op)   ((op)->ob_refcnt++) //增加计数
#define Py_DECREF(op)   //减少计数
if (--(op)->ob_refcnt != 0)
    ;
else
    __Py_Dealloc((PyObject *)(op))

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

导致引用计数+1的情况

1、对象被创建

2、对象被引用

3、对象被作为参数,传入到一个函数中

4、对象作为一个元素,存储在容器中

导致引用计数-1的情况

1、对象的别名被显式销毁

2、对象的别名被赋予新的对象

3、一个对象离开它的作用域,例如f函数执行完毕时,func函数中的局部变量(全局变量不会)

4、对象所在的容器被销毁,或从容器中删除对象

import sys

'''
在 Python 中,可以使用 sys.getrefcount() 函数来获取某个对象的引用计数。
注:sys.getrefcount() 返回的计数值会比实际的引用计数多 1,因为传递给 getrefcount() 的参数本身也会增加一次引用
'''
a = {}
print("a当前引用计数值:{}".format(sys.getrefcount(a))) #2
b=a
c=b
e=b
print("a当前引用计数值:{}".format(sys.getrefcount(a))) #5

引用计数优点

1、简单

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

引用计数缺点

1、维护引用计数会消耗资源

2、循环引用

listA = []
listB = []
listA.append(listB)
listB.append(listA)

list1与list2相互引用,如果不存在其他对象对它们的引用,list1与list2的引用计数也仍然为1,所占用的内存永远无法被回收,这将是致命的。 对于如今的强大硬件,缺点1尚可接受,但是循环引用导致内存泄露 

在引用计数机制中,GC的主要职责是

1、为新生成的对象分配内存

2、识别垃圾对象

3、从垃圾对象那回收内存

(二)标记-清除

标记-清除机制,顾名思义,首先标记对象(垃圾检测),然后清除垃圾(垃圾回收)

首先初始所有对象标记为白色,并确定根节点对象(这些对象是不会被删除),标记它们为黑色(表示对象有效),将有效对象引用的对象标记为灰色(表示对象可达,但它们所引用的对象还没检查),检查完灰色对象引用的对象后,将灰色标记为黑色。重复直到不存在灰色节点为止。最后白色结点都是需要清除的对象

关于变量的存储,内存中有两块区域:堆区与栈区,在定义变量时,变量名与值内存地址的关联关系存放于栈区,变量值存放于堆区,内存管理回收的则是堆区的内容,详解如下图

定义了两个变量x = 10、y = 20

当我们执行x=y时,内存中的栈区与堆区变化如下

标记/清除算法的做法是当应用程序可用的内存空间被耗尽的时,就会停止整个程序,然后进行两项工作,第一项则是标记,第二项则是清除

1、标记

通俗地讲就是:标记的过程就行相当于从栈区出发一条线,“连接”到堆区,再由堆区间接“连接”到其他地址,凡是被这条自栈区起始的线连接到内存空间都属于可以访达的,会被标记为存活

具体地:标记的过程其实就是,遍历所有的GCRoots对象(栈区中的所有内容或者线程都可以作为GCRoots对象),然后将所有GCRoots的对象可以直接或间接访问到的对象标记为存活的对象,其余的均为非存活对象,应该被清除。

2、清除

清除的过程将遍历堆中所有的对象,将没有标记存活的对象全部清除掉。

直接引用指的是从栈区出发直接引用到的内存地址,间接引用指的是从栈区出发引用到堆区后再进一步引用到的内存地址,以我们之前的两个列表l1与l2为例画出如下图像

当我们同时删除l1与l2时,会清理到栈区中l1与l2的内容 

(三)分代收集

为了优化垃圾回收的性能,Python采用了分代收集的策略。它将对象分为不同的代(通常是三代),并根据对象的存活时间来调整垃圾回收的频率和策略。

  • 第0代‌:最近创建的对象,生命周期最短,垃圾回收最频繁。
  • 第1代‌:存活时间较长的对象,垃圾回收频率较低。
  • 第2代‌:存活时间最长的对象,垃圾回收频率最低。
  • 这种分代收集的策略可以显著减少垃圾回收的开销,提高程序的性能

    分配内存
    -> 发现超过阈值了
    -> 触发垃圾回收
    -> 将所有可收集对象链表放到一起
    -> 遍历, 计算有效引用计数
    -> 分成 有效引用计数=0 和 有效引用计数 > 0 两个集合
    -> 大于0的, 放入到更老一代
    -> =0的, 执行回收
    -> 回收遍历容器内的各个元素, 减掉对应元素引用计数(破掉循环引用)
    -> 执行-1的逻辑, 若发现对象引用计数=0, 触发内存回收
    -> python底层内存管理机制回收内存

    Python中, 引入了分代收集, 总共三个”代”. Python 中, 一个代就是一个链表, 所有属于同一”代”的内存块都链接在同一个链表中,用来表示“代”的结构体是gc_generation, 包括了当前代链表表头、对象数量上限、当前对象数量

    // gcmodule.c
    struct gc_generation {
        PyGC_Head head;
        int threshold; /* collection threshold */
        int count; /* count of allocations or collections of younger generations */
    };

    Python默认定义了三代对象集合,索引数越大,对象存活时间越长

    #define NUM_GENERATIONS 3 #define GEN_HEAD(n) (&generations[n].head) /* linked lists of container objects */ static struct gc_generation generations[NUM_GENERATIONS] = { /* PyGC_Head, threshold, count */ { 
       { 
       { 
       GEN_HEAD(0), GEN_HEAD(0), 0}}, 700, 0}, { 
       { 
       { 
       GEN_HEAD(1), GEN_HEAD(1), 0}}, 10, 0}, { 
       { 
       { 
       GEN_HEAD(2), GEN_HEAD(2), 0}}, 10, 0}, };

    新生成的对象会被加入第0代,前面_PyObject_GC_Malloc中省略的部分就是Python GC触发的时机。每新生成一个对象都会检查第0代有没有满,如果满了就开始着手进行垃圾回收

     g->gc.gc_refs = GC_UNTRACKED;
     generations[0].count++; /* number of allocated GC objects */
     if (generations[0].count > generations[0].threshold &&
         enabled &&
         generations[0].threshold &&
         !collecting &&
         !PyErr_Occurred()) {
              collecting = 1;
              collect_generations();
              collecting = 0;
     }

    GC

    1、GC常用方法

    sys.getrefcount(对象)

    查看对象的引用计数值,但比正常计数值大于1,因为调用函数时会将实参的地址复制给形参从而导致形参也引用了对象,所以这会让对象的引用计数+1

    gc.enable():启用自动垃圾回收

    ③gc.disable():关闭GC垃圾自动回收机制

    gc.collect(第几代)

    如果不加参数会对0、1、2即所有代中的内存块对象进行垃圾检测回收

    0:表示只对0代进行垃圾检测回收

    1:表示只对1代进行垃圾检测回收

    2:表示只对2代进行垃圾检测回收

    gc.get_count( )

    获取程序当前回收计数值如:(166, 2, 3)

    其中166表示目前0代中剩余的内存对象数量

    2表示0代垃圾回收检测了几次。即在1代垃圾检测之后其值会重置为0,之后每当0代执行一次垃圾检测后其值就会累加+1,当累加的数量达到gc.get_threshold()返回的第二个元素数之后,解释器就会对1代中的所有内存对象进行垃圾检测回收。然后再将其值重置为0,然后再重新记录0代被垃圾回收检测的次数。

    3表示1代垃圾回收检测了几次。当1代垃圾回收检测了gc.get_threshold()返回的第3个参数的次数之后,解释器就会对2代进行一次垃圾检测回收,然后再将其值重置为0。之后再重新计录1代垃圾回收检测的次数。

    gc.set_threshold(垃圾回收频率)

    设置各代垃圾回收检测的阈值。如:(50,2,3)。

    50: 表示0代内存对象数达到50之后对0代进行垃圾回收检测。这会将0代内所有内存对象进行清空(通过垃圾删除、使用的内存移动到下一代)。
    2:表示0代垃圾回收检测2之后会对1代进行垃圾回收检测。
    3:表示1代垃圾回收检测3次之后会对2代进行垃圾回收检测。在2代中只会对垃圾进行清除而不会对使用的内存对象进行下一代移动。
    gc.get_threshold()———将当前回收阈值以形为 (threshold0, threshold1, threshold2) 的元组返回。

    设置代的垃圾回收阈值,默认返回值为(700,10,10)。

    700: 0代内存对象数量达到此值后,垃圾回收器就会对0代进行垃圾回收检测。即将0代内的垃圾(没有直接引用的内存对象)进行清除,将还在使用的内存对象移动到下一代中。因此对0代进行垃圾回收之后,0代的内存对象数量会变为0。
    第二参数10:表示0代垃圾检测次数。当0代垃圾检测次数达到此值后就会对1代进行垃圾回收检测。
    第三参数10:表示1代垃圾检测次数。当1代垃圾检查次数达到此值后,垃圾回收器就会对2代进行垃圾回收检测。即将2代中不在使用的内存进行释放清除

    2、内存泄漏

    # 内存泄露
    import gc
    class ClassA():
        def __init__(self):
            print('object born,id:%s'%str(id(self)))
    def f2():
        while True:
            c1 = ClassA()
            c2 = ClassA()
            c1.t = c2
            c2.t = c1
            del c1
            del c2
    #python默认是开启垃圾回收的,可以通过下面代码来将其关闭
    gc.disable()
    f2()

    执行f2(),进程占用的内存会不断增大。 创建了c1,c2后这两块内存的引用计数都是1,执行c1.t=c2和c2.t=c1后,这两块内存的引用计数变成2. 在del c1后,引用计数变为1,由于不是为0,所以c1对象不会被销毁;同理,c2对象的引用数也是1。 python默认是开启垃圾回收功能的,但是由于以上程序已经将其关闭,因此导致垃圾回收器都不会回收它们,所以就会导致内存泄露 

    3、手动调用gc回收垃圾

    #手动调用GC回收垃圾
    class ClassA():
        def __init__(self):
            print('id = %s'%str(id(self)))
    
    def f2():
        while True:
            c1 = ClassA()
            c2 = ClassA()
            c1.t = c2
            c2.t = c1
            del c1
            del c2
            # 手动调用垃圾回收功能,这样在自动垃圾回收被关闭的情况下,也会进行回收
            gc.collect()
            
    #python默认是开启垃圾回收的,可以通过下面代码来将其关闭
    gc.disable()
    f2()

    有三种情况会触发垃圾回收:

    1、当gc模块的计数器达到阀值的时候,自动回收垃圾

    2、调用gc.collect(),手动回收垃圾

    3、程序退出的时候,python解释器来回收垃圾

    二、Python内存优化

    Python为了优化速度,使用了小整数对象池, 避免为整数频繁申请和销毁内存空间。 Python 对小整数的定义是 [-5, 256] 这些整数对象是提前建立好的,不会被垃圾回收

    '''
    大整数池和小整数池的区别是:
    1、从结果来看是一样的
    2、大整数池是没有提前的创建好对象,是个空池子,需要我们自己去创建,创建好之后,会把整数对象保存到池子里面,后面都不需要再创建了,直接拿来使用
    小整数池是提前将【-5,256】的数据都提前创建好
    '''
    # 大小整数池
    a = 10
    b = 10
    print(id(a)) # 140708384203976
    print(id(b)) # 140708384203976
    c = 10
    print(id(c)) # 140708384203976
    
    biga = 10000
    bigb = 10000
    print(id(biga)) # 2372336764464
    print(id(bigb)) # 2372336764464
    bigc = 10000
    print(id(bigc)) # 2372336764464
    
    # 字符串的驻留共享机制intern机制
    sa="a!b"
    sb="a!b"
    sc="a!b"
    sd="a!b"
    print(sa is sb) # True
    print(id(sa)) # 2372336827392
    print(id(sb)) # 2372336827392
    print(id(sc)) # 2372336827392
    print(id(sd)) # 2372336827392

    1、在类定义中使用 __slots__

    作为动态类型语言的Python,在面向对象编程(OOP)方面具有很大的灵活性。例如,下面这段代码初步定义了一个Author类,包含了属性name和age。您会发现,在类的实例已创建之后,仍旧可以轻松地添加额外的属性:

    class Author:
        def __init__(self, name, age):
            self.name = name
            self.age = age
    
    p = Author('Zhang san', 30)
    p.job = 'Software Engineer'
    print(me.job)  # 输出: Software Engineer

    然而,这种灵活性的背面是在内存使用上的效率损失。正是因为Python的每个类实例都会有一个特殊的字典(__dict__)来存储实例变量,而内部基于哈希表的实现使这种字典结构在内存使用上效率偏低。

    大多数情况下,我们不需要在运行时动态地改变类实例的属性。考虑到这一点,我们没有必要为每个实例维护一个字典(__dict__)。

    为了解决这个问题,Python引入了__slots__这一神奇特性。通过这一属性,您可以事先声明类中哪些属性有效,如下所示:

    class Author:
        __slots__ = ('name', 'age')  # 声明有效属性名
    
        def __init__(self, name, age):
            self.name = name
            self.age = age
    
    p = Author('Zhang san', 30)
    p.job = 'Software Engineer'  # 尝试添加未声明属性时会报错
    print(p.job)  # 抛出AttributeError异常: 'Author'对象没有'job'属性

    正如代码示例所展现的,由于声明了__slots__,在运行时便不可再添加job属性。因此,Python不再为__slots__里未声明的属性维护一个字典,相反为其分配必要的内存即可。

    为了更直观地说明这一优化策略的效果,让我们通过一个内存使用对比实例进行观察:

    import sys
    
    class Author:
        def __init__(self, name, age):
            self.name = name
            self.age = age
    
    class AuthorWithSlots:
        __slots__ = ['name', 'age']  # 使用__slots__定义属性
    
        def __init__(self, name, age):
            self.name = name
            self.age = age
    
    # 实例化
    p = Author('Zhang san', 30)
    p_with_slots = AuthorWithSlots('Zhang san', 30)
    
    # 内存使用对比
    memory_without_slots = sys.getsizeof(p) + sys.getsizeof(me.__dict__)
    memory_with_slots = sys.getsizeof(p_with_slots)  # 使用__slots__的类不含有__dict__
    
    print(memory_without_slots, memory_with_slots)  # 输出内存占用对比

    # 使用__slots__的实例确实节约了内存
    随着Python实例使用__slots__优化后,不仅节省了内存,还提高了程序的整体性能。

    2、使用生成器以节省内存

    生成器是Python提供的一种内存高效的遍历机制。与普通列表不同的是,生成器是按需计算元素,而不是一次性产生全部,这在处理大型数据集时特别有利于内存保存。

    def number_generator():
        for i in range(100):
            yield i  # 每次调用时产生新元素
    
    numbers = number_generator()  # 创建生成器对象
    print(numbers)  # 输出生成器对象信息
    # <generator object number_generator at 0x104a57e40>
    print(next(numbers))  # 输出第一个元素
    # 0
    print(next(numbers))  # 输出第二个元素
    # 1
    让我们比较一下生成器和普通列表在内存上的占用差异:
    
    import sys
    
    # 使用列表
    numbers_list = [i for i in range(100)]
    numbers_gen = (i for i in range(100))  # 使用生成器表达式
    
    print(sys.getsizeof(numbers_gen))  # 输出生成器内存占用
    # 112
    print(sys.getsizeof(numbers_list))  # 输出列表内存占用
    # 920

    3、利用内存映射处理大型文件

    内存映射文件I/O是由操作系统级别提供的一种高效文件处理方式。简而言之,它利用当前进程的虚拟内存空间映射文件内容,而不是一次性将文件全部载入内存。这种映射方式而非完全加载的方法极大地节约了内存消耗。

    Python为我们提供了一个简单使用内存映射文件I/O的模块,从而无需处理操作系统层面的复杂实现。以下是一个使用mmap模块处理文件的示例:

    import mmap
    
    # 打开文件
    with open('test.txt', "r+b") as f:
        # 映射整个文件
        with mmap.mmap(f.fileno(), 0) as mm:
            # 使用文件方法读取内容
            print(mm.read())  # 输出映射内容
            # 使用切片语法读取部分内容
            snippet = mm[0:10]  # 获取前10个字符
            print(snippet.decode('utf-8'))

    以上就是内存映射文件I/O技术的简介以及它如何能够帮助我们处理大型文件而无须在内存上支付昂贵代价。

    4、尽可能减少全局变量的使用

    全局变量在整个程序中都可见,一旦被创建便在内存中持续存在。

    因此,当一个全局变量绑定到一个大型数据结构时,它将在程序的整个生命周期内占用内存空间,潜在地降低内存使用效率。

    为了提升内存效率,我们应避免或减少在Python代码中使用全局变量。

    5、通过逻辑操作符优化内存、这个技巧虽微妙,但能通过巧妙的应用显著降低内存消耗。

    以下是一段基于两个功能函数返回值的简单代码示例:

    # 逻辑操作符的使用
    result_a = expensive_function_a()  # 第一个函数
    result_b = expensive_function_b()  # 第二个函数
    result = result_a or result_b  # 使用逻辑操作符简化

    原先的代码执行了两个可能会消耗大量内存的函数。然而,更高效的做法如下:

    # 逻辑操作符的简化应用
    result = expensive_function1() or expensive_function2()  # 有效减少内存消耗
    由于逻辑运算符的短路特性,如果expensive_function1()返回真值,代码将不会执行expensive_function2()。这在不影响结果的情况下省去了额外的内存使用。

    6、谨慎地选择合适的数据类型

    在Python开发中,选择合适的数据类型能够在某些情况下显著节省内存的使用。

    元组相对于列表更节省内存

    元组的不可变性使得Python可以在内存分配方面进行一些优化。相反,列表由于其可变性,需要占用额外的内存以备不时之需。# 比较元组和列表的内存消耗

    import sys
    
    my_tuple = (1, 2, 3, 4, 5)
    my_list = [1, 2, 3, 4, 5]
    
    print(sys.getsizeof(my_tuple))  # 输出元组内存占用
    # 80
    print(sys.getsizeof(my_list))  # 输出列表内存占用
    # 120

    显然,在不需要修改数据的情况下,元组是比列表更为内存高效的选择。

    数组相对于列表更节省内存

    数组类型要求所有元素采用同一数据类型,这在内存效率上超越了普通列表。

    # 数组与列表的内存使用对比
    import sys
    import array
    
    my_list = [i for i in range(1000)]
    my_array = array.array('i', [i for i in range(1000)])
    
    print(sys.getsizeof(my_list))  # 输出列表内存占用
    # 8856
    print(sys.getsizeof(my_array))  # 输出数组内存占用
    # 4064

    数据科学模块优于内置数据类型

    在数据科学领域,Python框架,如NumPy和Pandas,提供了高效的数据类型选项。

    使用NumPy的数组可以在处理矩阵运算时提供优势,并成为数据科学家的首选。

    7、应用字符串互存技术以节省内存

    Python中的字符串互存技术在处理相同的字符串时,能够极大地优化内存使用。

    # 探索字符串互存现象
    >>> a = 'Y'*4096
    >>> b = 'Y'*4096
    >>> a is b
    True
    
    >>> c = 'Y'*4097
    >>> d = 'Y'*4097
    >>> c is d
    False

    is运算符用于检查两个变量是否指向相同内存对象,区别于比较值等同性的==运算符。

    而上述现象中,由于Python在4096以下的字符串上应用了字符串互存,所以a和b返回True,而c和d由于超出了这个界限,代表了不同的内存对象。

    # 显示应用字符串互存技术
    >>> import sys
    >>> c = sys.intern('Y'*4097)
    >>> d = sys.intern('Y'*4097)
    >>> c is d

    作者:MinggeQingchun

    物联沃分享整理
    物联沃-IOTWORD物联网 » Python – 垃圾回收;内存优化

    发表回复