Python 作为一种动态类型的语言,其对象和引用分离。在 Python 中万物皆对象,因此Python 的存储问题等同于对象的存储问题,对于每个对象,Python 都会分配一块内存空间去存储它 。
我们通过一个简单的赋值语句来理解 变量与对象,如下:
变量(testops),通过变量指针引用对象,变量指针指向具体对象的内存空间,取对象的值。对象 (9527),类型已知,每个对象都包含一个头部信息(类型标识符和引用计数器)。Python中变量名没有类型,类型属于对象,对象的类型决定了变量的类型。
如上,整数 9527 为一个对象, test 是一个变量。利用赋值语句,引用 test 指向对象 9527 。9527 对象存储在内存中,我们可以通过 id 函数,查看对象的内存地址,引用示意图如下:
对于整数和短小的字符等,会触发Python的缓存机制,即Python将这些对象进行缓存,不会为相同的对象分配不同的内存空间,如下:
如上,我们使用 is 关键字判断两个引用所指的对象是否相同。可以看到,由于Python缓存了小整数(其实也缓存了短字符串,Python2),因此每个对象只存有一份,比如,使用赋值语句创建小整数,如 27,并没有创建出新的对象,而是创建了一个引用。而当使用赋值语句创建大的整数可以有多个相同的对象,如使用赋值语句创建大整数 27000,此时创建出多个对象。
在Python中,每个对象都有指向该对象的引用总数,即引用计数(reference count)。一个对象会记录着引用自己的对象的个数,每增加1个引用,个数加1,每减少1个引用,个数减1。在垃圾回收过程中,利用引用计数器方法,在检测到对象引用个数为 0 时,对普通的对象进行释放内存的机制。
我们可以使用 sys.getrefcount 方法,来查看每个对象的引用计数。需要注意的是,当使用某个引用作为参数,传递给 getrefcount方法时,参数实际上创建了一个临时的引用。因此,getrefcount 方法所得到的结果比期望的多1。
由上可见,l 中的 [t,27] 两个元素,都指向了同一个对象,实际上,容器对象(如,列表、字典等)中包含的并不是元素对象本身,是指向各个元素对象的引用。同时,l 的引用计数随着 ll 的创建和删除,引用计数也随着增加1和减少1。
导致引用计数增加的场景如下:
导致引用计数减少的场景如下:
引用计数中的循环引用
循环引用即对象之间进行相互引用,出现循环引用后,利用上述引用计数机制无法对循环引用中的对象进行释放空间,从而导致内存泄漏,这就是循环引用问题,如下:
对象 test 中的元素引用 ops,而对象 ops 中元素同时来引用 test ,从而造成仅仅删除 test和 ops对象,无法释放其内存空间,因为他们依然在被引用(引用个数不为0)。进一步解释就是循环引用后,test 和 ops 被引用个数为2,删除 test 和 ops 对象后,两者被引用的个数变为1,并不是0,而Python只有在检查到一个对象的被引用个数为0时,才会自动释放其内存,所以这里无法释放 test 和 ops 的内存空间,因此这也是导致内存泄漏的原因之一。
在Python中的对象越来越多,占用的内存越来越大,垃圾回收机制就是将没用的对象清除,释放内存。Python垃圾回收采用引用计数机制为主,标记-清除和分代回收机制为辅的策略,其中标记清除机制用来解决技术引用带来的循环引用而无法释放内存的问题,分代回收机制是为提升垃圾回收的效率。
当Python的对象的引用计数降为 0时,说明没有任何引用指向该对象,该对象就成为要被回收的垃圾。比如新建一个对象,被赋值给某个变量,则该对象的引用计数变为1。如果变量被删除,对象的引用计数为0,那么该对象就会被垃圾回收。
如上,执行 del t 后,已经没有任何引用指向之前建立的对象 9527,该对象引用计数变为0,用户不可能通过任何方式使用这个对象,当垃圾回收启动时,Python扫描到这个引用计数为0的对象,就将它所占用的内存进行回收。
标记-清除机制——解决循环引用问题
标记-清除机制顾名思义,首先标记对象(垃圾检测),然后清除垃圾(垃圾回收),标记清除用来解决引用计数机制产生的循环引用,进而导致内存泄漏的问题。循环引用只有在容器对象才会产生,比如字典,元组,列表等。首先为了追踪对象,需要每个容器对象维护两个额外的指针,用来将容器对象组成一个链表,指针分别指向前后两个容器对象,这样可以将对象的循环引用摘除,就可以得出两个对象的有效计数,我们通过如下示例,进一步了解一下。
在 标记-清除机制中,存在root链表、unreachable链表,这里简单介绍一下。
如上,在未执行 del 语句时,test、ops的引用计数都为 2。但是在 del 执行完以后,test、ops 引用次数互相减 1。test、ops陷入循环引用中,此时标记清除机制来打破这种循环引用,找到其中一端 test 开始拆test、ops的引用环。即从 test 出发,因为它有一个对 ops的引用,则将 ops的引用计数减1,然后顺着引用达到 ops,因为 ops有一个对 test的引用,同样将 test的引用减1,如此就完成了循环引用对象间环摘除。
引用环去掉以后发现,test、ops循环引用变为了0,所以test、ops就被添加到 unreachable链表 中直接被回收掉。
分代回收机制-提升垃圾回收效率
解决循环引用问题,引入的标记-清除机制,处理过程非常繁琐,需要处理每一个容器对象,因此Python考虑一种改善性能的做法,基于“对象存在时间越长,越不可能在后面的程序中变成垃圾”的假设,提出分代回收机制。出于信任和效率,对于这样一些“寿命长”的对象,我们相信它们的存在价值,所以降低在垃圾回收中扫描它们的频率,分代回收是一种以空间换时间的操作方式。我们可以通过 gc.get_threshold 方法,查看分代回收机制的参数阈值设置,如下:
Python将所有的对象分为年轻代(第0代)、中年代(第1代)、老年代(第2代)三代。所有的新建对象默认是 第0代对象。当某一代对象经历过垃圾回收,若依然存活,那么它就将被划分到下一代对象。垃圾回收启动时,会扫描所有的 第0代对象。如果 第0代经过一定次数垃圾回收,那么就触发对0代和1代的扫描清理。当第1代也经历了一定次数的垃圾回收后,那么会触发对 第0,1,2代,即对所有对象进行扫描。
如上,gc.get_threshold 方法返回的 (700, 10, 10),700即是垃圾回收启动的阈值,返回的两个10是指,每10次0代垃圾回收,会执行1次1代的垃圾回收;而每10次1代的垃圾回收,会执行1次的2代垃圾回收。
同样可以用 gc.set_threshold 来调整分代回收策略,比如对 第2代对象进行更频繁的扫描,如下:
通过此分代回收机制,循环引用中的内存回收处理过程就会得到很大的性能提升。
在以下三种情况下,会触发垃圾回收机制:
接下来我们进一步了解一下 自动回收和手动回收 方式。
自动回收
垃圾回收时,Python不能运行其它的任务,频繁的垃圾回收将极大的降低Python的工作效率,当开启垃圾回收机制时(默认开启),在Python运行过程,会记录其中新增对象和释放对象的次数,当两者的差值高于某个阈值时,自动启动垃圾回收。
在Python中默认开启自动回收,其中涉及方法如下:
手动回收
使用 gc.collect 方法,手动执行分代回收机制。
上面例子中test、ops为循环引用,通过 gc.collect 手动回收垃圾,实现了回收的两个test、ops的对象。此外,gc.collect 方法返回此次垃圾回收的unreachable(不可达)对象个数,上面例子中回收的两个都是unreachable对象,即清除 我们在标记-清除机制中提到的unreachable 链表中的对象。