本节目标:搞清楚一个 Python 对象在内存里长什么样,并用
sys.getsizeof与递归测量给出真实字节数,看清__slots__到底省在哪、又贵在哪。
适用版本:Python 3.12+(实测 3.14.6)
4.2 对象布局、__slots__ 与内存占用测量
上一节我们看的是「对象什么时候被回收」,这一节看「对象本身占多少字节」。这是个容易被经验带偏的话题:几乎每篇讲 __slots__ 的文章都会告诉你「省 40%~50%」,但很少有人把实例本体、__dict__、GC 头这三块拆开算给你看。拆开之后你会发现一个反直觉的事实——__slots__ 实例的本体反而更大,省下来的是别的东西。
4.2.1 每个对象都有头:PyObject 与 PyVarObject
CPython 里所有对象的前 16 个字节是固定的头:ob_refcnt(8 字节,就是上一节那个引用计数)和 ob_type(8 字节,指向类型对象)。验证方法很直接:
import sys
print("object() 的 sizeof:", sys.getsizeof(object())) # 16
print("int.__basicsize__:", int.__basicsize__) # 24
object() 的 sizeof: 16
int.__basicsize__: 24
裸 object() 就是 16 字节,正好是头。而 int 的 __basicsize__ 是 24——多出来的 8 字节是 PyVarObject 的 ob_size。定长对象用 PyObject(16 字节),变长对象用 PyVarObject(24 字节,多一个「有多少个元素」的字段)。int 是变长对象,因为大整数要存任意多个「数位」。
所有类型的这两个字段都能直接读到:
for t in (object, int, float, list, tuple, dict, set, str, bytes):
print(f"{t.__name__:8} basicsize={t.__basicsize__:4} itemsize={t.__itemsize__}")
实测输出:
object basicsize= 16 itemsize=0
int basicsize= 24 itemsize=4
float basicsize= 24 itemsize=0
list basicsize= 40 itemsize=0
tuple basicsize= 32 itemsize=8
dict basicsize= 48 itemsize=0
set basicsize= 200 itemsize=0
str basicsize= 64 itemsize=0
bytes basicsize= 33 itemsize=1
basicsize 是「空对象的固定部分」,itemsize 是「每个元素追加多少字节」。于是可以反推出 sys.getsizeof 的计算口径:
getsizeof = basicsize + itemsize × n + (16 字节 GC 头,若对象被 GC 跟踪)
验证:int 的 getsizeof(0) = 24 + 4×1 = 28;int 2**30 = 24 + 4×2 = 32;tuple (1,2,3) = 32 + 8×3 + 16 = 72;dict {} = 48 + 16 = 64。全部对得上。唯一例外是 str——它重写了 __sizeof__,用紧凑表示(字符数据直接内联在对象尾部),所以 getsizeof('') = 41,比 basicsize 的 64 还小。
4.2.2 常见类型的实测字节数
在本机(3.14.6,64 位 arm64)上逐类量一遍:
import sys
rows = [(0, "int 0"), (2**100, "int 2**100"), (1.5, "float"), (True, "bool"),
("", "str ''"), ("hello", "str 'hello'"), ("x"*100, "str 100"),
([], "list []"), ([0]*10, "list [0]*10"), ((), "tuple ()"),
((1,2,3), "tuple (1,2,3)"), ({}, "dict {}"), ({i: i for i in range(10)}, "dict 10"),
(set(), "set ()"), (set(range(10)), "set 10")]
for o, name in rows:
print(f"{name:14} {sys.getsizeof(o):5} bytes")
实测输出:
int 0 28 bytes
int 2**100 40 bytes
float 24 bytes
bool 28 bytes
str '' 41 bytes
str 'hello' 46 bytes
str 100 141 bytes
list [] 56 bytes
list [0]*10 136 bytes
tuple () 48 bytes
tuple (1,2,3) 72 bytes
dict {} 64 bytes
dict 10 352 bytes
set () 216 bytes
set 10 728 bytes
几个值得记住的点:bool 和 int 一样大(True 就是一个特殊的 int);空 dict 64 字节、空 set 216 字节,set 的空壳贵得多;str 每多一个字符约 +1 字节(紧凑表示)。
4.2.3 容器不「装」内容,装的是指针
list 本体 40 字节的固定部分里,有一个指向独立指针数组的 ob_item。列表存的是 8 字节一个的对象指针,不是对象本身——这也是为什么 list [0]*10 = 136 = 56 + 8×10。
import sys
for n in [0, 1, 2, 4, 8, 9, 16, 17, 32, 33]:
print(f"list len={n:3} -> {sys.getsizeof([0]*n):5} bytes")
实测输出:
list len= 0 -> 56 bytes
list len= 1 -> 64 bytes
list len= 2 -> 72 bytes
list len= 4 -> 88 bytes
list len= 8 -> 120 bytes
list len= 9 -> 128 bytes
list len= 16 -> 184 bytes
list len= 17 -> 192 bytes
list len= 32 -> 312 bytes
list len= 33 -> 320 bytes
每加一个元素 +8 字节,且分配按容量跳档(比如 9→16 之间一次性预留)。dict 的稀疏表更夸张:空 64 字节,装 1 个键值对直接跳到 224 字节,11 个到 632 字节,50 个到 2264 字节,按 2 的幂增长。这意味着「用一个小 dict 代替几个属性」在内存上是亏的——空字典的固定开销就比三个字段还大。
4.2.4 __dict__ vs __slots__:省的不是实例本体
现在进入正题。定义两个语义完全相同的类,一个用默认的 __dict__,一个用 __slots__:
import sys
class WithDict:
def __init__(self, name, age, email):
self.name, self.age, self.email = name, age, email
class WithSlots:
__slots__ = ("name", "age", "email")
def __init__(self, name, age, email):
self.name, self.age, self.email = name, age, email
d = WithDict("Alice", 25, "a@example.com")
s = WithSlots("Alice", 25, "a@example.com")
print("WithDict 实例:", sys.getsizeof(d))
print("WithSlots 实例:", sys.getsizeof(s))
print("WithDict.__dict__:", sys.getsizeof(d.__dict__))
WithDict 实例: 48
WithSlots 实例: 56
WithDict.__dict__: 296
反直觉的地方来了:__slots__ 实例本体是 56 字节,比 __dict__ 实例的 48 字节还大 8 字节。 把两块拆开看就清楚了(__basicsize__ 实测:WithDict 是 16,WithSlots 是 40):
WithDict实例 = 16 头 + 16 GC 头 + 8(__dict__指针)+ 8(__weakref__指针)= 48;WithSlots实例 = 16 头 + 24(3 个字段指针内联)+ 16 GC 头 = 56。
__dict__ 版只放两个「管理用」指针(16 字节),字典本体在实例之外;__slots__ 版把 3 个字段指针内联进实例(24 字节),比前者多 8 字节。所以:
- 实例本体:
__dict__版 48,__slots__版 56,__slots__反而多 8 字节; - 但
__dict__版每个实例还额外挂一个 296 字节的字典; - 合计:
(48 + 296) − 56 = 288字节的净节省,全部来自「省掉了__dict__」。
再量每个槽的确切成本:
def make(n):
return type(f"S{n}", (), {"__slots__": tuple(f"f{i}" for i in range(n))})
prev = None
for n in range(7):
cur = sys.getsizeof(make(n)())
if prev is not None:
print(f"slots {n-1}->{n}: +{cur - prev} bytes")
prev = cur
slots 0->1: +8 bytes
slots 1->2: +8 bytes
...(每个槽恰好 +8 字节)
slots=0 的实例是 32 字节(16 头 + 16 GC 头),每多一个槽 +8 字节,干净利落。
4.2.5 100 万实例的真实差距
单实例的算术容易有「其实也没差多少」的错觉,那就用 tracemalloc 量 100 万个实例(本机 3.14.6):
import tracemalloc, gc
N = 1_000_000
class D:
def __init__(self, x, y): self.x, self.y = x, y
class S:
__slots__ = ("x", "y")
def __init__(self, x, y): self.x, self.y = x, y
for label, cls in [("__dict__", D), ("__slots__", S)]:
gc.collect()
tracemalloc.start()
base = tracemalloc.take_snapshot()
objs = [cls(1, 2) for _ in range(N)]
snap = tracemalloc.take_snapshot()
total = sum(s.size_diff for s in snap.compare_to(base, "filename"))
tracemalloc.stop()
print(f"{label}: 净增长 {total/1024/1024:6.1f} MB, 单实例约 {total/N:.1f} bytes")
del objs
实测输出:
__dict__: 净增长 92.0 MB, 单实例约 96.5 bytes
__slots__: 净增长 53.8 MB, 单实例约 56.4 bytes
100 万个双字段对象,__dict__ 版占 92.0 MB,__slots__ 版占 53.8 MB,省掉 38.2 MB(约 41%)。这个 41% 和常见文章说的「40%~50%」吻合,但你现在知道它的来源了:省的是每实例那个 296 字节的 __dict__,不是实例本体。
4.2.6 __slots__ 的代价
省内存不是白来的,代价有明确的四条:
- 不能动态加属性:
s.extra = 1抛AttributeError,因为实例没有__dict__可写。 - 没有
__dict__:依赖vars(obj)、obj.__dict__的代码(含某些序列化库)会失效。 - 继承要重新声明:子类若想继续用槽,必须自己写
__slots__;父类有__dict__时子类会重新引入__dict__,省内存的效果被抵消。 - 弱引用要显式开:需要
weakref支持时得加__weakref__槽,实测实例从 32 字节涨到 48 字节。
判断标准很简单:实例数量大(几十万以上)、字段固定、不需要动态属性——才用 __slots__。其余场景它是负优化。
4.2.7 sys.getsizeof 不递归:最大的坑
getsizeof 只算对象自己,不算它引用的东西。这会让容器看起来小得离谱:
import sys
inner = [0] * 1000
outer = [inner]
print("inner 浅 sizeof:", sys.getsizeof(inner)) # 8056
print("outer 浅 sizeof:", sys.getsizeof(outer)) # 64
实测输出:
inner 浅 sizeof: 8056
outer 浅 sizeof: 64
outer 明明装着 8 KB 的数据,getsizeof 却只报 64 字节——因为它只数了那个 8 字节的指针。要算真实占用,得自己递归,并用 id() 去重(既防无限递归,也防共享子对象被重复计数):
def deep_size(obj, seen=None):
if seen is None:
seen = set()
oid = id(obj)
if oid in seen:
return 0
seen.add(oid)
size = sys.getsizeof(obj)
if isinstance(obj, dict):
size += sum(deep_size(k, seen) + deep_size(v, seen) for k, v in obj.items())
elif isinstance(obj, (list, tuple, set, frozenset)):
size += sum(deep_size(x, seen) for x in obj)
elif hasattr(obj, "__dict__") and not isinstance(obj, type):
size += deep_size(obj.__dict__, seen)
return size
print("outer 深 deep_size:", deep_size([inner])) # 8148
shared = [0] * 1000
print("3 个共享引用浅:", sys.getsizeof([shared]*3)) # 80
print("3 个共享引用深:", deep_size([shared]*3)) # 8164
实测输出:
outer 深 deep_size: 8148
3 个共享引用浅: 80
3 个共享引用深: 8164
注意最后两行:列表里有三个同一个 shared 引用,深测量只算了一份 shared(8164 ≈ 80 + 8084),而不是三份——这正是 id() 去重的作用。另一种轻量口径是用 gc.get_referents 取一层直接引用:
import gc, sys
obj = {"k": [1, 2, 3], "v": (4, 5)}
refs = gc.get_referents(obj)
print("直接引用:", [type(r).__name__ for r in refs])
print("一层合计:", sys.getsizeof(obj) + sum(sys.getsizeof(r) for r in refs))
实测输出:
直接引用: ['list', 'tuple']
一层合计: 336 bytes
4.2.8 一个会让测量失真的细节:常量折叠
写测量脚本时踩到过一个坑:self.payload = b"x" * 1024 看着像「每个实例各持 1 KB」,实际全是同一个对象。因为 b"x" * 1024 两个操作数都是常量,编译器直接把它折叠进常量表:
import dis
def f():
return b"x" * 1024
print("常量表里有折叠结果:", len(f.__code__.co_consts) == 2)
dis.dis(f)
常量表里有折叠结果: True
3 LOAD_CONST 1 (b'xxxx...(1024 个 x)...')
RETURN_VALUE
字节码里根本没有乘法指令,只有一条 LOAD_CONST。验证对象身份:a = b"x"*1024 与 b = b"x"*1024 两个表达式的结果 id 相同(实测 True),sizeof 为 1057,证明是同一份常量。这对测量和泄漏排查都有直接影响:你以为每个实例占了 1 KB,其实它们共享一份,真实占用可能小几个数量级。要造「每个实例独立」的负载,得用 bytearray(1024) 这类运行时分配(4.3 节的泄漏演示就是这么改的)。
小结
- 所有 CPython 对象前 16 字节是头:
ob_refcnt+ob_type;变长对象(int等)再加 8 字节的ob_size(PyVarObject)。 getsizeof = basicsize + itemsize × n + 16 字节 GC 头;str是唯一例外,用紧凑表示,空串只有 41 字节。- 容器本体很小(
list []56、dict {}64 字节),内容存在独立的指针数组/哈希表里,每元素约 8 字节且按容量跳档分配。 - 反直觉结论:
__slots__实例本体(56 字节)反而比__dict__实例(48 字节)更大;节省的 288 字节全部来自省掉 296 字节的__dict__。 - 实测 100 万个双字段实例:
__dict__版 92.0 MB vs__slots__版 53.8 MB,省约 41%。 sys.getsizeof不递归,容器会严重低估(外层 64 字节 vs 真实 8148 字节);递归测量必须用id()去重,gc.get_referents可取一层引用。b"x" * 1024这类常量表达式会被折叠进常量表,所有实例共享同一对象——测量内存时务必警惕。
理解了对象布局与测量口径,下一节就用 tracemalloc 和 objgraph 把「内存一直涨」这类问题定位到具体那一行代码。
阅读导航:上一节:4.1 引用计数、循环 GC 与分代回收 · 下一节:4.3 内存泄漏定位 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。