为什么在python for循环中计算临时变量会占用大量内存?

Sin*_*inh 4 python cpython

以下两个代码是等效的,但是第一个大约占用700M内存,后一个仅占用约100M内存(通过Windows任务管理器)。这里会发生什么?

def a():
    lst = []
    for i in range(10**7):
        t = "a"
        t = t * 2
        lst.append(t)
    return lst

_ = a()
Run Code Online (Sandbox Code Playgroud)
def a():
    lst = []
    for i in range(10**7):
        t = "a" * 2
        lst.append(t)
    return lst

_ = a()
Run Code Online (Sandbox Code Playgroud)

ead*_*ead 5

@vurmux提出了使用不同内存的正确原因:字符串实习,但似乎缺少一些重要的细节。

CPython实现在编译期间会实习一些字符串,例如"a"*2-有关如何/为什么"a"*2实习的更多信息,请参见此SO-post

澄清:正如@MartijnPieters在他的评论中正确指出的:重要的是编译器是否进行常量折叠(例如,求两个常量的乘积"a"*2)。如果完成了常量折叠,则将使用所得常量,并且列表中的所有元素将引用同一对象,否则不引用。即使所有字符串常量都被求和(因此进行了常量折叠=>字符串被求和),也很难说intern:这里的关键是常量折叠,因为它也解释了根本没有interning的类型的行为,例如浮点数(如果使用t=42*2.0)。

是否经常发生折叠,可以使用dis-module 轻松验证(我称您为第二个版本a2()):

>>> import dis
>>> dis.dis(a2)
  ...
  4          18 LOAD_CONST               2 ('aa')
             20 STORE_FAST               2 (t)
  ...
Run Code Online (Sandbox Code Playgroud)

如我们所见,在运行时不会执行乘法运算,而是直接加载乘法运算的结果(在编译时计算)-结果列表包含对同一对象的引用(常量加载18 LOAD_CONST 2):

>>> len({id(s) for s in a2()})
1
Run Code Online (Sandbox Code Playgroud)

在那里,每个引用仅需要8个字节,这意味着需要大约80Mb(+列表的过度分配+解释程序所需的内存)内存。

在Python3.7中,如果生成的字符串包含4096个以上的字符,则不会执行常量折叠,因此将替换"a"*2"a"*4097会导致以下字节码:

 >>> dis.dis(a1)
 ...
  4          18 LOAD_CONST               2 ('a')
             20 LOAD_CONST               3 (4097)
             22 BINARY_MULTIPLY
             24 STORE_FAST               2 (t)
 ...
Run Code Online (Sandbox Code Playgroud)

现在,不预先计算乘法,结果字符串中的引用将是不同的对象。

优化器还不够智能,无法识别,t实际上"a"位于中t=t*2,否则它将能够执行常量折叠,但是目前为止,第一个版本(我称之为a2())的结果字节码:

... 5 22 LOAD_CONST 3(2)24 LOAD_FAST 2(t)26 BINARY_MULTIPLY 28 STORE_FAST 2(t)...

它将返回一个列表,其中包含10^7不同的对象(但所有对象均相等):

>>> len({id(s) for s in a1()})
10000000
Run Code Online (Sandbox Code Playgroud)

也就是说,每个字符串大约需要56个字节(sys.getsizeof返回51,但是由于pymalloc-memory-allocator是8字节对齐的,因此将浪费5个字节)+每个引用8个字节(假设64bit-CPython-version),因此大约为610Mb (+列表的过度分配+解释器所需的内存)。


您可以通过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
Run Code Online (Sandbox Code Playgroud)

确实,我们现在不仅可以看到需要的内存更少,而且列表还具有对同一对象的引用(可在此处在线查看其较小的size(10^5)):

>>> len({id(s) for s in a1_interned()})
1
>>> all((s=="aa" for s in a1_interned())
True
Run Code Online (Sandbox Code Playgroud)

字符串实习可以节省大量内存,但是有时很难理解字符串是否要实习。调用sys.intern明确消除了这种不确定性。


所引用的其他临时对象的存在t不是问题:CPython使用引用计数进行内存管理,因此,只要没有对它的引用,就立即删除该对象-无需垃圾收集器进行任何交互,而垃圾收集器仅在CPython中使用分解周期(这与Java的GC不同,因为Java不使用引用计数)。因此,临时变量实际上是临时变量-无法累积这些对象以对内存使用产生任何影响。

临时变量的问题t仅在于它阻止了编译期间的窥孔优化,该优化是针对"a"*2而不是针对进行的t*2