Python 内存模型

11

我有一个非常大的列表。假设我这样做(是的,我知道这段代码非常不符合Pythonic,但为了举例子……):

n = (2**32)**2
for i in xrange(10**7)
  li[i] = n

运行良好。然而:

for i in xrange(10**7)
  li[i] = i**2

占用了显著更多的内存。我不明白为什么会这样——存储大数需要更多的位,在Java中,第二个选项确实更节省内存...

有人能解释一下这是为什么吗?


1
另外,我认为你可以通过将range()改为xrange()来减少内存使用。xrange使用迭代器而不是生成整个列表,因此使用更少的内存。(至少这是我理解的) - Mike Cooper
1
根据答案评论中的混淆,我不认为你说的是你的意思,@unknown -- 一个数组(导入数组,并使用array.array)不能容纳大于32位的整数;我想你是指列表--请编辑问题以澄清,因为这确实很重要!-) [使用array.array,您将不会观察到您描述的内存消耗行为等等]。 - Alex Martelli
1
在Python 3中,range()所做的与xrange()以前所做的相同,因此它取决于Python的版本。 - Kathy Van Stone
1
@Mike,虽然你是正确的,但这并不能解释内存使用上的差异 - Kenan Banks
4个回答

18

Java会对一些值类型(包括整数)进行特殊处理,使它们按值存储(而不像其他所有东西一样通过对象引用存储)。Python不会对这样的类型进行特殊处理,因此将n分配给列表(或其他普通Python容器)中的多个条目时,不必进行复制。

编辑:请注意,引用始终是指 对象 ,而不是“变量”——在Python(或Java)中没有“变量的引用”。例如:

>>> n = 23
>>> a = [n,n]
>>> print id(n), id(a[0]), id(a[1])
8402048 8402048 8402048
>>> n = 45
>>> print id(n), id(a[0]), id(a[1])
8401784 8402048 8402048
从第一个打印输出结果可以看出,列表 a 中的两个条目引用了与 n 引用相同的对象--但是当 n 被重新赋值后, 现在引用了一个不同的对象,而 a 中的两个条目仍然引用以前的那个对象。 array.array (来自 Python 标准库模块array)与列表非常不同:它保留了同一类型的紧凑拷贝,每个项目需要尽可能少的位数来存储该类型的值的拷贝。所有正常容器都会保留引用(在 C 编写的 Python 运行时内部实现为指向 PyObject 结构的指针:每个指针在 32 位构建上占用 4 字节,每个 PyObject 至少占用 16 或更多字节 [包括指向类型的指针,引用计数,实际值和 malloc 舍入]),而数组则不会保留引用(因此它们不能是异构的,不能有除少数基本类型之外的项目等)。
例如,如果一个包含 1000 个项目的容器中,所有项目都是不同的小整数(其值可以适合每个项目的 2 个字节),则将其作为 array.array('h') 处理需要约 2000 字节的数据,而作为一个列表需要约 20,000 个字节。但如果所有项目都是相同的数字,则数组仍需要 2000 个字节的数据,而列表只需要大约 20 个字节 [[在所有这些情况下,您都必须再添加大约 16 或 32 字节以用于容器对象本身,除了数据的内存]]。
然而,虽然问题中说“数组”(甚至在标记中),但我怀疑它的arr实际上并不是数组——如果是数组,它就不能存储(2**32)*2(数组中最大的整数值为 32 位),并且问题中报告的内存行为实际上不会被观察到。因此,问题实际上可能是关于列表而不是数组。 编辑:@ooboo 的评论提出了许多合理的后续问题,而不是试图在评论中压缩详细的解释,我将其搬到这里。

太奇怪了–毕竟,整数的引用是如何存储的?id(variable) 给出一个整数,该引用本身就是一个整数,使用整数是否更便宜?

