Python interview - Python 垃圾处理机制

面试的时候问到了Python的GC,垃圾处理机制是怎么样的。 没仔细研究就直接进了一个大坑。现在总结网上大牛们博客来自己写一个总结。


在国内博客上,看到的关键词都是:引用计数,标记引用,分代回收。对应的国外帖子中就是:reference counting, mark and sweep, 分代回收没有固定的词,但是翻译成generation collection还是不错的,个人觉得generational GC也挺好。


谈论GC之前,参考Vamei的博客说一下python的内存管理。

Python是一门动态类型的、面向对象的语言,对象与引用分离。有一个描述很好,python就像使用“筷子”一样,通过引用来接触和翻动真正的食物-对象。

结合赋值语句和内置函数id()来观察。id()函数用于返回对象的身份(identity)。这里所谓的身份,就是该对象的内存地址。

a = 1

print id(a) # 140441997576472
print hex(id(a)) # 0x7fbb33608918

分别是内存地址的十进制和十六进制表示。


需要知道的时,在python中,整数和短小的字符,python都会缓存这些对象,以便重复使用。当我们创建多个赋值为1的引用时,实际上是让所有这些引用指向同一个对象。

a = 1
b = 1

print id(a) # 140414173611256
print id(b) # 140414173611256

我们可以看到的是,程序返回是一样的。可见a和b实际上是指向同一个对象的两个引用。


我们可以用is关键词来判断以上所说,python是否只是缓存整数和短小的字符。

a = 1
b = 1

print a is b # True

a = 9999
b = 9999

print a is b # True

a = "good"
b = "good"

print a is b # True

a = "Happy new year!"
b = "Happy new year!"

print a is b # True

a = []
b = []

print a is b # False

a = ()
b = ()

print a is b # True

a = (1, 2)
b = (1, 2)

print a is b # False

从结果来看,python确实缓存了整数和字符,对于空的tuple也进行了缓存。



一句话来说,Python GC 主要使用引用计数(reference counting)来跟踪和回收垃圾。在引用计数的基础上,通过“标记-清除”(mark and sweep)解决容器对象可能产生的循环引用问题,通过“分代回收”(generational collection)以空间换时间的方法提高垃圾回收效率。接下来就一个一个概念来总结


1. 引用计数

在python中,每个对象都有存有指向该对象的引用总数,即引用计数(reference count)。

我们可以用sys包中得getrefcount(),来查看某个对象的引用计数。

from sys import getrefcount

a = [1, 2, 3]
print(getrefcount(a)) # 2

b = a
print(getrefcount(b)) # 3

需要注意的是,当使用某个引用作为参数的时候,传递给getrefcount()函数的时候,自然的参数实际上会创建一个临时的引用。因此,我们得到的结果会比期望的多1。


引用的增加和减少:

a = [1, 2, 3]
print(getrefcount(a)) # 2

b = [a, a]
print(getrefcount(a)) # 4

由于对象b引用了两次a,a的引用计数增加2,最后的输出是4。


a = [1, 2, 3]
b = a
print(getrefcount(b)) # 3

del a
print(getrefcount(b)) # 2

用del关键词删除某个引用。

a = [1, 2, 3]
b = a
print(getrefcount(b)) # 3

a = 1
print(getrefcount(b)) # 2


之前的引用被重新定义,对象的引用计数会减少。



结合CPython源码分析:

所有的python对象的头部都包含了这样一个结构PyObject(相当于继承自PyObject)

// object.h
struct _object {
    Py_ssize_t ob_refcnt;
    struct PyTypeObject *ob_type;
} PyObject;
ob_refcnt就是引用计数值。

例如,下面是int型对象的定义

// intobject.h
typedef struct {
        PyObject_HEAD
        long ob_ival;
} PyIntObject;


引用计数,有很明显的优点:

1. 高效

2. 运行期间没有停顿

3. 对象有确定的生命周期

4. 易于实现

但是, 原始的引用计数也有明显的缺点:

