《Python高级编程》4.2 对象布局、`__slots__` 与内存占用测量

本节用 sys.getsizeof 逐类实测 int/str/list/dict/tuple 与自定义实例的字节数,讲清 PyObject 头 16 字节、PyVarObject 与容器指针数组的布局,并给出一个反直觉结论:__slots__ 实例本体反而比 __dict__ 实例更大,省下的是整块 __dict__;再用 100 万实例实测 92.0MB 对 53.8MB 的真实差距。

本节目标:搞清楚一个 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 内存泄漏定位 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「python」更多文章

  1. 《Python高级编程》目录
  2. 《Python高级编程》11.3 PEP 流程与版本迁移策略
  3. 《Python高级编程》11.2 嵌入式与自由线程运行时