Python 中的缓存重用机制(常量池)

为了减少高频使用数据创建时申请内存和销毁时撤销内存的开销,Python 中引入了 Python 缓存重用机制(常量池)以提高程序执行的效率。

何为缓存重用?

Python 缓存重用机制,实际上就是 >>>> 在 Python 解释器启动时就从内存空间中开辟出一小部分(常量池),专门用来存储高频使用的数据,以大大减少高频使用数据创建时申请内存和销毁时撤销内存的开销。

Python 在存储数据时,会根据数据的读取频繁程度以及内存占用情况来考虑,是否按照一定的规则将数据存储在缓存中,这些缓存中的数据是不会被 GC(Garbage Collector)回收的。


那么问题来了,内存重用机制适用于哪些基本数据类型呢?或者说哪些基本数据类型的常量会被存放到常量池中呢?

缓存哪些数据类型?

Python 是否将指定数据存入缓存(常量池)中的规则:

数据类型 是否可以重用 生效范围 备注
范围在 [-5, 256] 之间的小整数 如果之前在程序中创建过,就直接存入缓存,后续不再创建 全局 小整数池
bool 类型 如果之前在程序中创建过,就直接存入缓存,后续不再创建 全局 bool 常量池
字符串类型数据(需符合命名规则 ) 如果之前在程序中创建过,就直接存入缓存,后续不再创建 全局 字符串常量池
大于 256 的整数 Or 大于 0 的浮点型小数 只要在本代码块内创建过,就直接缓存,后续不再创建 本代码块
小于 0 的浮点型小数 Or 小于 -5 的整数 不进行缓存,每次都需要额外创建 不使用常量池

PS >>>> 一般来说,Python 常量池小且有限,只会存放少量的数据对象。事实上,常量池的范围还和解释器以及编译器有关,例如在 Pycharm、VSCode 等 IED 中,大整数和较长、带有特殊字符的字符串也存在常量池。


实验验证

实验展示缓存重用机制(常量池机制) >>>> 通过一段程序来演示 Python 缓存重用机制的规则:

代码中,id() 内置函数可以用于获取变量指向数据对象的内存地址。is:比较的是 两个实例对象是不是完全相同是不是同一个对象(占用的内存地址是否相同,内容相同)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# 范围在 [-5, 256] 之间的小整数
int1 = -5
int2 = -5
print("{[-5, 256] 情况下的两个变量}:", id(int1), id(int2), int1 is int2)
# bool类型
bool1 = True
bool2 = True
print("{bool 类型情况下的两个变量}:", id(bool1), id(bool2), bool1 is bool2)
# 对于字符串
s1 = "3344"
s2 = "3344"
print("{字符串情况下的两个变量}", id(s1), id(s2), s1 is s2)

# 大于 256 的整数
int3 = 9527
int4 = 9527
print("{大于 256 的整数情况下的两个变量}", id(int3), id(int4), int3 is int4)
# 大于 0 的浮点数
f1 = 9527.0
f2 = 9527.0
print("{大于 0 的浮点数情况下的两个变量}", id(f1), id(f2), f1 is f2)

# 小于 0 的浮点数
f3 = -2.45
f4 = -2.45
print("{小于 0 的浮点数情况下的两个变量}", id(f3), id(f4), f3 is f4)
# 小于 -5 的整数
n1 = -6
n2 = -6
print("{小于 -5 的整数情况下的两个变量}", id(n1), id(n2), n1 is n2)

运行该程序,其输出结果为:

1
2
3
4
5
6
7
{[-5, 256] 情况下的两个变量}: 1801941456 1801941456 True
{bool 类型情况下的两个变量}: 1801461008 1801461008 True
{字符串情况下的两个变量} 2335217145592 2335217145592 True
{大于 256 的整数情况下的两个变量} 2335216742288 2335216742288 True
{大于 0 的浮点数情况下的两个变量} 2335210240160 2335210240160 True
{小于 0 的浮点数情况下的两个变量} 2335210240112 2335210240136 False
{小于 -5 的整数情况下的两个变量} 2335216742352 2335216742160 False

以上输出结果中,每行都输出了 2 个相对应的变量所指向的内存地址,如果相等,则表明 Python 内部对其使用了缓存机制,反之则没有。可对照以上输出结果来理解 【数据存入缓存(常量池)规则】 中具体说明。