1. 维护引用计数的次数和引用赋值成正比,而不像mark and sweep等基本与回收的内存数量有关。

2. 无法解决循环引用的问题。 A和B互相引用而再没有外部引用A和B中得任何一个,它们的引用计数都为1,但是明显应该被回收。


{

引入gc模块进行分析:

from sys import getrefcount
import gc
gc.set_debug(gc.DEBUG_STATS|gc.DEBUG_LEAK)

a = []
b = []
a.append(b)
print(getrefcount(a)) # 2
print(getrefcount(b)) # 3
del a
del b
print gc.collect()
'''
gc: collecting generation 2...

gc: objects in each generation: 194 3361 0

gc: done, 0.0004s elapsed.
0
gc: collecting generation 2...
gc: objects in each generation: 0 0 3550
gc: done, 0.0004s elapsed.
'''

我们可以发现,这里GC并不起作用,垃圾处理仅仅对循环引用起作用。那么我们改一下a引用b,b也引用a。

from sys import getrefcount
import gc
gc.set_debug(gc.DEBUG_STATS|gc.DEBUG_LEAK)

a = []
b = []
a.append(b)
b.append(a)
print(getrefcount(a)) # 2
print(getrefcount(b)) # 3
del a
del b
print gc.collect()
'''
3
3
2
gc: collecting generation 2...
gc: objects in each generation: 196 3361 0
gc: collectable <list 0x1009c7830>
gc: collectable <list 0x1009c7128>
gc: done, 2 unreachable, 0 uncollectable, 0.0004s elapsed.
gc: collecting generation 2...
gc: objects in each generation: 0 0 3552
gc: done, 0.0004s elapsed.
'''

很明显可以发现,这次两个list,a和b是循环引用,垃圾回收果然起了作用,两个list对象被回收了。

}

为了解决这两个问题,python又引入了后面两种GC机制。


2. 标记引用

在上面的例子中,被回收的两个list对象,是unreachable的对象,为什么会unreachable呢?

需要了解python的标记-清除回收机制:

* 寻找root object集合,root object多指全局引用和函数栈上的引用,如上代码中,a就是root object。

* 从root object出发,通过其每一个引用到达的所有对象都标记被reachable(垃圾检测)

* 将所有的非reachable的对象删除(垃圾回收)


这里还需要提到垃圾回收中->>可收集对象链表,python将所有可能产生循环引用的对象用链表连接起来,所谓的可产生循环引用的对象也就是list,dict,class等得容器类,int,string并不是,每次实例化此类对象时候都会加入这个链表,我们将该链表称为可收集对象链表,此链表是双向的。

比如:a=[], b=[], c={},将会产生: head<---> a <---> b <---> c 的双向链表。


由此,我们可以假想上述代码的垃圾回收过程:当调用gc.collect()的时候,将从root object开始,就是a。由于del a,del b,那么a和b都将称为unreachable对象,而且循环引用被拆除,此时的a,b引用数都是0,那么a,b都将被回收,所以collect返回2。

from sys import getrefcount
import gc
gc.set_debug(gc.DEBUG_STATS|gc.DEBUG_LEAK)

a = []
b = []
a.append(b)
b.append(a)
print(getrefcount(a)) # 3
print(getrefcount(b)) # 3
del b
print gc.collect()
'''
3
gc: collecting generation 2...
3
0
gc: objects in each generation: 196 3361 0
gc: done, 0.0004s elapsed.
gc: collecting generation 2...
gc: objects in each generation: 0 0 3552
gc: done, 0.0004s elapsed.
'''

很明显的,这次垃圾回收并没有成功,虽然我们del b,但是a对象依旧在,从a出发,还能找到b的引用,所以b还是reachable对象,并不会被垃圾收集。



结合CPython源码分析:

对于可以包含其他对象引用的容器对象(如list,dict,set,class)都可能产生循环引用,为此,在申请内存时,所有容器对象的头部都加上了PyGC_Head来实现“标记-清除”机制。

