Python内存管理揭秘 -- id()
在玩弄 id()
的时候,我开始关注不同对象中相同属性的地址。不过现在这似乎不重要了。接下来是代码:
class T(object):
pass
class N(object):
pass
第一次测试(在交互式控制台中):
n = N()
t = T()
id(n)
# prints 4298619728
id(t)
# prints 4298619792
其实这里并没有什么意外。n.__class__
和 t.__class__
是不同的,所以看起来它们不可能是同一个对象。现在,这两个对象之间唯一的区别就是 __class__
吗?假设不是,因为:
>>> n1 = N()
>>> n2 = N()
>>> id(n1) == id(n2)
False
或者说,Python 会创建不同的对象,即使它们的内容完全相同,而不是一开始就把名字 n1
和 n2
指向同一个对象(在内存中),然后在 n1
或 n2
被修改时再重新指向?为什么会这样?我知道这可能涉及到约定、优化、心情、底层问题(别客气)但我还是很好奇。
现在,和之前一样的类,T()
和 N()
-- 在命令行中一个接一个执行:
>>> id(N())
4298619728
>>> id(N())
4298619792
>>> id(N())
4298619728
>>> id(N())
4298619792
为什么会有这样的变化呢?
但接下来就变得奇怪了。再次是同样的类,在命令行中:
>>> id(N()), id(T())
(4298619728, 4298619728)
>>> id(N()), id(T())
(4298619728, 4298619728)
>>> id(N()), id(T())
(4298619728, 4298619728)
不仅变化停止了,N()
和 T()
似乎变成了同一个对象。既然它们不可能是同一个对象,我理解为 N()
返回的东西在 id()
调用后被销毁了,在整个语句结束之前。
我意识到这可能是个难以回答的问题。但我希望有人能告诉我我在观察什么,我的理解是否正确,分享一些关于解释器内部工作和内存管理的“黑魔法”,或者指向一些关于这个主题的好资源?
谢谢你花时间来讨论这个问题。
5 个回答
我可能说错了,但我觉得你看到的是垃圾回收器在工作。调用 N() 或 T() 时,会创建一个对象,但这个对象没有被存储到任何地方,之后就会被垃圾回收器处理掉。处理完后,这些内存地址就可以被重新使用了。
文档中提到的内容 说得很清楚:
id(object)
:返回一个对象的“身份”。这个身份是一个整数(或者长整数),在对象的生命周期内是唯一且不变的。两个生命周期不重叠的对象可能会有相同的 id() 值。
每当你调用一个构造函数时,就会创建一个新对象。这个对象的 id 和任何其他 当前存在的 对象的 id 都是不同的。
>>> n1 = N()
>>> n2 = N()
>>> id(n1) == id(n2)
False
这两个对象的“内容”并不重要。它们是两个不同的实体;它们有不同的 id 是完全合乎逻辑的。
在 CPython 中,id 实际上就是内存地址。它们会被回收:如果一个对象被垃圾回收了,未来创建的另一个对象可能会获得相同的 id。这就是你在重复测试 id(N()), id(T())
时看到的情况:因为你没有保存对新创建对象的引用,解释器可以自由地将它们垃圾回收并重用它们的 id。
id 的回收显然是一个实现或平台的特性,不应该依赖于它。
你问了很多问题。我会尽量回答其中的一些,希望你能搞清楚其他问题(如果需要帮助,随时问我)。
第一个问题:解释一下 id
的行为
>>> n1 = N()
>>> n2 = N()
>>> id(n1) == id(n2)
False
这说明每次你调用一个对象构造函数时,Python都会创建一个新对象。这是合理的,因为这正是你所要求的!如果你只想分配一个对象,但给它两个名字,你可以这样写:
>>> n1 = N()
>>> n2 = n1
>>> id(n1) == id(n2)
True
第二个问题:为什么不使用写时复制?
你接着问为什么Python不实现写时复制的策略来分配对象。其实,当前的策略是每次调用构造函数时构造一个对象,这样做:
- 实现简单;
- 明确(完全按照你的要求来做);
- 易于记录和理解。
而且,写时复制的使用场景并不强烈。只有在创建了许多相同的对象且这些对象从未被修改的情况下,它才会节省存储空间。但在这种情况下,为什么要创建许多相同的对象呢?为什么不使用一个对象呢?
第三个问题:解释一下分配行为
在CPython中,id
实际上是对象在内存中的地址。你可以查看 builtin_id
函数,具体在bltinmodule.c
的第907行。
你可以通过创建一个包含 __init__
和 __del__
方法的类来研究Python的内存分配行为:
class N:
def __init__(self):
print "Creating", id(self)
def __del__(self):
print "Destroying", id(self)
>>> id(N())
Creating 4300023352
Destroying 4300023352
4300023352
你会发现Python能够立即销毁对象,这样就能回收空间供下一个分配使用。Python使用引用计数来跟踪每个对象的引用数量,当对象没有任何引用时,它就会被销毁。在同一语句的执行过程中,同一块内存可能会被多次重用。例如:
>>> id(N()), id(N()), id(N())
Creating 4300023352
Destroying 4300023352
Creating 4300023352
Destroying 4300023352
Creating 4300023352
Destroying 4300023352
(4300023352, 4300023352, 4300023352)
第四个问题:解释一下“ juggling”
我恐怕无法重现你所展示的“ juggling”行为(即交替创建的对象获得不同的地址)。你能提供更多细节吗,比如Python版本和操作系统?如果你使用我的类 N
,你会得到什么结果?
好的,如果我让我的类 N
继承自 object
,我可以重现这个“ juggling”。
我有一个关于为什么会发生这种情况的理论,但我还没有在调试器中验证过,所以请你适当参考。
首先,你需要了解一些关于Python内存管理器是如何工作的。去看看obmalloc.c
,等你看完再回来。我会等你。
...
都明白了吗?很好。那么现在你知道Python是如何通过将小对象按大小分类到池中来管理小对象的:每个4 KiB的池中包含一小范围大小的对象,还有一个空闲列表帮助分配器快速找到下一个要分配的对象的槽位。
现在,Python交互式命令行也在创建对象:例如抽象语法树和编译后的字节码。我的理论是,当 N
是一个新式类时,它的大小使得它与交互式命令行分配的某个其他对象进入同一个池。所以事件的顺序大致如下:
用户输入
id(N())
Python在池 P 中为刚创建的对象分配一个槽位(称为槽位 A)。
Python销毁该对象,并将其槽位返回到池 P 的空闲列表中。
交互式命令行分配某个对象,称为 O。这个对象恰好适合进入池 P,所以它获得了刚刚释放的槽位 A。
用户再次输入
id(N())
。Python在池 P 中为刚创建的对象分配一个槽位。槽位 A 已满(仍然包含对象 O),所以它获得了槽位 B。
交互式命令行忘记了对象 O,因此它被销毁,槽位 A 被返回到池 P 的空闲列表中。
你可以看到,这解释了交替的行为。在用户输入 id(N()),id(N())
的情况下,交互式命令行没有机会在两个分配之间插入,所以它们都可以进入池中的同一个槽位。
这也解释了为什么旧式对象不会发生这种情况。可以推测旧式对象的大小不同,因此它们进入不同的池,不会与交互式命令行创建的对象共享槽位。
第五个问题:交互式命令行可能分配了哪些对象?
查看pythonrun.c
以获取详细信息,但基本上交互式命令行:
读取你的输入并分配包含你代码的字符串。
调用解析器,构建描述代码的抽象语法树。
调用编译器,构建编译后的字节码。
调用求值器,为堆栈帧、本地变量、全局变量等分配对象。
我不确切知道这些对象中哪个导致了“ juggling”。不是输入字符串(字符串有自己专门的分配器);也不是抽象语法树(它在编译后就被丢弃)。也许是字节码对象。