python 垃圾回收机制

                                         python 垃圾回收机制

 

 

    python 程序在运行时,需要在内存中开辟出一块空间,用于存放运行时产生的临时变量,计算完成后,再将结果输出到永久性存储器中。但是当数据量过大,或者内存空间管理不善,就很容易出现内存溢出的情况,程序可能会被操作系统终止。

    而对于服务器这种用于永不中断的系统来说,内存管理就显得更为重要了,不然很容易引发内存泄漏。

        这里的内存泄漏是指程序本身没有设计好,导致程序未能释放已不再使用的内存,或者直接失去了对某段内存的控制,造成了内存的浪费。

一、python 的垃圾回收如何实现

    1、引用计数为主(缺点:循环引用无法解决)

    2、引用标记清除和分代回收解决引用计数的问题

    3、引用计数为主 + 标记清除和分代回收为辅

 

二、引用计数

    在学习 python 的整个过程中,我们一直在强调,python 一切皆对象,也就是说,在 python 中你用到的一切变量,本质上都是类对象。

    那么,如何知道一个对象永远都不能再使用了呢?很简单,就是当这个对象的引用计数为 0 时,说明这个对象永不再用,自然它就变成了垃圾需要被回收。

    引用计数机制:python 里一切皆对象,它们的核心就是一个结构体:PyObject

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

    PyObject 是每个对象必有的内容,其中 ob_refcnt 就是作为引用计数。当一个对象有新的引用时,它的 ob_refcnt 就会增加1,当引用它的对象被删除或该对象的引用失效时,它的 ob_refcnt 就会减少 1,一旦对象的引用计数为 0,该对象立即被回收,对象占用的内存空间将被释放。

    1、优点:

        (1) 简单;

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

    2、缺点:

        (1) 需要额外的空间维护引用计数,消耗资源;

        (2) 循环引用

import os, sys
import psutil


# 显示当前 python 程序占用的内存大小
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)]
    b = [i for i in range(10000000)]
    show_memory_info('after a, b created')
    a.append(b)
    b.append(a)
    print("a reference count: ", sys.getrefcount(a))
    print("a reference count: ", sys.getrefcount(b))


func()
show_memory_info('finished')


# 运行结果:
initial memory used: 6.50390625 MB
after a, b created memory used: 781.88671875 MB
a reference count:  3
a reference count:  3
finished memory used: 781.88671875 MB

        程序中,a 和 b 相互引用,并且作为局部变量在函数 func 调用结束后,a 和 b 这两个指针从程序意义上已经不存在,但从输出结果中看到,依然有内存占用,这是为什么呢?因为互相引用导致它们的引用计数都不为0。

        有读者可能会说,互相引用还是很容易被发现的呀,问题不大。可是,更隐蔽的情况是出现一个引用环,在工程代码比较复杂的情况下,引用环真不一定能被轻易发现。那应该怎么做呢?

        事实上,python 本身能够处理这种情况,可以显式调用 gc.coolect() 来启动垃圾回收,例如:

import os, sys, gc
import psutil


# 显示当前 python 程序占用的内存大小
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)]
    b = [i for i in range(10000000)]
    show_memory_info('after a, b created')
    a.append(b)
    b.append(a)
    print("a reference count: ", sys.getrefcount(a))
    print("a reference count: ", sys.getrefcount(b))


func()
gc.collect()
show_memory_info('finished')

# 运行结果:
initial memory used: 6.578125 MB
after a, b created memory used: 781.34765625 MB
a reference count:  3
a reference count:  3
finished memory used: 6.90625 MB

        创建 a 和 b 两个列表之后,a 和 b 两个对象的引用计数为1;

        a.append(d)、b.append(a) 引用之后,a 和 b 两个对象的引用计数为2。

        为什么最后获取到 a 和 b 的引用计数都为3呢?

            使用 sys.getrefcount(a) 可以查看 a 对象的引用计数,但是比正常数大 1,因为调用函数的时候传入 a,这会让 a 的引用计数 +1。

    3、示例:   

import os, sys, gc
import psutil


# 显示当前 python 程序占用的内存大小
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: 6.59765625 MB
after a created memory used: 393.90625 MB
finished memory used: 6.82421875 MB

        注意:执行此程序之前,需安装 psutil 模块(获取系统信息的模块),可使用 pip 命令直接安装,执行命令为$pip install psutil,如果遇到 Permission denied 安装失败,请加上 sudo 重试。

        可以看到,当调用函数 func() 且列表 a 被创建之后,内存占用迅速增加到了 393 MB,而在函数调用结束后,内存则返回正常。这是因为,函数内部申明的列表 a 是局部变量,在函数返回后,局部变量的引用会注释掉,此时列表 a 所指代对象的引用计数为0,python 变回执行垃圾回收。因此之前占用的大量内存就又回来了。

        明白这个原理后,稍微修改上面的代码,如下所示:     

import os, sys, gc
import psutil


# 显示当前 python 程序占用的内存大小
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')
    global a
    a = [i for i in range(10000000)]
    show_memory_info('after a created')


func()
show_memory_info('finished')

# 运行结果:
initial memory used: 6.484375 MB
after a created memory used: 393.85546875 MB
finished memory used: 393.85546875 MB

            上面这段代码中,global a 表示将 a 申明为全局变量,则即使函数返回后,列表的引用仍然存在,于是 a 对象就不会被当做垃圾回收掉,依然占用大量内存。

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

import os, sys, gc
import psutil


# 显示当前 python 程序占用的内存大小
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')
    return a


a = func()
show_memory_info('finished')

