Python源码解读之四 对象回收与引用计数

前言

在 C 和 C++ 中,程序员被赋予了极大的自由,可以任意地申请内存。但权力的另一面对应着责任,程序员最后不使用的时候,必须负责将申请的内存释放掉,并把无效指针设置为空。可以说,这一点是万恶之源,大量内存泄漏、悬空指针、越界访问的 bug 由此产生。

而现代的开发语言都带有垃圾回收机制,语言本身负责内存的管理和维护,比如 C#、Java、Go。垃圾回收机制将开发人员从维护内存分配和清理的繁重工作中解放出来,但同时也剥夺了程序员和内存亲密接触的机会,并牺牲了一定的运行效率。不过好处就是提高了开发效率,并降低了 bug 发生的概率。

Python 里面同样具有垃圾回收机制,只不过它是为引用计数机制服务的。所以解释器通过内部的引用计数和垃圾回收,代替程序员进行繁重的内存管理工作,关于垃圾回收我们后面会详细说,先来看一下引用计数。

引用计数

Python通过管理对象的引用计数来决定对象在内存中的存在与否,前面的分享中我们说过,Python一切皆对象,所有对象都有一个 **_ob_refcnt _**成员。这个成员维护这对象的引用计数,也决定这对象的存在与消亡。

相比起类型对象,我们更关注实例对象行为。因为只有讨论实例对象,我们的讨论才有意义。类型对象(内置)超越了引用计数规则,永远不会被析构或者销毁,因为他们在底层是被静态定义好的。同理,我们自定义的类,虽然可以被回收,但是探讨它的引用计数也是没有价值的,我们可以看第一个例子:

class A:
    pass

del A

首先 del_ **关键字只能作用于变量,不可以作用于对象,比如:pi = 3.14,你可以del pi,但是不可以 del 3.14,这不符合语法规则。因为 **del _的作用是让变量指向的对象的引用计数减 1,所以我们只能 del 变量,不能 del 对象。
我们使用 def 、class 关键字定义出来的也属于变量,比如上面代码中的 A ,只要是变量,就可以被 del。但是 del 变量只是删除了该变量,换言之就是让该变量无法再被使用,至于变量指向的对象是否会被回收,就看是否还有其他的引用也指向它。也就是说,对象是否被回收完全由解释器判断他的引用计数是否为0所决定。
因此当对象的所有引用都被删除之后,那么该对象也会被删除。以我们上面的代码为例,里面的 A 也是一个变量,引用了某个 PyTypeObject 结构体实例,并且它也纸杯变量 A 引用。所以当 del A 之后,底层的类对象也会被销毁。
另外,我们虽然说 int、str、 tuple 这些是类型对象,但这只是从 Python 的层面。如果要从解释器的角度来看,比如 int ,它也是一个变量,指向对应的数据结构(PyLong_Type),既然是变量,那么久可以被删除。

try:
    del int
except NameError as e:
    print(e) # name 'int' is not defined

上面的代码在执行的时候报错了,原因是 del 只能删除局部作用于和全局作用域内的变量(也就是局部变量和全局变量),对于内置作用域里面的变量, del 是无法删除的。

如果从底层来讲, int它也是一个变量, 指向了对应的数据结构(PyLong_Type)
既然是变量, 那么就可以删除, 但是这个删除并不是直接删除对象,而是将变量指向的对象的引用计数减去1,然后将这个变量也给删掉。
Python中的对象是否被删除是通过其引用计数是否为0决定的, "del 变量"只是删除了这个变量,让这个变量不再指向该对象罢了
所以"del 变量"得到的结果就是我们没办法再使用这个变量了,这个变量就没了,但是变量之前指向的对象是不是也没了就看还有没有其它的引用也指向它。

如果一定想删除也是有办法的,需要显式地指定内置作用域,比如:

# 内置类型、内置函数都在__builtins__ 里面
print(__builtins__) # 
# __builtins__ 等价于 import builtins
# Python变量查找默认按照 LEGB 规则
# 所以调用 __builtins__.int 比直接用 int 要快一些

# 我们将 int 给删掉,可以直接 del __builtins__.int
# 但这里获取了模块的属性字典
# 主要想表明操作模块本质上就是操作模块的属性字典
# 比如 module.attr 相当于 module.__dict__["attr"]

__builtins__.__dict__.pop("int") # 等价于 del __builtins__.int
try:
    print(int(3))
except NameError as e:
    print(e) # name 'int' is not defined

我们看到此时 int 已经不能使用了,因为它已经从内置作用域中被删除了。不过 int 虽然被上除了,但是 int 底层指向的 PyLong_Type 却没有被删除。

del __builtins__.int

try:
    print(int(3))
except NameError as e:
    print(e)  # name 'int' is not defined

# int 虽然被删除
# 但是 int 指向的类型对象没有被删除
n = 123
print(n.__class__)  # 
print(n.__class__("0xFF", base=16))  # 255

