创建一百万个 Python 对象,有没有 __slots__ 差了整整一倍内存
去年做一个数据处理项目,需要把一份 CSV 文件里的几百万条记录解析成 Python 对象。
跑了一会儿,服务器内存报警了。
当时我的第一反应是:数据量太大,换成流式处理?改用 pandas?还是加机器?
结果同事过来看了一眼,加了几个字,内存占用直接砍掉了将近一半。
他加的东西叫 __slots__。
Python 对象为什么这么"重"
要理解 __slots__,得先搞清楚 Python 普通对象为什么占内存多。
Python 里每个类的实例,默认都有一个叫 __dict__ 的字典,用来存储这个实例的所有属性。
字典这个结构本身就是为了灵活性设计的——你可以随时往里添加任何键。但这灵活性有代价:字典在内存里的存储开销相当大,哪怕它只装了三个字段。
import sys
class Point:
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
p = Point(1.0, 2.0, 3.0)
print(sys.getsizeof(p)) # 对象本身大小
print(sys.getsizeof(p.__dict__)) # 属性字典大小
print(p.__dict__)
输出:
48
232
{'x': 1.0, 'y': 2.0, 'z': 3.0}
注意:对象本身只有 48 字节,但它挂着的那个字典就占了 232 字节。三个浮点数,一共才 24 字节的数据,外壳就用了 232 字节。
这就是问题所在。
__slots__ 做了什么
__slots__ 告诉 Python:这个类的实例只会有这几个属性,固定住,不需要字典了。
Python 会改用更紧凑的结构(类似 C 的结构体)来存储属性,省掉那个开销巨大的 __dict__。
class PointSlots:
__slots__ = ['x', 'y', 'z']
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
ps = PointSlots(1.0, 2.0, 3.0)
print(sys.getsizeof(ps)) # 对象本身大小
print(hasattr(ps, '__dict__')) # 是否有 __dict__
输出:
64
False
对象本身从 48 字节变成了 64 字节(因为把属性直接内嵌进来了),但没有那个 232 字节的字典了。
总内存:48 + 232 = 280 字节 变成了 64 字节。节省了将近 77%。
百万对象,差距有多大
光看单个对象的数字不直观,来测一个真实场景:
import tracemalloc
import sys
class Record:
def __init__(self, user_id, score, label):
self.user_id = user_id
self.score = score
self.label = label
class RecordSlots:
__slots__ = ['user_id', 'score', 'label']
def __init__(self, user_id, score, label):
self.user_id = user_id
self.score = score
self.label = label
N = 1_000_000
# 测量普通类
tracemalloc.start()
records = [Record(i, i * 0.1, i % 10) for i in range(N)]
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"普通类 - 当前内存: {current / 1024 / 1024:.1f} MB, 峰值: {peak / 1024 / 1024:.1f} MB")
# 测量 __slots__ 类
tracemalloc.start()
records_slots = [RecordSlots(i, i * 0.1, i % 10) for i in range(N)]
current, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"__slots__ - 当前内存: {current / 1024 / 1024:.1f} MB, 峰值: {peak / 1024 / 1024:.1f} MB")
输出:
普通类 - 当前内存: 247.3 MB, 峰值: 247.3 MB
__slots__ - 当前内存: 114.5 MB, 峰值: 114.5 MB
一百万条记录,内存差了将近 133 MB。
同样的数据,用 __slots__ 少用了 54% 的内存。
还有一个隐藏收益:属性访问更快
除了省内存,__slots__ 对象的属性访问速度也更快。
普通对象访问属性要走字典查找,而 __slots__ 的属性是固定偏移量访问,直接到内存地址取值,省去了哈希计算和冲突处理。
import timeit
p_normal = Point(1.0, 2.0, 3.0)
p_slots = PointSlots(1.0, 2.0, 3.0)
time_normal = timeit.timeit(lambda: p_normal.x + p_normal.y + p_normal.z, number=5_000_000)
time_slots = timeit.timeit(lambda: p_slots.x + p_slots.y + p_slots.z, number=5_000_000)
print(f"普通对象访问耗时: {time_normal:.3f}s")
print(f"__slots__ 访问耗时: {time_slots:.3f}s")
print(f"速度提升: {time_normal / time_slots:.2f}x")
输出:
普通对象访问耗时: 0.412s
__slots__ 访问耗时: 0.298s
速度提升: 1.38x
快了将近 40%,五百万次访问节省了 0.11 秒。看起来不多,但在高频循环里累积效果很可观。
用 __slots__ 要注意的坑
坑一:没法随意添加属性了
用了 __slots__,实例就不能动态添加不在列表里的属性:
ps = PointSlots(1.0, 2.0, 3.0)
ps.w = 4.0 # AttributeError: 'PointSlots' object has no attribute 'w'
这是限制,也是约束带来的安全感——你不会不小心把数据写到了一个拼错名字的属性上。
坑二:继承时需要小心
如果父类有 __slots__,子类也应该定义自己的 __slots__,否则子类会自动加回 __dict__,优化就失效了:
class Base:
__slots__ = ['x', 'y']
class Child(Base):
# 没有定义 __slots__,会自动有 __dict__
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z # 这个属性会存在 __dict__ 里
# 正确做法:
class ChildCorrect(Base):
__slots__ = ['z'] # 只需声明子类新增的属性
def __init__(self, x, y, z):
self.x = x
self.y = y
self.z = z
坑三:不能配合 weakref 默认使用
如果你需要对对象用弱引用(weakref),需要在 __slots__ 里显式加上 '__weakref__':
class MyClass:
__slots__ = ['data', '__weakref__']
什么时候该用
明确的判断标准:
- 需要批量创建大量同类对象(成千上万以上)
- 每个对象的属性集合是固定的、事先确定的
- 对内存或访问速度敏感
典型场景:解析大量数据记录、游戏里的实体对象(粒子、怪物)、网络协议的包结构、机器学习特征行。
如果你只创建几个配置对象、几十个用户实例,加不加 __slots__ 意义不大,代码反而更死板。
写在最后
那次项目内存报警的事之后,我对 Python 对象的"重量"开始有了感知。
每个 Python 对象背后都挂着一本字典,这在灵活性上是天才设计,但在大量创建的场景下就变成了累赘。__slots__ 本质上是在说:我知道自己需要什么,不需要那本字典。
很多性能优化最终都是这个逻辑——把不确定性换成确定性,把灵活性换成效率。
学会什么时候该灵活,什么时候该约束自己,是写出好代码的关键。
觉得有收获的话点个赞吧,也欢迎转发给经常在服务器上跑批量数据处理的朋友。
有遇到过对象爆内存的经历吗?留言说说你是怎么解决的,看看大家都踩过哪些坑。
最后,别忘了关注「有为大青年」,我们下期见~