神奇的 9527

对于 【数据存入缓存(常量池)规则】 中所提到的代码块,Python 中的函数和类都被认为是在程序中开辟了一块新的代码块。以函数为例,函数内部的代码分属一个代码块,函数外部的代码属于另一个代码块。

Python 缓存机制在不同的代码块中也会有不同的表现。在上面例子代码的基础上,继续编写如下程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
def fun():
# [-5,256]
int1 = -5
print("{fun 中 -5 的存储状态}",id(int1), id(int2), int1 is int2)
# bool类型
bool3 = True
print("{fun 中 bool 类型的存储状态}", id(bool3), id(bool2), bool3 is bool3)
# 字符串类型
s1 = "3344"
print("{fun 中 3344 字符串的存储状态}", id(s1), id(s2), s1 is s2)

# 大于 256
int3 = 9527
print("{fun 中 9527 的存储状态}", id(int3), id(int4), int3 is int4)
# 浮点类型
f1 = 9527.0
print("{fun 中 256.4 的存储状态}",id(f1), id(f2), f1 is f2)

# 小于 -5
n1 = -6
print("{fun 中 -6 的存储状态}", id(n1), id(n2), n1 is n2)

fun()

输出结果为:

1
2
3
4
5
6
{fun 中 -5 的存储状态} 140403724199328 140403724199328 True
{fun 中 bool 类型的存储状态} 140403724013120 140403724013120 True
{fun 中 3344 字符串的存储状态} 140403700614512 140403700614512 True
{fun 中 9527 的存储状态} 140403702968720 140403704573872 False
{fun 中 9527.0 的存储状态} 140403704701576 140403704701216 False
{fun 中 -6 的存储状态} 140403702970768 140403702970800 False

根据输出结果可以分析出:

  • 从 -5 、bool 类型以及字符串 “3344” 的输出结果可以得知,无论是在同一代码块,还是不同的代码块,它们都使用相同的缓存内容;
  • 从 9527 和 9527.0 的输出结果可以得知,如果位于同一代码块,则使用相同的缓存内容;反之,则不使用;
  • 从 -6 的输出结果得知,Python 没有对其缓存进行操作。

全流程验证 Python 解释器内部使用常量池机制

这里再来附一篇关于 全流程验证 Python 解释器内部使用常量池机制 的博文:

转载至 nickchen 的博文 >>>> 通过代码验证 Python 解释器内部使用了常量池

全文 + 个人理解如下:

变量的引入

[1] –> 为什么要有变量?

我们知道,Python 世界就可以看作现实世界的【镜像】,我们想要做到的就是在镜像世界里面高效地解决现实世界里的繁琐任务。这首先就要求 Python 【镜像世界】中必然存在与现实世界物体(研究对象)对应的抽象表达,比如现实世界中最常见的数字、文字、图像、声音等等,在 Python 世界统一被称为:【数据】,有了数据才有后续的一切。

计算机语言开发者们为了使用计算机语言的人更好的在计算机中去描述这些实物数据,便在计算机语言中引入了变量这个概念,Python 也不例外。简单点说,变量就是用来描述世间万物的。

[2] –> 定义变量

为了在计算机书写方便,定义一变量也有一定的规则,在这里我们仅说说Python中变量的定义规则,首先我们先定义两个变量:

1
2
name = "xuexi"
year = 2021

上述代码中我们便定义了两个变量,从上面定义的两个变量中,我们可以看到,变量的组成分为三个部分:

  1. 变量名:反应变量值所描述的意义,并且可以用来引用变量值;
  2. 赋值运算符:赋值;
  3. 变量值:存放数据,用来记录现实世界中的某种状态。

常量引入

上面简单讲解了 Python 中的变量,通过字面意思,可以看到变量其实是一个变化的量,例如,下面这个实例:

1
2
3
year = 2021
year = year + 1
print(year) # 输出结果:2022

刚开始我们赋予了 year 一个变量值为 2021,当我们对 year 进行加 1 操作时,可以发现 year 值变成了 2022 。对于上述现象我们不难理解,因为之前说过 Python 中变量是用来描述世间万物的,世间万物在现实中是可以变化的,变量当然也可以随之变化。

