python学习分享之垃圾回收-缓存机制

2023年2月7日10:56:21

大家好,我是小眼睛优粥面,上一篇文章和大家简单聊了一聊python垃圾回收中的 “标记清除” 和 “分代回收” 机制,我们用几句话简单总结一下前面我们介绍的内容。

在python内部中自己维护了一个refchain双向环状链表,我们使用程序创造的所有对象都会存到这个链表中,而每一个对象都记录着一个引用计数器的值(ob_refcnt),对象被引用时引用计数器+1,删除引用时引用计数器-1,最后当引用计数器的值变为0的时候,则启动垃圾回收机制,即从双向链表中将该对象删除。但是在python中对于那些有多个元素组成的对象存在循环引用的问题,为了解决这个问题python又引用了标记清除和分代回收机制。但是如果就这样完事了,那岂不是太简单,python源码内部在上述的流程中还做了一些优化,那就是:缓存机制。今天我们就来聊一聊这块的内容。

欢迎大家交流分享(码字不易,希望大家标明出处),有不对的地方请大家指正,也希望大家关注我的微信公众号 “记不住先生和忘不了小姐”,里面不光有 “记不住” 的技术还有那 “忘不了” 的情怀,万分感谢啦^ ^


Python中缓存机制主要分为两大类,分别是 “缓存机制” 和 “free_list机制”,缓存机制有可细分为:小数据池和驻留机制,我们详细来看一下。

1. 小数据池(small_ints 和 unicode_latin1)

所谓小数据池,就是为了避免重复创造和销毁一些常见对象,python在其内部会自己维护一个缓存资源的操作,我们看这么一个代码(python版本3.7.6)。