CPython 将引用存储为指向 PyObject 的指针(使用 Java 和 C# 编写的 Jython 和 IronPython 使用这些语言的隐式引用;使用 Python 编写的 PyPy 具有非常灵活的后端和可以使用许多不同的策略)。 id(v) (仅适用于 CPython)给出指针的数字值(只是一种方便的方法来唯一地标识对象)。列表可以是异构的(某些项目可能是整数,其他对象是不同类型的),因此将某些项目存储为 PyObject 指针而将其他项目存储为不同的类型并不明智(每个对象还需要类型指示在 CPython 中,引用计数至少是这样)。- array.array 是同质且受限制的,因此它确实存储

5
当你创建了一个对象a并将名称n指向该对象时,它会存储对与n引用相同的对象的引用;然后你将名称n切换为指向另一个对象。它总是引用到对象本身(这就是我说“对象引用”的原因,你知道的!),永远不是引用到名称本身,你似乎对此混淆了(在Python或Java中没有引用到名称的概念,只有对对象的引用)。 - Alex Martelli
1
是的,我现在看到区别了。有趣。 - Kenan Banks
1
@Triptych:整数是不可变对象,您无法更改它们的值。变量“n”只是一个对象的标签(最初是一个不可变整数5,稍后是整数3)。 - S.Lott
我明白。不过很奇怪 - 毕竟,整数的引用是如何存储的呢?id(variable)返回一个整数,引用本身也是一个整数,使用整数不是更便宜吗?此外,如果我将2 ** 64分配给每个插槽而不是分配n,当n持有对2 ** 64的引用时,是否会有所不同?当我只写1时会发生什么? - ooboo
谢谢Alex。那是非常好的解释。不过,有一些关于字面值我没有理解。假设实现为我使用的每个字面值创建一个新的PyObject;它是否会检查是否已经存在相同的PyObject?如果我将相同的字面值添加到列表中100次,比分配n个100个插槽在列表中更昂贵吗? - ooboo
显示剩余6条评论

6
在你的第一个例子中,你将相同的整数存储了len(arr)次。因此,Python只需要在内存中存储一次整数并引用它len(arr)次。
在你的第二个例子中,你存储了len(arr)个不同的整数。现在,Python必须为len(arr)个整数分配存储空间,并在每个len(arr)个插槽中引用它们。

那么 Python 本质上将整数存储和引用如同对象一样吗? - Victor
Matthew,Python并不会“在每个元素中单独存储”该值。这不是C语言,而是Python。 - Lennart Regebro
1
@Matt - 我一开始支持你,但我们错了。在解释器中使用id()函数后,我明白了。 - Kenan Banks
感谢您的澄清,很抱歉我的解释不正确。 - Matthew Flaschen

3
你只有一个变量n,但你创建了许多i的平方。
发生的情况是Python使用引用。每次执行array[i] = n时,都会创建一个指向n值的新引用。请注意,不是指向变量,而是指向值。然而,在第二种情况下,当你执行array[i] = i**2时,你创建了一个新值,并引用这个新值。这当然会占用更多的内存。
实际上,即使重新计算,Python也会继续重复使用相同的值并仅使用对它的引用。因此,例如:
l = []
x = 2
for i in xrange(1000000):
    l.append(x*2)

通常不会使用比更多的内存

l = []
x = 2
for i in xrange(1000000):
    l.append(x)

然而,在这种情况下

l = []
x = 2
for i in xrange(1000000):
    l.append(i)

每个 i 的值都会得到一个引用并因此被保留在内存中,与其他示例相比占用大量内存。
(Alex 指出了术语上的一些混淆。在 Python 中有一个名为 array 的模块。这些类型的数组存储整数值,而不是像 Python 的普通列表对象那样存储对象的引用,但其行为方式相同。但由于第一个示例使用无法存储在这种数组中的值,因此这种情况不太可能发生。
相反,问题最有可能使用在许多其他语言中使用的“数组”一词,它与 Python 的列表类型相同。)

这并没有考虑更大的内存使用。 - Kenan Banks
是的,第二种情况会创建len(array)个i**2数字,它们会占用内存。而在第一种情况中,只有一个n,这不会占用太多内存。 - Lennart Regebro
1
@Matthew,如果arr是一个array.array,则它确实存储值的副本(因此,@Lennart,它并不总是按引用传递-- array.array是一种特殊情况,它保存单个小类型的值的副本,这就是为什么它通常比列表更紧凑的原因)。特别是,没有办法让array.array存储超过32位的整数,所以我们大多数都假设OP写错了,他说的是list而不是array.array! - Alex Martelli
1
@ Lennart - 既然你有获胜的答案(目前为止),为什么不详细阐述一下并澄清似乎是相当普遍的误解呢? - Kenan Banks
Alex,解释得好。我本应该看到(2**32)**2无法存储在任何真实的array数组中。 - Matthew Flaschen
显示剩余2条评论

0
在这两个例子中,arr[i]都会获取对象的引用,无论它是n还是i * 2的结果对象。
在第一个例子中,n已经被定义,所以只需获取引用即可。但在第二个例子中,必须评估i * 2,如果需要为这个新的结果对象分配空间,则GC将分配空间,然后使用其引用。

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接