但是在某个局部范围内,变量可能是不会变化的,例如在 2021 年这一年,都只会是 2021 年,没有人会说 2021 年是 2022 年。如果你有丰富的开发经验,会明白变量定义出来不是存放在那里给你看的,更多的是要拿来用的。也就是说如果在 2021 年中的某个程序需要使用 year 这个变量,但这个变量是不需要进行修改的。为了防止误操作对 year 这个变量进行了修改,计算机语言便设计了常量这个概念,也就是说,常量相对于变量是一个不会变化的量。

在 Python 中,有没有常量呢?不严格的讲,其实是有的,只是在定义常量的时候常量名必须的全大写,例如,下面这个实例:

1
2
3
YEAR = 2021
YEAR = YEAR + 1
print(YEAR) # 输出结果:2022

上面这个常量的实例令人大吃一惊,因为使用常量 YEAR 后和使用变量 year 的结果一致,也就是说常量 YEAR 遭到了更改。但是,稍微解释你就明白了。

在 Python 中,虽然也和其他很多计算机语言一样拥有常量这个概念,但更多的是约定俗成的,Python 并没有严格的对常量进行控制,只是规定常量名必须全部大写。原因很简单:都是常量了,你为什么还要修改???


常量池引入

上面讲到常量就是一个不会变化的变量,严格的讲,在 Python 中是没有常量这个概念的。但是,在 Python 中又有另外一种例外,那就是常量池,为了搞清楚常量池,首先我们得弄明白 Python 的几个小知识,接下来一一叙说。

Python 解释器

上面提及到 Python 是计算机用来描述世间万物的一种语言,由于计算机没有人脑那么强大,计算机更多的只是认识高低压电频,再通过对高低压电频的转化进而编码成我们看到的一个又一个字符,也就是说计算机是无法直接认识利用 Python 写下的字符的(此处涉及计算机组成原理,不多做介绍)。

也就是说,当我们利用 Python 写下一个又一个字符并且交给电脑时,需要通过编码这个过程,而这个编码的过程有时候也被称为解释。解释的原理就相当于从中文转成英文,只不过此时不是需要让英文使用者看懂中文,而是让计算机能够看懂 Python。

中文转成英文的时候,可能需要一个翻译员或一个翻译软件,利用 Python 写下的字符转化为计算机能看懂的语言同样如此,这个转化过程也需要一个外物的帮助——Python 解释器。


Python 变量存储机制

假设我们使用 Python 解释器定义了以下一个变量:

1
year = 2021

现在假设江西师范大学相当于电脑内存,每当有一批新学生进入师大时,师大都会开辟出一个新教室给这批新同学使用,并且会给每一个教室一个独一无二的教室牌号。由于把师大看作是内存,这批新同学就可以看成是变量值,而教室牌号就是变量名。也就是说,对于师大这个大内存,每定义一个变量 year = 2021,就会在这个大内存中开辟一个小空间,小空间中放变量值 2021,然后大内存会给这个小空间定义一个变量名 year,此时变量名 year 指向变量值2021

上面说到每当 Python 解释器解释一个变量时,会将这个变量存放到内存中的一个小空间中,但如何知道这个小空间的具体位置呢?此处介绍 Python 的一个内置函数 id(),通过这个函数可以获取某一个变量所在的内存地址,例如下面这个实例:

1
2
year = 2021
print(id(year))

Python 垃圾回收机制

对于上述师大的例子,此处再做延伸。由于那一批学生所在班级新转来了几位同学,需要那一批学生更换更大一点教室,也就是给他们一个新的教室。那么学校应该会这样处理,首先开辟一个新的教室,然后拿下那一批学生原有教室的教室牌号更换到这个新教室,最后会清空原有教室。

在 Python 中,也是如此,如果到了新的一年,我们会重新定义一个 year 变量,也就是 year = 2022 。如果这是在同一个程序中如此做,Python 会沿用上述更换教室的方法,它首先会解除 year2021 的连接,开辟一个新内存存放变量值 2022 ,让 year2022 连接。此时,会发现 2021 这个变量值只有变量值而没有变量名,因此这个没有变量名的变量值会变成 Python 眼中的一个垃圾变量,从而触发 Python 垃圾回收机制(GC),对这个 2021 所在的内存空间进行回收。