之所以会有这个现象,是因为底层的 PyLong_Type 并不仅仅被 int 这个变量所引用,我们来查看一下它的引用计数吧。

import sys

print(sys.getrefcount(int))  # 98
my_int = int
print(sys.getrefcount(int))  # 99

我们看到整型对象的引用计数非常多,主语它都被谁引用了,我们就不用关心。总之,我们探讨类型独享的引用计数是没有太大意义的,而且内置类型超越了引用计数的规则,我们也没有太多关注的必要。我们得需要关注的是实例对象。

>>> import sys
>>> e = 2.71
>>> sys.getrefcount(e)
2

当我们创建一个新的对象时,引用对象应该为 1 ,但是这里为什么是 2 呢?因为 e 这个变量作为参数传到了sys.getrefcount 这个函数里面,所以函数里面的参数也指向 2.71 这个 PyFloatObject, 所以引用计数加1 。当函数结束后,局部变量被销毁,再将引用计数减 1 。

>>> a = e
>> sys.getrefcount(e)
3
>>> sys.getrefcount(a)
3

上面这段应该比较容易理解,因为变量间的传递会传递指针,所以 a 也会指向 2.71 这个浮点数,因此对象的引用计数加 1 。

注意:我们说变量只是个符号,引用计数是针对变量指向的对象而言的,变量本身没有所谓的引用计数。

此时变量 e 指向的对象的引用计数为 3 ,我们说操作变量相当于操作变量指向的对象,a e 都指向同一个对象,所以结果是一样的,都是 3 ,因为实际上获取的是同一个对象的引用计数。

>>> lst = [e, a]  
>>> sys.getrefcount(e)  
5

>>> del lst
>>> sys.getrefcount(e)  
3  

把变量放在容器里面,显然列表 lst 里面多了两个指针,这两个指针也指向这里的 PyFloatObject对象,因此结果是 5 也就不难理解了。
将列表删除、或者将列表清空,那么里面的变量也就没了。而在删除变量之后,会将变量指向的对象的引用计数减去 1,所以又变成了 3。

# 再删除一个变量,引用计数再减 1
>>> del a  
>>> sys.getrefcount(e)
2 
# 结果为 2,说明外部还有一个变量在引用它
# 所以这个浮点数不会被回收。
# 如果再次del,引用计数就会为0,这个浮点数就真的没了。
>>> del e  

另外,引用计数什么时候会加1,什么时候会减1,在上一篇文章中已经说的很详细了,可以去看一下。

这里增加一个额外的讨论,这里使用sys.getrefcount查看引用计数,为什么要用命令行。如果大家有用pycharm执行这段代码,会发现打印出来的结果是4而不是2。这里说一下在pycharm是那4个地方引用已经为什么在交互打印结果是2。
首先pycharm引用的分别是:

  1. 全局名字空间
  2. 每个编译单元都会有一个常量池(元组),存放字符串、整数浮点数等常量
  3. 解释器在编译脚本时,会将它转成字节码,这个过程也会将便连忙,以及常量都放在一个列表中
  4. sys.getrefcount是一个函数,传进去也会加一个引用

而在交互打印的结果是2,原因在于交互环境下,每一行都是一个独立的执行单元,执行之后,该单元被销毁了,只有全局名字空间会保存2.71的引用。而使用pycharm,整个文件是作为一个整体编译的。

如果在交互环境下,输入e = 2.71;sys.getrefcount(e),会发现打印的结果也是4,因为一行,查看引用计数的时候,e = 2.71所在的编译单元还没有被销毁。

在底层,解释器会通过Py_INCREF(op)和Py_DECREF(op)这两个宏,来增加和减少一个对象的引用计数,当一个对象的引用计数减少到 0 后,Py_DECREF将调用该对象的析构函数来释放该对象所占有的内存和系统资源。这个析构函数由对象的类型对象(Py***_Type)中定义的函数指针来指定,也就是tp_dealloc。
我们来看一下底层的这些宏:

#define _Py_NewReference(op) (                          \
    _Py_INC_TPALLOCS(op) _Py_COUNT_ALLOCS_COMMA         \
    _Py_INC_REFTOTAL  _Py_REF_DEBUG_COMMA               \
    Py_REFCNT(op) = 1)
    //对于新创建的对象,引用计数为 1    

#define _Py_Dealloc(op) (                               \
    _Py_INC_TPFREES(op) _Py_COUNT_ALLOCS_COMMA          \
    (*Py_TYPE(op)->tp_dealloc)((PyObject *)(op)))
//引用计数为 0 时执行析构函数
//Py_TYPE(op)->tp_dealloc获取析构函数对应的函数指针
//再通过 * 获取指向的函数,函数接收 PyObject *
//所以将op转成PyObject *进行调用,最终回收对象


