第3章:Python内存模型——对象布局、小整数池与循环引用
聊Python内存,很多人觉得这是底层的事,跟业务代码没关系。我当年也这么想,直到线上服务莫名其妙内存暴涨,查了三天才发现是循环引用搞的鬼。嗯,从那以后,我写每个类都会多看一眼引用关系。
3.1 Python对象的内存布局
Python里一切皆对象。这句话你肯定听过。但你知道一个对象在内存里长什么样吗?
每个Python对象,本质上是一个结构体。我习惯把它想象成三部分:
- 头部(PyObject_HEAD):包含引用计数和类型指针
- 数据区:存放实际的值
- 可能还有额外字段:比如可变对象的缓冲
举个例子,一个整数对象在内存里是这样的:
# 一个简单的整数
a = 42
print(id(a)) # 内存地址
print(type(a)) # <class 'int'>
你看到的 id(),其实就是对象头部的起始地址。引用计数就藏在这个头部里。每次你赋值、传参、放入容器,引用计数就+1。反之则-1。归零时,内存就被回收。
核心要点:Python对象的内存开销比你想的大。一个整数不仅仅是4字节或8字节,它带着整个对象头。我测过,空对象大约占32字节(64位系统)。
3.2 小整数池与字符串驻留
这里有个经典问题:为什么 a = 256 和 b = 256 是同一个对象,而 a = 257 就不是?
说白了,Python启动时预先创建了[-5, 256]范围内的整数对象。这叫小整数池。你每次用这个范围内的数,拿到的都是同一个对象。
a = 256
b = 256
print(a is b) # True
c = 257
d = 257
print(c is d) # False(交互式环境下可能True,但脚本里通常是False)
为什么这么设计?性能。这些小整数太常用了,每次都新建对象太浪费。我在项目中遇到过一个问题:用 is 比较两个大整数,结果时对时错。排查半天才发现是整数池的边界问题。从那以后,我比较值只用 ==,绝不偷懒用 is。
字符串驻留也是类似思路。Python会把一些短字符串、标识符风格的字符串缓存起来。
s1 = "hello"
s2 = "hello"
print(s1 is s2) # True(驻留了)
s3 = "hello world!"
s4 = "hello world!"
print(s3 is s4) # 不一定,取决于实现
我的建议:别依赖驻留机制。字符串比较永远用 ==。驻留是优化手段,不是语言保证。你想想看,如果代码跑到不同Python版本上行为不一致,那得多坑。
3.3 循环引用问题
这才是内存泄漏的重灾区。Python的引用计数机制有个天然缺陷:它处理不了循环引用。
什么叫循环引用?就是A引用B,B引用A,或者更复杂的环状结构。两个对象的引用计数永远不为0,即使外部已经没有变量指向它们。
class Node:
def __init__(self, name):
self.name = name
self.parent = None
self.child = None
# 创建循环引用
a = Node("A")
b = Node("B")
a.child = b
b.parent = a
# 删除外部引用
del a
del b
# 但这两个对象还在内存里,互相指着对方
我曾经维护过一个爬虫系统,每天跑完内存占用翻倍。查了三天,发现是回调函数里形成了闭包循环引用。每个请求的回调持有response对象,response对象又持有回调列表。嗯,那感觉就像在迷宫里绕圈。
Python解决这个问题的方案是垃圾回收器(GC)。它专门处理循环引用。GC会定期扫描所有对象,找出不可达的循环引用组,然后回收它们。
注意:GC不是实时的。它只在特定时机触发:比如引用计数变化达到阈值,或者你手动调用 gc.collect()。如果你的程序创建大量循环引用,内存可能会在GC运行前持续增长。
怎么检测循环引用?我常用的方法:
- 用
gc.get_objects()查看所有被追踪的对象 - 用
gc.get_referrers()反向查找谁引用了某个对象 - 用
objgraph库可视化引用关系
import gc
import objgraph
# 强制GC
gc.collect()
# 查看某个类的实例数量
objgraph.count('Node')
# 生成引用图(需要安装graphviz)
objgraph.show_refs([some_object], filename='refs.png')
我个人习惯在开发环境里定期打印对象数量变化。如果某个类的实例数只增不减,那八成有循环引用或者忘记释放。
3.4 知识体系图
下面这张图概括了本章的核心逻辑。我画的时候特意把循环引用放在中心,因为它是最容易踩坑的地方。
3.5 避坑指南
最后分享几个实战经验:
- 慎用
__del__方法:它会影响GC对循环引用的处理。我曾经在__del__里写了资源释放逻辑,结果循环引用导致它永远不被调用。资源泄漏了。 - 用
weakref打破循环:如果必须设计双向引用,考虑用弱引用。弱引用不增加引用计数,GC可以正常回收。 - 定期检查对象数量:在测试环境里跑
gc.get_objects()看看趋势。如果某个类的实例数持续增长,赶紧查。
import weakref
class Node:
def __init__(self, name):
self.name = name
self.parent = None
self.child = None
a = Node("A")
b = Node("B")
a.child = weakref.ref(b) # 弱引用
b.parent = weakref.ref(a) # 弱引用
# 现在即使有循环,GC也能正常回收
一句话总结:Python的内存管理很智能,但不是万能的。理解对象布局、善用驻留优化、警惕循环引用——做到这三点,大部分内存问题都能提前规避。