为了更好地理解 Python 垃圾回收机制,可以看下面这个例子:

1
2
3
4
5
6
7
year = 2021
print(id(year)) # 输出 2867780266704
print(year) # 输出 2021

year = 2022
print(id(year)) # 输出 2867780267824
print(year) # 输出 2022

通过上述例子,可以看到当新定义了一个 year 变量时,year 会与新的变量进行一个连接。当然,此处所说的垃圾回收机制只是为了引入 引用计数 这个概念,并不是完全正确的解释,并且上述实例还无法证明变量值 2021 所在内存是否被回收,下面将通过引用计数的实例会进一步说明并重新解释垃圾回收机制。


引用计数

上面讲到如果某个变量值绑定着变量名,就是一个正常的变量,如果该变量值没有绑定着门牌号,这个变量就是一个垃圾变量,对于垃圾变量,Python 会触发垃圾回收机制回收这个变量所占有的内存。进而可以想到,Python 中一个变量名一定只能对应一个变量值。

在这里我们就不能沿用师大这个例子了,而得引出一个新的名词——引用计数。

为了解释引用计数,我们首先得明白在 Python 中,当定义了一个变量值为 2021 的变量时,它可以表示年份、也可以表示山的高度……也就是说一个变量名只能对应一个变量值,但是一个变量值可以对应不同的变量名,这种设计也是比较合理的。

现在我们引出 “引用计数” 这个概念,当相同的变量值被赋予不同的变量名时,变量值每增加一个变量名的赋予,则该变量值的引用计数加1。由于我们可以通过 Python 内置 sys 模块中的 getrefcount() 函数获取某一个变量的引用计数(getrefcount 输出值默认从 3 开始),可以通过下面这个例子感受下(命令行调用或 IDE 下运行):

1
2
3
4
5
6
7
8
9
10
11
12
13
import sys

# 引用计数初始值为 3
print(sys.getrefcount(2021)) # 输出为 3

year = 2021
print(sys.getrefcount(2021)) # 输出为 4

height = 2021
print(sys.getrefcount(2021)) # 输出为 5

del year
print(sys.getrefcount(2021)) # 输出为 4

从上述代码可以看出变量值 2021 的引用计数由于每一次赋予新的变量名,引用计数都会增加,而当我们利用 del 关键字删除变量值 2021 的一个变量名 year 时,引用计数则会减少。

为了更加严谨的表达引用计数,此处不得不再次深入,引用计数字面意思可以理解为引用的次数,也就是说上面的例子其实并不严谨,更严谨的讲,只有当一个变量值每一次被直接或间接引用时,引用计数才会增加,在 Python 中让引用计数增加共有三种方法:

  1. 变量被创建,变量值引用计数加 1
  2. 变量被引用,变量值引用计数加 1
  3. 变量作为参数传入到一个函数,变量值引用计数加 2

具体看下述实例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import sys

# 引用计数初始值为3
print(sys.getrefcount(2021)) # 输出为 3

# 变量被创建,变量值引用计数加 1
year = 2021
print(sys.getrefcount(2021)) # 输出为 4

# 变量被引用,变量值引用计数加1
height = year
print(sys.getrefcount(2021)) # 输出为 5

# 变量作为参数传入到一个函数,变量值引用计数加 2
def func(year):
print(sys.getrefcount(year))

func(year) # 输出为 7

Python 中既然有增加引用计数的方法, 也当然会减少引用计数的方法,共有以下 4 种:

  1. 变量值对应的变量名被销毁
  2. 变量值对应的变量名被赋予新的值
  3. 变量值对应的变量名离开它的作用域
  4. 变量值对应的变量名的容器被销毁

重新认识 Python垃圾回收机制

有了 getrefcount() 方法并通过引用计数,我们就可以解开垃圾回收机制遗留的一个问题——如何判断是否触发了垃圾回收机制。每当一个变量定义,他的 getrefcount 默认输出值为 3,而如果该变量值被垃圾回收机制回收,则它的 getrefcount 输出值回到 3,可以通过下面实例验证上述猜想:

1
2
3
4
5
6
7
8
9
10
11
12
13
import sys

print(sys.getrefcount(2021)) # 输出为 3

