Python内存管理揭秘 -- id()

5 投票
5 回答
560 浏览
提问于 2025-04-16 21:46

在玩弄 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 会创建不同的对象,即使它们的内容完全相同,而不是一开始就把名字 n1n2 指向同一个对象(在内存中),然后在 n1n2 被修改时再重新指向?为什么会这样?我知道这可能涉及到约定、优化、心情、底层问题(别客气)但我还是很好奇。

现在,和之前一样的类,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 个回答

1

我可能说错了,但我觉得你看到的是垃圾回收器在工作。调用 N() 或 T() 时,会创建一个对象,但这个对象没有被存储到任何地方,之后就会被垃圾回收器处理掉。处理完后,这些内存地址就可以被重新使用了。

5

文档中提到的内容 说得很清楚

id(object)

返回一个对象的“身份”。这个身份是一个整数(或者长整数),在对象的生命周期内是唯一且不变的。两个生命周期不重叠的对象可能会有相同的 id() 值。

每当你调用一个构造函数时,就会创建一个新对象。这个对象的 id 和任何其他 当前存在的 对象的 id 都是不同的。

>>> n1 = N()
>>> n2 = N()
>>> id(n1) == id(n2)
False

这两个对象的“内容”并不重要。它们是两个不同的实体;它们有不同的 id 是完全合乎逻辑的。

在 CPython 中,id 实际上就是内存地址。它们会被回收:如果一个对象被垃圾回收了,未来创建的另一个对象可能会获得相同的 id。这就是你在重复测试 id(N()), id(T()) 时看到的情况:因为你没有保存对新创建对象的引用,解释器可以自由地将它们垃圾回收并重用它们的 id。

id 的回收显然是一个实现或平台的特性,不应该依赖于它。

7

你问了很多问题。我会尽量回答其中的一些,希望你能搞清楚其他问题(如果需要帮助,随时问我)。

第一个问题:解释一下 id 的行为

>>> n1 = N()
>>> n2 = N()
>>> id(n1) == id(n2)
False

这说明每次你调用一个对象构造函数时,Python都会创建一个新对象。这是合理的,因为这正是你所要求的!如果你只想分配一个对象,但给它两个名字,你可以这样写:

>>> n1 = N()
>>> n2 = n1
>>> id(n1) == id(n2)
True

第二个问题:为什么不使用写时复制?

你接着问为什么Python不实现写时复制的策略来分配对象。其实,当前的策略是每次调用构造函数时构造一个对象,这样做:

  1. 实现简单;
  2. 明确(完全按照你的要求来做);
  3. 易于记录和理解。

而且,写时复制的使用场景并不强烈。只有在创建了许多相同的对象且这些对象从未被修改的情况下,它才会节省存储空间。但在这种情况下,为什么要创建许多相同的对象呢?为什么不使用一个对象呢?

第三个问题:解释一下分配行为

在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 是一个新式类时,它的大小使得它与交互式命令行分配的某个其他对象进入同一个池。所以事件的顺序大致如下:

  1. 用户输入 id(N())

  2. Python在池 P 中为刚创建的对象分配一个槽位(称为槽位 A)。

  3. Python销毁该对象,并将其槽位返回到池 P 的空闲列表中。

  4. 交互式命令行分配某个对象,称为 O。这个对象恰好适合进入池 P,所以它获得了刚刚释放的槽位 A

  5. 用户再次输入 id(N())

  6. Python在池 P 中为刚创建的对象分配一个槽位。槽位 A 已满(仍然包含对象 O),所以它获得了槽位 B

  7. 交互式命令行忘记了对象 O,因此它被销毁,槽位 A 被返回到池 P 的空闲列表中。

你可以看到,这解释了交替的行为。在用户输入 id(N()),id(N()) 的情况下,交互式命令行没有机会在两个分配之间插入,所以它们都可以进入池中的同一个槽位。

这也解释了为什么旧式对象不会发生这种情况。可以推测旧式对象的大小不同,因此它们进入不同的池,不会与交互式命令行创建的对象共享槽位。

第五个问题:交互式命令行可能分配了哪些对象?

查看pythonrun.c以获取详细信息,但基本上交互式命令行:

  1. 读取你的输入并分配包含你代码的字符串。

  2. 调用解析器,构建描述代码的抽象语法树。

  3. 调用编译器,构建编译后的字节码。

  4. 调用求值器,为堆栈帧、本地变量、全局变量等分配对象。

我不确切知道这些对象中哪个导致了“ juggling”。不是输入字符串(字符串有自己专门的分配器);也不是抽象语法树(它在编译后就被丢弃)。也许是字节码对象。

撰写回答