@vurmux 提出了不同内存使用的正确原因:字符串驻留,但似乎缺少一些重要的细节。
CPython 实现在编译期间实习一些字符串,例如"a"*2
- 有关如何/为什么的更多信息"a"*2
被实习看到这个SO-post https://stackoverflow.com/q/55643933/5769463.
澄清:正如 @MartijnPieters 在他的评论中正确指出的那样:重要的是编译器是否进行常量折叠(例如计算两个常量的乘法)"a"*2
) 或不。如果完成常量折叠,则将使用生成的常量,并且列表中的所有元素将引用同一对象,否则不会。即使所有字符串常量都被实习(因此执行常量折叠 => 字符串实习) - 谈论实习仍然很草率:常量折叠是这里的关键,因为它也解释了根本没有实习的类型的行为,例如浮点数(如果我们使用t=42*2.0
).
是否发生了常量折叠,可以很容易地验证dis
-module(我称你的第二个版本a2()
):
>>> import dis
>>> dis.dis(a2)
...
4 18 LOAD_CONST 2 ('aa')
20 STORE_FAST 2 (t)
...
正如我们所看到的,在运行时不执行乘法,而是直接加载乘法的结果(在编译器期间计算) - 结果列表包含对同一对象的引用(用18 LOAD_CONST 2
):
>>> len({id(s) for s in a2()})
1
在那里,每个引用只需要 8 个字节,这意味着大约80
Mb(+列表的过度分配+解释器所需的内存)所需的内存。
在Python3.7中,如果结果字符串超过4096个字符,则不会执行常量折叠,因此替换"a"*2
with "a"*4097
导致以下字节码:
>>> dis.dis(a1)
...
4 18 LOAD_CONST 2 ('a')
20 LOAD_CONST 3 (4097)
22 BINARY_MULTIPLY
24 STORE_FAST 2 (t)
...
现在,乘法不是预先计算的,结果字符串中的引用将是不同对象的。
优化器还不够聪明,无法识别出t
实际上是"a"
in t=t*2
,否则它将能够执行常量折叠,但现在第一个版本的字节码(我称之为a2()
):
...
5 22 LOAD_CONST 3 (2)
24 LOAD_FAST 2 (t)
26 二进制乘法
28 STORE_FAST 2 (t)
...
它会返回一个列表10^7
里面有不同的对象(但所有对象都是相等的):
>>> len({id(s) for s in a1()})
10000000
即每个字符串需要大约 56 个字节(sys.getsizeof
返回 51,但因为 pymalloc-内存分配器是 8 字节对齐的,所以会浪费 5 个字节)+每个引用 8 个字节(假设 64 位 CPython 版本),因此大约610
Mb(+列表的过度分配+解释器所需的内存)。
您可以通过以下方式强制字符串的驻留sys.intern https://docs.python.org/3/library/sys.html#sys.intern:
import sys
def a1_interned():
lst = []
for i in range(10**7):
t = "a"
t = t * 2
# here ensure, that the string-object gets interned
# returned value is the interned version
t = sys.intern(t)
lst.append(t)
return lst
实际上,我们现在不仅可以看到需要更少的内存,而且列表还引用了同一对象(在线查看稍小的大小(10^5
) here https://ideone.com/0mqYyv):
>>> len({id(s) for s in a1_interned()})
1
>>> all((s=="aa" for s in a1_interned())
True
字符串驻留可以节省大量内存,但有时很难理解字符串是否/为什么被驻留。呼唤sys.intern
明确消除了这种不确定性。
存在引用的附加临时对象t
不是问题:CPython 使用引用计数进行内存管理,因此一旦没有对对象的引用,对象就会被删除 - 无需与垃圾收集器进行任何交互,垃圾收集器在 CPython 中仅用于分解循环(即与 Java 的 GC 不同,因为 Java 不使用引用计数)。因此,临时变量实际上是临时的 - 这些对象无法累积以对内存使用产生任何影响。
临时变量的问题t
只是它阻止了编译过程中的窥视孔优化,这是为"a"*2
但不是为了t*2
.