year = 2021
print(sys.getrefcount(2021)) # 输出为 4
print(id(year)) # 输出 4499932720
print(year) # 输出 2021

year = 2022
print(sys.getrefcount(2021)) # 输出为 3
print(id(year)) # 输出 4499932560
print(year) # 输出 2022

通过上述实例,可以发现由于变量值 2021 对应的变量名被新的变量值 2022 引用,它的 getrefcount 输出值为 3,引用计数变成了 0 ,因此可以证明 Python 触发了垃圾回收机制。

如果对上述验证 Python 触发垃圾回收机制的实例深入挖掘,会发现当把 year 赋给变量值 2022 时,变量值的 2021 的引用计数为 0,此时触发了 Python 的垃圾回收机制,那么是否可以表明只有当变量值 2021 的引用计数为 0 时才能触发垃圾回收机制呢?而不是上一次说的当变量值的变量名被新的变量值被引用了才会销毁呢?因为变量值可以对应多个变量名,下面通过下述实例验证:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import sys

print(sys.getrefcount(2021)) # 输出为 3

year = 2021
print(sys.getrefcount(2021)) # 输出为 4

height = 2021
print(sys.getrefcount(2021)) # 输出为 5

year = 2022
print(sys.getrefcount(2021)) # 输出为 4

del height
print(sys.getrefcount(2021)) # 输出为 3

通过上述实例,可以发现由于定义一个变量后,该变量对应的变量值引用计数可以不断增加,而只要引用计数不为 0,那么 Python 就一直还在内存中保留着这个变量值并且对其引用,只有当该变量的引用计数为 0 时,Python 才会触发垃圾回收机制对该变量值进行回收,这才是比较正确的垃圾回收机制。当然,如果深入,Python 的回收机制还有分代回收,此处不做延展,了解上述这些就足矣了解接下来讲的小整数池。


常量池

在上述各个知识的打通之后,现在可以正式引入常量池这个概念。上面讲到在 Python 中严格的讲是没有常量这个概念的,即使你通过约定俗成的方法定义了一个常量,但这个常量也只是一个变量,也就是说只要你对这个常量做出修改,这个常量原有对应的常量值引用计数就会变成 0,由于常量等同于变量,它一样会被 Python 垃圾回收机制回收。

但是在 Python 中,存在着一些例外,这些例外就是一个小整数池,顾名思义,小整数池表示的是从 -5256 范围内的整数,这些整数定义出来后就是一个常量,也就是说他们的引用计数即使为 0,也不会被 Python 的垃圾回收机制回收。

通过垃圾回收机制判断小整数池中的整数是否会被垃圾回收机制回收,可用如下实例证明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
>>> a = 5
>>> id(a)
4529334480
>>> del a
>>> b = 5
>>> id(b)
4529334480
>>>
>>> a = 257
>>> id(a)
4533920752
>>> del a
>>> b = 257 # 消除分代回收对结果的影响
>>> del b
>>> b = 257
>>> id(b)
4531031792
>>>

从上述实例中可以看出,变量值5即使被垃圾回收机制回收后,再次创建变量值为 5 的变量,该变量的内存地址始终无变化,即该变量未被垃圾回收机制回收,小整数池中的其他整数同理;而变量值 257 却已经被垃圾回收机制回收,非小整数池中的其他变量同理。

当然,还可以通过下述方法查看这些小整数池的整数的内存地址的变化,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
a = 256
b = int("256")
print(id(a), id(b)) # 4544968752 4544968752

a = 257
b = int("257")
print(id(a), id(b)) # 4548719792 4546289360

a = -5
b = int("-5")
print(id(a), id(b)) # 4544960400 4544960400

a = -6
b = int("-6")
print(id(a), id(b)) # 4690036912 4546289360

对于上述实例,在 Python 中,由于每生成一个变量便会开辟一个新的内存空间给该变量,但是上述实例表明当变量值为 -5256 时,每次开辟的内存空间地址都是一样的;而当变量值不属于 [-5, 256] 时,每次定义变量值时,内存空间的地址都是不一样的。


Author

Waldeinsamkeit

Posted on

2018-01-05

Updated on

2022-11-06

Licensed under

You need to set install_url to use ShareThis. Please set it in _config.yml.

Comments

You forgot to set the shortname for Disqus. Please set it in _config.yml.