// objimpl.h
typedef union _gc_head {
    struct {
        union _gc_head *gc_next;
        union _gc_head *gc_prev;
        Py_ssize_t gc_refs;
    } gc;
    long double dummy;  /* force worst-case alignment */
} PyGC_Head;

在为对象申请内存的时候,可以明显看到,实际申请的内存数量已经加上了PyGC_Head的大小

// gcmodule.c
PyObject *
_PyObject_GC_Malloc(size_t basicsize)
{
    PyObject *op;
    PyGC_Head *g = (PyGC_Head *)PyObject_MALLOC(
                sizeof(PyGC_Head) + basicsize);
    if (g == NULL)
        return PyErr_NoMemory();

    ......

    op = FROM_GC(g);
    return op;
}

举例来说,从list对象的创建,主要逻辑如下:

// listobject.c
PyObject *
PyList_New(Py_ssize_t size)
{
    PyListObject *op;
    ......
    op = PyObject_GC_New(PyListObject, &PyList_Type);
    ......
    _PyObject_GC_TRACK(op);
    return (PyObject *) op;
}

_PyObjecy_GC_TRACK就将对象链接到了第0代对象集合中。用于分代回收。

垃圾标记时,会先将集合中得对象的引用计数赋值一份副本,以免在操作过程中破坏真实的引用计数值。

// gcmodule.c
static void
update_refs(PyGC_Head *containers)
{
    PyGC_Head *gc = containers->gc.gc_next;
    for (; gc != containers; gc = gc->gc.gc_next) {
        assert(gc->gc.gc_refs == GC_REACHABLE);
        gc->gc.gc_refs = FROM_GC(gc)->ob_refcnt;
        assert(gc->gc.gc_refs != 0);
    }
}

然后操作这个副本,遍历对象集合,将被引用对象的引用艺术副本值-1.

// gcmodule.c
static void
subtract_refs(PyGC_Head *containers)
{
    traverseproc traverse;
    PyGC_Head *gc = containers->gc.gc_next;
    for (; gc != containers; gc=gc->gc.gc_next) {
        traverse = FROM_GC(gc)->ob_type->tp_traverse;
        (void) traverse(FROM_GC(gc),
                   (visitproc)visit_decref,
                   NULL);
    }
}

这个traverse的对象类型定义的函数,用来遍历对象,通过传入的回调函数visit_decref来操作引用计数副本。

例如dict就要在key和value上都用visit_decref操作一遍:

// dictobject.c
static int
dict_traverse(PyObject *op, visitproc visit, void *arg)
{
    Py_ssize_t i = 0;
    PyObject *pk;
    PyObject *pv;

    while (PyDict_Next(op, &i, &pk, &pv)) {
        visit(pk);
        visit(pv);
    }
    return 0;
}

然后根据引用计数副本值是否是0将集合内的对象分成两类,reachable和unreachable,其中unreachable就是可以被回收的对象。

// gcmodule.c
static void
move_unreachable(PyGC_Head *young, PyGC_Head *unreachable)
{
    PyGC_Head *gc = young->gc.gc_next;
    while (gc != young) {
        PyGC_Head *next;
        if (gc->gc.gc_refs) {
            PyObject *op = FROM_GC(gc);
            traverseproc traverse = op->ob_type->tp_traverse;
            assert(gc->gc.gc_refs > 0);
            gc->gc.gc_refs = GC_REACHABLE;
            (void) traverse(op,
                            (visitproc)visit_reachable,
                            (void *)young);
            next = gc->gc.gc_next;
        }
        else {
            next = gc->gc.gc_next;
            gc_list_move(gc, unreachable);
            gc->gc.gc_refs = GC_TENTATIVELY_UNREACHABLE;
        }
        gc = next;
    }
}

在处理了weak reference和finalizer等琐碎的细节之后,就可以回收unreachable的对象了。



3. 分代回收

分代回收的整体思想是,将系统中的所有内存块根据其存活时间划分为不同的集合,每个集合就是一个“代”,generation。垃圾收集频率随着不同的“代”的存活时间的增大而减小,存活时间通常利用经过几次垃圾回收来度量。