//增加引用计数
#define Py_INCREF(op) (                         \
    _Py_INC_REFTOTAL  _Py_REF_DEBUG_COMMA       \
    ((PyObject *)(op))->ob_refcnt++) 
    //引用计数自增 1


//减少引用计数
#define Py_DECREF(op)                                   \
    do {                                                \
        PyObject *_py_decref_tmp = (PyObject *)(op);    \
        if (_Py_DEC_REFTOTAL  _Py_REF_DEBUG_COMMA       \
        --(_py_decref_tmp)->ob_refcnt != 0)             \
            _Py_CHECK_REFCNT(_py_decref_tmp)            \
        else                                             \
            _Py_Dealloc(_py_decref_tmp);                \
            //引用计数减1,如果减完1变成了0,则执行析构函数 
    } while (0)											\
        
//注意:Py_INCREF和Py_DECREF不可以处理NULL指针,会报错
//所以又有两个宏,做了一层检测,会判断对象指针为NULL的情况
#define Py_XINCREF(op)                                \
    do {                                              \
        PyObject *_py_xincref_tmp = (PyObject *)(op); \
        if (_py_xincref_tmp != NULL)                  \
            Py_INCREF(_py_xincref_tmp);               \
    } while (0)

#define Py_XDECREF(op)                                \
    do {                                              \
        PyObject *_py_xdecref_tmp = (PyObject *)(op); \
        if (_py_xdecref_tmp != NULL)                  \
            Py_DECREF(_py_xdecref_tmp);               \
    } while (0)
//当然减少引用计数,除了Py_DECREF和Py_XDECREF之外
//还有一个Py_CLEAR,也可以处理空指针的情况

因此这几个宏的作用如下:

  • _Py_NewReference:接收一个对象,将其引用计数设置为1,用于新创建的对象。此外我们在定义里面还看到了一个宏Py_REFCNT,这是用来获取对象引用计数的,也就是获取 ob_refcnt 成员的值。当然除了Py_REFCNT之外,我们之前还见到了一个宏叫Py_TYPE,这是专门获取对象的类型的,得到的是一个指向 PyTypeObject 结构体实例的指针,也就是 ob_type 成员的值
  • _Py_Dealloc:接收一个对象, 执行该对象的类型对象里面的析构函数, 来对该对象进行回收
  • Py_INCREF:接收一个对象, 将该对象引用计数自增1,即ob_refcnt++
  • Py_DECREF:接收一个对象, 将该对象引用计数自减1,即ob_refcnt–,如果自减之后发现为0,那么调用**_Py_Dealloc**
  • Py_XINCREF:和Py_INCREF功能一致,但是可以处理空指针
  • Py_XDECREF:和Py_DECREF功能一致,但是可以处理空指针
  • Py_CLEAR:和Py_XDECREF类似,也可以处理空指针

在一个对象的引用计数为 0 时,与该对象对应的析构函数就会被调用,但是要特别注意的是,我们刚才一直说调用析构函数之后会回收对象或者销毁对象、删除对象等等,意思是将这个对象从内存中抹去,但是这并不意味着最终一定调用 free 释放空间。换句话说就是对象没了,但是对象占用的内存却有可能还在。

如果对象没了,占用的内存也要释放的话,那么频繁申请、释放内存空间会使Python的执行效率大打折扣,更何况Python已经背负了人们对其执行效率的不满这么多年。

所以Python底层大量采用了缓存池的技术,使用这种技术可以避免频繁地申请和释放内存空间。因此在析构的时候,只是将对象占用的空间归还到缓存池中,并没有真的释放。

这一点,在后面剖析内置实例对象(PyFloatObject,PyListObject等等)的实现中,将会看得一清二楚,因为大部分内置的实例对象都会有自己的缓存池。

对象的分类

这里再补充一下对象的分类,我们之前根据支持的操作,将Python对象分成了数值型、序列型、映射型,总共3类,但其实我们是可以分为5类的:

  • Fundamental对象:类型对象,如int、float、bool、tuple
  • Numeric对象:数值对象,如int实例、float实例、bool实例
  • Sequence对象:序列对象,如str实例、list实例、tuple实例
  • Mapping对象:关联对象(映射对象),如dict实例
  • Internal对象:虚拟机在运行时内部使用的对象,如function实例(函数)code实例(字节码)frame实例(栈帧)module实例(模块)method实例(方法)。没错,函数、字节码、栈帧、模块、方法等等它们在底层也都是一个类的实例对象。比如:函数的类型是**,在底层对应PyFunctionObject。那么问题来了,的类型对象是什么呢?显然就是**啦。

小结

到这一章我们已经把所有的基础概念讲完了,从下一篇开始我们就要详细剖析内置对象的底层实现了,不如浮点数、比如浮点数、整数、字符串、元组、列表等等都是如何实现的。

有了目前为止的这些基础,我们后面就会轻松很多,先把对象、变量等概念梳理清楚,然后再来搞这些数据结构的底层实现。

你可能感兴趣的:(python,python,jvm,java)