# 运行结果:
initial memory used: 6.51953125 MB
after a created memory used: 394.41796875 MB
finished memory used: 394.41796875 MB

        以上是最常见的几种情况,下面由表及里,深入看一下 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))

        运行结果:

            

        另一个要注意的是,在函数调用发生的时候,会产生额外的两次引用,一次来自函数栈,另一次是函数参数。

import sys

a = []
print(sys.getrefcount(a))   # 2次
b = a
print(sys.getrefcount(a))   # 3次
c = b
d = b
e = c
f = e
g = d
print(sys.getrefcount(a))   # 8次

        运行结果:

            

        分析一下这段代码,a、b、c、d、e、f、g 这些变量全部指代的是同一个对象,而 sys.getrefcount() 函数并不是统计一个指针,而是要统计一个对象被引用的次数,所以最后一共会有 8 次引用。

        理解引用这个概念后,引用释放释放是一种非常自然和清晰的思想。相比 C 语言中需要使用 free 去手动释放内存,python 的垃圾回收在这里可以说是省力省心了。

        不过还有读者会好奇,如果想手动释放内存,应该怎么做呢?方法同样很简单,只需要先调用 del a 来删除一个对象,然后强制调用 gc.collect() 即可手动启动垃圾回收。例如:

import os, sys, gc
import psutil


# 显示当前 python 程序占用的内存大小
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))


show_memory_info('initial')
a = [i for i in range(10000000)]
show_memory_info('after a created')
del a
gc.collect()
show_memory_info('finished')
print(a)

        运行结果:

            

        是不是觉得垃圾回收非常简单呢?这里再问大家也一个问题:引用次数为 0 是垃圾回收启动的充要条件吗?还有没有其他可能性呢?

            答:引用计数是其中最简单的实现,引用计数并非充要条件,它只能算作充分非必要条件,至于其他的可能性,循环引用正是其中的一种。

三、标记清除

    标记清除(Mark-Sweep)算法是一种基于追踪回收(tracing GC)技术实现的垃圾回收算法。它分为两个阶段:第一阶段是标记阶段,GC 会把所有的 "活动对象" 打上标记,第二阶段是把那些没有标记的对象 "非活动对象" 进行回收。那么GC 又是如何判断哪些是活动对象哪些是非活动对象的呢?

    

    对象之间通过引用(指针)连在一起,构成一个有向图,对象构成这个有向图的节点,而引用关系构成这个有向图的边。从根对象(root object)出发,沿着有向边遍历对象,可达的(reachable)对象标记为活动对象,不可达的对象就是要被清除的非活动对象。根对象就是全局变量、调用栈、寄存器。mark-sweep 在上图中,我们把小红圈视为全局变量,也就是把它作为 root object,从小红圈出发,可达对象被标记为绿色,即为活动对象,未被标记为绿色的对象是非活动对象会被 GC 回收。

    标记清除算法作为 python 的辅助垃圾收集技术主要处理的是一些容器对象,比如 list、dict、tuple、instance等,因为对于字符串、数值对象是不可能造成循环引用的问题。python 使用一个双向链表将这些容器对象组织起来。不过,这种简单粗暴的标记清除算法也有明显的缺点:

        清除非活动对象前它必须顺序扫描整个堆内存,哪怕只剩下小部分活动对象也要扫描所有对象。

 

四、分代回收

    分代回收是一种以空间换时间的操作方式,python 将内存根据对象的存活时间划分为不同的集合,每个集合称为一个代,python 将内存分为了 3 代,分别为年轻代(第0代)、中年代(第1代)、老年代(第2代),它们对应的是 3 个链表,它们的垃圾收集频率随对象的存活时间增大而减小。

    新创建的对象都会分配在年轻代,年轻代链表的总数达到上限时,python 垃圾收集机制就会被触发,把那些可以被回收的对象回收掉,而那些不会回收的对象就会被移到中年代中去,以此类推,老年代中的对象是存活时间最久的对象,甚至是存活于整个系统的生命周期内。而每一代启动自动垃圾回收的阙值,则是单独指定的。当垃圾回收器中新增对象减去删除对象达到相应的阙值时,就会对这一代对象启动垃圾回收。

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

    gc模块里面会有一个长度为3的列表的计数器,可以通过gc.get_count()获取。

    例如(488,3,0),其中488是指距离上一次一代垃圾检查,Python分配内存的数目减去释放内存的数目,注意是内存分配,而不是引用计数的增加。例如:

    print gc.get_count() # (590, 8, 0)
    a = ClassA()
    print gc.get_count() # (591, 8, 0)
    del a
    print gc.get_count() # (590, 8, 0)

    3是指距离上一次二代垃圾检查,一代垃圾检查的次数,同理,0是指距离上一次三代垃圾检查,二代垃圾检查的次数。

    gc模快有一个自动垃圾回收的阀值,即通过gc.get_threshold函数获取到的长度为3的元组,例如(700,10,10)

    每一次计数器的增加,gc模块就会检查增加后的计数是否达到阀值的数目,如果是,就会执行对应的代数的垃圾检查,然后重置计数器。

    例如,假设阀值是(700,10,10):

        a、当计数器从(699,3,0)增加到(700,3,0),gc模块就会执行gc.collect(0),即检查一代对象的垃圾,并重置计数器为(0,4,0)

        b、当计数器从(699,9,0)增加到(700,9,0),gc模块就会执行gc.collect(1),即检查一、二代对象的垃圾,并重置计数器为(0,0,1)

        c、当计数器从(699,9,9)增加到(700,9,9),gc模块就会执行gc.collect(2),即检查一、二、三代对象的垃圾,并重置计数器为(0,0,0)

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值