基本来说,当引用计数为0的时候,对象就要被回收。

a = [1, 2, 3]
del a

del a之后,已经没有了任何引用指向之前建立的[1,2,3]。那么这个对象就不能被接触或者使用。当垃圾回收启动的时候,Python会扫描到这个引用计数为0的对象,将它所占据的内存清空。


然而,python并不是频繁的进行垃圾回收。如果内存中的对象不多,那么就不必要总是进行垃圾回收。所以,python只有在特定条件下,自动启动垃圾回收。当python运行时,会记录其中分配对象(object allocation)和取消分配对象(object deallocation)的次数。当两者的差值高于某个阈值的时候,垃圾回收才会启动。


我们可以通过gc模块的get_threshold()方法,查看该阈值:

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

default是(700, 10, 10)。后面两个10是与分代回收相关的阈值。700即是垃圾回收启动的阈值。可以通过set_threshold()方法重新设置。

import gc
gc.set_threshold(700, 10, 5)


当然也可以手动启动垃圾回收,即gc.collect()。


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


这两个数字即(700,10,10)中得两个10。也就是说,每10次0代垃圾回收,会配合一次1代垃圾回收;而每10次1代垃圾回收,才会有1次2代垃圾回收。


结合CPython源码分析:

用来表示“代”的结构体是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;
 }



=====================

扩展:

内存泄露

import gc
gc.set_debug(gc.DEBUG_STATS|gc.DEBUG_LEAK)

class A:
    def __del__(self):
        pass
class B:
    def __del__(self):
        pass

a=A()
b=B()
print hex(id(a)) # 0x107a4f5a8
print hex(id(a.__dict__)) # 0x7ff0b05423e0
a.b=b
b.a=a
del a
del b

print gc.collect() # 4
'''
gc: collecting generation 2...
gc: objects in each generation: 211 3361 0
4
gc: uncollectable <A instance at 0x10ee295a8>
gc: uncollectable <B instance at 0x10ee295f0>
gc: uncollectable <dict 0x7f849d22ba10>
gc: uncollectable <dict 0x7f849d22b050>
gc: done, 4 unreachable, 4 uncollectable, 0.0005s elapsed.
gc: collecting generation 2...
gc: objects in each generation: 0 0 3564
gc: done, 0.0004s elapsed.
'''
print gc.garbage

从输出中,看到uncollectible字样,很明显这次垃圾回收搞不定,造成了内存泄露。

因为在del b的时候,会调用b的__del__方法,该方法中很可能使用了b.a,但是如果在之前的del a时候将a给回收掉,那么此时就会造成异常。所以python会造成uncollectable,也就是内存泄露。所以__del__方法要慎用,如果用的话确保没有循环引用。


Tip1:print hex(id(a)),验证了回收的确实是a。

Tip2:gc.garbage,返回的时unreachable对象,且不能被回收的对象。由于有设定gc.set_debug(gc.DEBUG_STATS|gc.DEBUG_LEAK),而gc.DEBUG_LEAK=gc.set_debug(gc.DEBUG_STATS|gc.DEBUG_COLLECTABLE|gc.DEBUG_UNCOLLECTABLE|gc.DEBUG_INSTANCES|gc.DEBUG_OBJECTS|gc.DEBUG_SAVEALL),文档中指出如果设置了gc.DEBUG_SAVEALL,那么所有的unreachable对象都将加入gc.garbage返回的列表,而不止不能被回收的对象。



参考:

1. http://blog.csdn.net/yueguanghaidao/article/details/11274737

2. http://www.cnblogs.com/vamei/p/3232088.html

3. https://pymotw.com/2/gc/

4. https://www.digi.com/wiki/developer/index.php/Python_Garbage_Collection

5. http://patshaughnessy.net/2013/10/30/generational-gc-in-python-and-ruby

6. http://hbprotoss.github.io/posts/pythonla-ji-hui-shou-ji-zhi.html


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值