Python 3.7.6 (tags/v3.7.6:43364a7ae0, Dec 19 2019, 00:42:30) [MSC v.1916 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> p1 = 1
>>> p2 = 1
>>> print(id(p1))
140734330597648
>>> print(id(p2))
140734330597648

>>> p3 = 257
>>> p4 = 257
>>> print(id(p3))
559876634128
>>> print(id(p4))
559876633520

我们都知道,正常情况下,如果我们运行python程序的时候是先会先向内存去申请一块内存空间,用来存储我们的数据。每次变量的赋值理论上都会开辟新的内存空间,而程序在内存中存储数据是按照空间连续去存储的,所以按道理变量p1和变量p2应该指向不同的内存地址,但我们这上面为什么是同一个呢。

这是因为python为了节省资源,避免重复创建和销毁一些常见的对象,它在启动解释器的时候自动创建了一些默认的 “缓存池”,对于部分小整型和字符型执行缓存机制。即将这些对象进行预先创建,不会为相同的对象分配多个内存空间,这些数据包括:

在解释器启动的时候,python会自动创建以下小数据池,如果是不同代码块中的两个变量,并在此范围内,那么它们具有相同的id值,即重复使用这个范围的内容时,不会重新开辟内存空间,具体内容如下:

(1)small_ints链表:存储 -5,-4,-3,...,256 范围内(-5 <= value < 257)的整数。

(2)unicode_latin1[256]链表:存储所有的 ascii 字符,以后使用时就不再反复创建。

(3)布尔型满足小数据池

注意这块提到的一个注意事项(坑),那就是 “不同代码块中的” ,那么什么是不同的代码块呢,这块很重要。

2. 代码块

我们继续讲剩下的缓存机制之前,插入一下这个概念。我们先看一下官方的解释。

A Python program is constructed from code blocks. A block is a piece of Python program text that is executed as a unit. The following are blocks: a module, a function body, and a class definition. Each command typed interactively is a block. A script file (a file given as standard input to the interpreter or specified as a command line argument to the interpreter) is a code block. A script command (a command specified on the interpreter command line with the ‘-c‘ option) is a code block. The string argument passed to the built-in functions eval() and exec() is a code block.

A code block is executed in an execution frame. A frame contains some administrative information (used for debugging) and determines where and how execution continues after the code block’s execution has completed.

以上是对于代码块 python 官方文档的解释。也就是说,Python 程序是由代码块构造的,其中块是一个 python 程序的文本,作为一个程序执行的的基本单元。

代码块主要含义:“一个模块,一个函数,一个类,一个文件” 中的代码都属于同一个代码块。而作为交互式方式(命令行进入解释器操作)输入的每个命令都是一个代码块。这也就很好的解释了为什么我们在 “交互式命令行” 、 “xxx.py文件” 、 “pycharm中” 中运行以上同样的代码结果不一致的问题(图一p3和p4内存地址不一致,图二p3和p4内存地址一致),如下图所示:

交互式方式运行
pycharm文件运行

因为通过命令行进入Python解释器里面,每一行代码都是一个代码块,而在pycharm下一个文件下就是一个代码块。但是对于一个文件中的两个函数,分别是两个不同的代码块,比如这样:

def func01():
    p1=257
    print(id(p1))

def func02():
    p2=257
    print(id(p2))

# 其中,func01和func02为两个代码块
func01()  # 574636218224
func02()  # 574637180400

3.驻留机制

python的驻留机制是python系统内部维护了一个叫 “interned” 的字典,当允许被驻留的数据首次创建时,会被记录到该字典中,如果在 “同一个代码块中(注意)” 该数据被删除或者被其他对象再次引用时,不开辟新的内存空间,而是直接指向驻留空间中的内存地址,这样可以大量的节省内存空间。

注意一点的是:本人发现这个机制在python的不同版本中还不一致,在 python3.x 中的某些版本中该内容做出了调整,但在 python2.x 中仍保留了该部分,所以说大家可能看一些其他人的博客介绍驻留或缓存机制的时候,有一些代码在python3中运行结果跟他们写的不一致,这问题也困扰了许久,导致这篇博客推迟了很久才发出来,本文我们会对对我们的想法进行验证,同时后期会找寻一些官方的资料进行佐证。首先,我们具体看一下哪些数据在python2.x中会被驻留(这里使用python版本为2.7.6)。

(1)整型、浮点型,所有整型和浮点型在目前python的所有版本中均满足驻留机制

# 所有整型均满足驻留机制
a = 8888888888888888888888888888
b = 8888888888888888888888888888
print(id(a))  # 44311456
print(id(b))  # 44311456
print(a is b)  # True
# 所有浮点型均满足驻留机制
a = -0.568888888888888888888888888888
b = -0.568888888888888888888888888888
print(id(a))  # 39583696
print(id(b))  # 39583696
print(a is b)  # True

(2)布尔型,在 Python2 中是没有布尔型的,它用数字 0表示 False,用 1 表示 True。到 Python3.x 中,把 True 和 False 定义成关键字了,但它们的值还是 1 和 0。但无论是2.x还是3.x均满足驻留机制。

# 布尔型满足驻留机制
a = True
b = True
print(id(a))  # 505513920
print(id(b))  # 505513920
print(a is b)  # True

(3)字符型,字符串的驻留机制比较复杂,而且对于python不同版本还有所不同,我们拿python2.7.5和python3.7.4做一个比较。

  • 单一字符串,满足驻留机制
# 单一字符串,在python2.7.5和3.7.4版本均满足驻留机制
a = "This_is_string_111111111111111111111"
b = "This_is_string_111111111111111111111"
print(id(a))  # 44245856
print(id(b))  # 44245856
print(a is b)  # True
  • 乘法得到的字符串,在python2.7.5中,运行结果如下:
# -*- coding: utf-8 -*-
# 非乘法得到的字符串,都满足代码块驻留机制
a = "这是一个非乘法等到的字符串,都满足代码块驻留机制!"
b = "这是一个非乘法等到的字符串,都满足代码块驻留机制!"
print(id(a))  # 41011888
print(id(b))  # 41011888
print(a is b)  # True

# 乘数为1时,任何字符串都满足代码块驻留机制
a = "乘数为1时,任何字符串满足代码块的驻留机制。" * 1
b = "乘数为1时,任何字符串满足代码块的驻留机制。" * 1
print(id(a))  # 48850176
print(id(b))  # 48850176
print(a is b)  # True

# 乘数>=2时:仅含大小写字母,数字,下划线,且总长度<=20,满足代码块驻留机制
a = "ab_32" * 4
b = "ab_32" * 4
print(id(a))  # 41927040
print(id(b))  # 41927040
print(a is b)  # True
c = "ab_32" * 5
d = "ab_32" * 5
print(id(c))  # 51417392
print(id(d))  # 51987120
print(c is d)  # False
  • 而以上代码在 python3.7.4 中运行却得到了不同的结果:
# 非乘法得到的字符串,都满足代码块驻留机制
a = "这是一个非乘法等到的字符串,都满足代码块缓存机制!"
b = "这是一个非乘法等到的字符串,都满足代码块缓存机制!"
print(id(a))  # 1867641922480
print(id(b))  # 1867641922480
print(a is b)  # True

# 乘数为1时,任何字符串都满足代码块驻留机制
a = "乘数为1时,任何字符串满足代码块的缓存机制。" * 1
b = "乘数为1时,任何字符串满足代码块的缓存机制。" * 1
print(id(a))  # 1867642068016
print(id(b))  # 1867642068016
print(a is b)  # True

# 乘数>=2时:任何字符串都满足代码块驻留机制
a = "ab_32" * 4
b = "ab_32" * 4
print(id(a))  # 1867639964816
print(id(b))  # 1867639964816
print(a is b)  # True
c = "ab_32" * 5
d = "ab_32" * 5
print(id(c))  # 1867642328448
print(id(d))  # 1867642328448
print(c is d)  # True

我们可以看到在python3.7.4中,所有的字符串均保留了驻留机制。

4. free_list机制

在我们之前介绍的内容中,当我们引用计数器为0时,应该启用垃圾回收机制回收该内存空间,但实际上python并没有直接回收,“在不同的代码块中(注意)” 而是将对象添加到一个叫 “free_list” 的列表中作为缓存,这个机制就叫做 “free_list机制”。这是做什么呢?这就是为了在以后再创建对象时,不再重新开辟内存,而是使用 free_list 中的内存空间。我们看一段代码:

Python 3.7.4 (tags/v3.7.4:e09359112e, Jul  8 2019, 20:34:20) [MSC v.1916 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> a=9.99
>>> print(id(a))
1699375448816
>>> del a
>>> b=8.88888888
>>> print(id(b))
1699375448816

变量a被删除,该块的内存地址并没有被直接回收,而是自动添加到了 free_list 列表中,当b对象被创建时,直接使用该内存地址,无需开辟新的内存空间,这样做可以大大提高内存的使用效率。需要注意的是:python的这种缓存机制只适用于float/list/tuple/dict四种数据类型,但针对每一种数据类型它的 free_list 存储的机制和容量稍有不同,我们详细看一下。

(1)float类型,维护的free_list链表最多可缓存100个float对象,超过则释放之前的内存空间。

(2)list类型,维护的free_list数组最多可缓存80个list对象,超过则释放之前的内存空间。

Python 3.7.4 (tags/v3.7.4:e09359112e, Jul  8 2019, 20:34:20) [MSC v.1916 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> a = [1,2,3,4,5]
>>> print(id(a))
173932106760
>>> del a
>>> b = ["jeeven","name","student"]
>>> print(id(b))
173932106760

(3)tuple类型,维护一个free_list数组且数组容量20,元组的free_list存储的机制稍有不同,我们看一个 “栗子”。

Python 3.7.4 (tags/v3.7.4:e09359112e, Jul  8 2019, 20:34:20) [MSC v.1916 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> a=(1,2,3,4,5)
>>> print(id(a))
458961967336
>>> del a
>>> b=("jeeven",2)
>>> print(id(b))
458965331144
>>> c=(1,2)
>>> print(id(c))
458965272328
>>> del c
>>> d=("jeeven",2)
>>> print(id(d))
458965272328

在上面的代码中,a和b并没有按照free_list的机制去存储,这是因为元组维护的free_list是按照元素个数来的,每个索引单独维护了一个数据链表,如下图所示。每个链表最多可以容纳2000个元组对象,所以说上述代码中含2个元素的c和d是存储在索引为2的数据链表中的,而a和b存储的是不同索引的链表中,所以内存地址不一致。

元组free_list数据链表

(4)dict类型,维护的free_list数组最多可缓存80个dict对象。

Python 3.7.4 (tags/v3.7.4:e09359112e, Jul  8 2019, 20:34:20) [MSC v.1916 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> a={"a":1, "b":2}
>>> print(id(a))
458961852440
>>> del a
>>> b={"c":1, "d":2, "e":3}
>>> print(id(b))
458961852440

好,以上就是关于 “python垃圾回收” 的所有内容,“缓存机制” 这块稍微有些混乱的,是因为不同的python版本和不同的代码块可能会给学习这部分同学内容造成困扰。所以可能当你看到这篇文章的时候,python的版本已经更新,例子运行和上面的结果不一致你也不必困扰,毕竟内存回收和内存分配,在python内部已经完全为我们自动化的去处理了所有的事情,在使用上你大可不必关心这些。而我们了解到这些原理和机制的话,希望可以对我们做开发、设计或有在使用python时更深入的理解,我想目的就达到了。下一个系列我想跟大家聊一聊python中各种数据类型在内存中的存储形式的,尽请期待。

“闻道有先后,术业有专攻。每个人都有自己精通的领域,同样也有自己的不足,有缺憾,就有可改之处,也就有进步的空间。尽力发挥自己的长处,不断完善自己的短处,每天稍有进步,日积月累终会有所大不同。” 又是新的一天,早安,我是小眼睛优粥面,欢迎大家交流分享,并指正其中的错误,万分感谢。

  • 作者:小眼睛优粥面
  • 原文链接:https://blog.csdn.net/u012864245/article/details/113114902
    更新时间:2023年2月7日10:56:21 ,共 7146 字。