首页 > 技术文章 > PYLevel03Python内存管理之垃圾回收机制

bbdbolg 2020-12-20 02:07 原文

03 Python内存管理之垃圾回收机制

2 引入

解释器在执行到定义变量的语法时,会申请内存空间来存放变量的值,而内存的容量是有限的,因此这就涉及到了变量值所占用内存空间回收问题。当一个变量值没有存在价值(我们称之为垃圾)的时候,就应该将其占用的内存空间给回收掉,那么,问题来了,什么样的变量值是没有存在价值的呢???

单从逻辑层面分析,我们定义变量将变量值存起来的目的是为了以后取出来使用,而取得变量值需要通过其他绑定的直接引用(如times = 1010times直接引用)或间接引用(如lis = [num, ] num = 100100num直接引用,而被容器类型lis间接引用),所以当一个变量值不再绑定任何引用时,我们就无法再访问到该变量值了,该变量值自然就是没有用的,就应该被当成一个垃圾来回收。

毋庸置疑的是,内存空间的申请与回收都是非常耗费资源的事情,而且存在很大的危险性,稍有不慎就有可能引发内存溢出的问题,因此CPython解释器就提供了一种自动回收垃圾的机制来帮助我们解决这个烦恼。

3 什么是垃圾回收机制

所谓的垃圾回收机制(简称GC),是Python解释器自带的一种内存管理机制,它专门用来回收没有存在价值的变量值所占用的内存空间

4 为什么要有垃圾回收机制

因为在程序的运行过程中会申请大量的内存空间,然而对于一些没用的内存空间如果不及时清理,最终就会导致内存溢出,进而导致程序崩溃、系统宕机。因此,内存管理是一件很重要且繁杂的事情,那么较好的是,Python解释器自带的垃圾回收机制把开发人员从繁杂的内存管理中解放出来了,真的非常感谢Python。

5 垃圾回收机制(GC)原理的前序知识

5.1 栈区与堆区

在定义变量的时候,变量名与变量值都是需要存储的,分别对应内存中的两块区域:栈区堆区

  1. 变量名与变量值的内存地址的关联关系存放于栈区
  2. 变量存放于堆区中,内存管理回收的就是堆区的空间

那么我们在定义两个变量num_1 = 100num_2 = 200的时候,在计算机的内存中究竟发生了什么事情呢?如下图所示:

当我们执行num_1 = num_2的时候,内存中又发生了什么事情呢?如下图所示:

5.2 直接引用与间接引用

  • 直接引用指的是从栈区出发直接引用到的内存地址
  • 间接引用指的是从栈区出发引用到堆区后,再通过进一步的引用才能到达的内存地址

比如说:

那么他们在内存中发生了什么事情呢?如下图所示:

6 垃圾回收机制(GC)原理之引用计数

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

6.1 什么是引用计数

所谓的引用计数就是:变量值被变量名关联的次数

比如说:user_name = 'Tim Cook',这一行代码执行之后,变量值'Tim Cook'被关联了1个变量名user_name,那么我们就称之为引用计数为1

6.1.1 引用计数增加

user_name = 'Tim Cook'(变量值'Tim Cook'的引用计数为 1)

name = user_name(把变量名user_name的内存地址给了name,此时user_namename都关联了'Tim Cook',所以变量值'Tim Cook'的引用计数变为了 2)

6.1.2 引用计数减少

name = 'Apple'(变量名name先与变量值'Tim Cook'解除关联,然后与'Apple'建立了关联,变量值'Tim Cook'的引用计数变成了 1)

del user_name(del的含义是解除变量名user_name与变量值'Tim Cook'的关联,此时变量值'Tim Cook的引用计数变成了 0)

变量值'Tim Cook'的引用计数一旦变为 0,其占用的内存空间就会被Python解释器的垃圾回收机制回收掉。

6.2 引用计数的问题与解决方案

6.2.1 问题一:循环引用

引用计数机制存在着一个致命的弱点,即循环引用(又称之为交叉引用),如下代码所示:


那么以上这种情况,循环引用,变量值不再被任何变量名关联,但是变量值的引用计数并不为0,应该被回收的空间却不能被回收,什么意思呢?试想一下,请看如下操作:

这种情况下,变量名lis1lis2已经解除了与lis1lis2的关联

注意观察一下,由于lis1lis2之间相互引用,此时两个列表的引用计数均不为0,但是两个列表不再被任何其他对象关联,没有任何人可以再次访问到这两个列表,那么理论上这两个列表所占用的内存空间应该被回收,但是由于两个列表之间有相互引用,导致引用计数不为0,因此这些对象所占用的内存空间永远不会被释放,所以说,循环引用是致命的,循环引用比较于手动进行内存管理所产生的内存泄漏毫无区别。所以Python引入了标记-清除分代回收来分别解决引用计数的循环引用问题与效率低的问题。

6.2.2 解决方案:标记-清除

容器对象,比如:list、tuple、set、dict、class、instance都可以包含对其他对象的引用,所以都可能产生循环引用。然而标记-清除就是为了解决循环引用的问题

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

  • 标记

直白地讲,栈区相当于“根”,凡是从“根”(栈区)出发可以访达(直接或间接引用)的,都称之为“有根之人”,有根之人当活,无根之人当☠️

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

  • 清除

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

基于上述例子的循环引用,当我们同时删除lis1lis2的时候,会清理掉栈区中的lis1lis2的内容以及直接引用关系

这样一来,在启用标记-清除算法的时候,从栈区出发,没有任何一条直接或者间接引用可以访达lis1lis2,也就是lis1lis2成为了无根之人,于是lis1lis2都没有被标记为存活,这样一来,二者就会被清理掉,那么就解决了循环引用带来的内存溢出问题。

6.2.3 问题二:效率问题

引用计数除了具有循环引用带来的内存溢出的问题,还有效率问题

基于引用计数的回收机制,每次回收内存,都需要把所有对象的引用计数全部都遍历一遍,这是非常消耗时间的,于是便引入了分代回收来提高回收效率,分代回收采用的是用空间换取时间的策略。

6.2.4 解决方案:分代回收

  • 分代

分代回收的核心思想是:在历经多次扫描的情况下,都没有被回收的那个变量,GC机制就会认为这个变量是常用的变量,针对于常用的变量,GC机制对其扫描的频率便会下降,具体的实现原理如下所示:

  • 回收

回收依然是使用引用计数作为回收的依据

虽然分代回收可以起到提升效率的效果,但是也存在一定的缺点,例如:一个变量刚从新生代移动到青春代,该变量的绑定关系就被解除了,该变量理论上应该被回收,但是由于青春代的扫描频率低于新生代的扫描频率,这就导致了应该被回收的垃圾没有得到及时的清理。

所以我们知道了,没有十全十美的方案,毋庸置疑,如果没有分代回收,GC机制一直不停的对所有对象进行扫描,可以更加及时的清理掉垃圾占用的内存空间,但是,这种一直不停的对所有对象进行扫描的方式效率极低,所以我们只能将二者中和。

6.3 阶段总结

垃圾回收机制是在清理垃圾以及释放内存的大背景下,允许分代回收以及小部分垃圾不会被及时释放为代价,以此换取引用计数整体扫描频率的降低,从而提升性能,这是一种以空间换取时间的解决方案。

推荐阅读