本节目标:掌握「按对象布局选容器」的量化方法,能用
getsizeof/tracemalloc/objgraph判断一段代码的真实内存代价。
适用版本:Python 3.12+(实测 3.14.6)
10.2 内存优化与数据结构选型
上一节把「时间」量到了字节码事件级,这一节换成「空间」。站内专题 Python 内存管理与 GC 性能
讲过 __slots__、tracemalloc、objgraph 的用法,本书 4.2 对象布局、__slots__ 与内存占用测量
又从 PyObject 头 16 字节的角度拆过实例布局。本节不重复这些,而是聚焦一个更常见的决策:同一批数据,装进不同容器,内存能差几十倍——到底该选哪个,代价是什么。
10.2.1 两种口径:getsizeof 与 tracemalloc
sys.getsizeof 只报对象本身的字节数,不递归。要估整块数据,得自己沿引用走。下面这个 deep() 用 id() 去重(避免共享对象被重复计数),能给出「可达内存」的近似值:
import sys
def deep(o):
seen, stack, total = set(), [o], 0
while stack:
x = stack.pop()
i = id(x)
if i in seen:
continue
seen.add(i)
total += sys.getsizeof(x)
if isinstance(x, (list, tuple, set, frozenset)):
stack.extend(x)
elif isinstance(x, dict):
stack.extend(x.values())
return total
单个内建对象的实测开销(3.14.6,64 位):
| 对象 | getsizeof | 说明 |
|---|---|---|
int 0 | 28 | PyLongObject = 24 头 + 1 个 30-bit digit |
int 2**30 | 32 | 需要 2 个 digit |
float | 24 | |
'' / 'a' / 'abc' | 41 / 42 / 44 | ASCII 紧凑表示,每字符 +1 |
bytes b'' / b'abc' | 33 / 36 | |
() / (1,) | 48 / 56 | 每个元素 +8 指针 |
[1] | 64 | 空 list 56,加指针数组 |
{} / {1: 1} | 64 / 224 | 小 dict 一上来就分配哈希表 |
set() / {1} / frozenset() | 216 | 空的 set 就已 216 字节 |
注意最后一行:空 set() 就有 216 字节,而空 dict 只有 64 字节。因为 CPython 的 set 初始就按较大容量建表,小集合的场景里 set 反而比 dict 更占内存。这也解释了为什么 {1: 1} 单元素 dict 是 224 字节——它已经预分配了一张 8 槽的哈希表。
10.2.2 装 100 万个整数:五种容器的实测对比
这是本节的核心实验。同样 100 万个整数,分别用 list、array('i')、bytes、numpy 承载:
import sys
from array import array
import numpy as np
N = 1_000_000
lst = list(range(N)) # 每元素是一个独立 PyLong
arr = array('i', range(N)) # 紧凑 C int 数组
bs = bytes(N) # N 个零字节
npa = np.arange(N, dtype=np.int32)
真实结果:
| 容器 | getsizeof | 深测量(含元素) | 每元素 |
|---|---|---|---|
list(range(N)) | 8,000,056 | 36,000,056 | 8 + 28 = 36 字节 |
array('i', range(N)) | 4,091,948 | 4,091,948 | ~4 字节 |
bytes(N) | 1,000,033 | 1,000,033 | 1 字节 |
np.arange(N, int32) | 4,000,112 | 4,000,112 | 4 字节 |
list 的 getsizeof 只有 8 MB(那是指针数组本身),深测量却是 36 MB——多出来的 28 MB 全是 100 万个 PyLong 对象。这就是「getsizeof 不递归」的坑:容器本身和它指向的对象是两笔账。而 array/bytes/numpy 把数据存在一块连续缓冲区里,不产生任何独立对象,深测量与 getsizeof 相同。
但省内存不是免费的。用 pyperf 实测对 1000 个整数求和:
python -m pyperf timeit --fast -s "data=list(range(1000))" "sum(data)"
python -m pyperf timeit --fast -s "from array import array; data=array('i', range(1000))" "sum(data)"
真实输出:
list : Mean +- std dev: 2.60 us +- 0.04 us
array: Mean +- std dev: 7.13 us +- 0.11 us
array 求和慢了约 2.7 倍——因为每次取元素都要把 C int 装箱成一个新的 PyLong。这就是选型的核心权衡:紧凑容器省内存,但每次访问都有装箱成本。选型判据是「数据规模 × 访问次数」:
- 数据大、只做整体运算(数值计算、序列化)→
array/numpy/bytes。 - 数据小或要频繁随机访问/增删 → 普通
list。
memoryview 是另一条路:它不复制,只在已有缓冲区上开一个视图。实测:
data = bytearray(range(256)) * 4096 # 1 MiB
mv = memoryview(data)
sl = mv[0:1024] # 零拷贝切片
bs = bytes(data)[0:1024] # 真实拷贝
print(sys.getsizeof(data), sys.getsizeof(sl), sys.getsizeof(bs), sl.obj is data)
真实输出:
1048633 184 1057 True
bytearray 本体 1,048,633 字节,memoryview 切片只有 184 字节(视图头),且 sl.obj is data 为 True——它指向原缓冲区,没有复制。而 bytes(data)[0:1024] 是 1057 字节的真实拷贝。处理大文件、网络包、二进制协议时,memoryview 能省掉整段拷贝。
10.2.3 字符串驻留:sys.intern
Python 会自动驻留「看起来像标识符」的编译期字符串常量,但运行时拼接出来的不会。实测:
import sys
s1 = "hello_world_constant_string"
s2 = "".join(["hello_world_constant_", "string"])
s3 = sys.intern(s2)
print(s1 is s2, s1 is s3)
真实输出:
False True
s1 is s2 为 False——运行期拼出来的字符串是独立对象;sys.intern(s2) 后 s1 is s3 为 True,两者共享同一对象。当你要把大量重复的键(字段名、枚举名、解析器 token)放进字典或做集合运算时,sys.intern 既省内存又让比较退化为指针比较。反过来,如果字符串几乎不重复,驻留表本身会一直持有它们,反而变成泄漏源。
一个相关的陷阱:小整数与常量会被共享。[1000] * 1000 深测量只有 8084 字节(1000 个槽指向同一个 1000 对象),而 [i for i in range(1000)] 是 36856 字节(1000 个不同对象)。测内存时如果不注意这种共享,数字会差好几倍。
10.2.4 生成器:把峰值内存压到接近零
列表推导会全量物化,生成器表达式只保留迭代状态。实测 getsizeof:[x*x for x in range(10**6)] 是 8,448,728 字节,而 (x*x for x in range(10**6)) 只有 208 字节。
更贴近真实场景的是 tracemalloc 的峰值。同样对 100 万个数求和:
import tracemalloc, gc
N = 1_000_000
for label, fn in [("list", lambda: sum([x*x for x in range(N)])),
("genexpr", lambda: sum(x*x for x in range(N)))]:
gc.collect(); tracemalloc.start()
fn()
cur, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
print(f"{label:<8} current={cur/1024:8.1f} KB peak={peak/1024:10.1f} KB")
真实输出:
list current= 3.3 KB peak= 39500.4 KB
genexpr current= 0.1 KB peak= 0.5 KB
列表版峰值 39.5 MB,生成器版峰值 0.5 KB——相差近 8 万倍。这就是「流式处理」的本质:只要下游能逐个消费,就不该把整个序列物化。注意 current 两者都接近 0,说明峰值才是判断内存风险的正确指标,程序跑完后的 current 会骗人。
10.2.5 objgraph:从「涨了多少」定位到「谁持有它」
tracemalloc 告诉你「哪一行分配得最多」,但要问「这个对象为什么没被回收、谁在引用它」,需要 objgraph 3.6.2。先按类型统计增长:
import objgraph, gc
class Cache: pass
def make_leak():
global _leaked
_leaked = []
for i in range(1000):
c = Cache(); c.data = [0]*10
_leaked.append(c)
gc.collect()
print("before:", objgraph.count("Cache"))
make_leak()
print("after :", objgraph.count("Cache"))
objgraph.show_most_common_types(limit=6)
真实输出:
before: 0
after : 1000
function 2179
dict 1709
list 1312
tuple 1254
wrapper_descriptor 1139
Cache 1000
objgraph.count("Cache") 精确报出新增了 1000 个实例。show_most_common_types 给出各类型总量排行。更实用的是 show_growth()——它对比两次调用之间的增量,直接把「这轮涨得最多的类型」列出来:
function 2179 +2179
dict 1709 +1709
list 1312 +1312
tuple 1216 +1216
wrapper_descriptor 1139 +1139
Cache 本身没进 top5,因为排在前面的都是解释器启动时创建的基础对象——这也提醒:第一次调用 show_growth 时全部计数都是「增量」,要在程序热身之后再取基准。定位到可疑对象后,用 objgraph.show_backrefs([obj], max_depth=10, filename="refs.png") 画出引用链,就能看到是哪个全局容器/闭包/异常链把它摁住了。
tracemalloc 的 compare_to 则从「分配点」这一侧切入。下面测量一个不断 append 的缓存:
import tracemalloc, gc
gc.collect(); tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
_cache = []
for _ in range(50):
_cache.append([i for i in range(100)])
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:2]:
print(f"{stat.size_diff/1024:+9.1f} KB {stat.count_diff:+6d} objs {stat.traceback.format()[-1].strip()}")
真实输出:
+279.9 KB +5104 objs _cache.append([i for i in range(100)])
+5104 objs 精确指向那一行 append——tracemalloc 给的是分配点,objgraph 给的是引用链,两者配合才能从「涨了多少」一路查到「谁在持有」。
小结
sys.getsizeof不递归:list(range(10**6))本体只有 8 MB,深测量却有 36 MB(差额是 100 万个PyLong);紧凑容器(array/bytes/numpy)深测量与本体相同。- 选型是「内存 vs 访问成本」的权衡:
array('i')装 100 万整数只要 4 MB,但求和比list慢约 2.7 倍(每次访问要装箱PyLong)。 - 空
set()占 216 字节、空dict仅 64 字节;小集合场景set反而更费内存。 memoryview切片是零拷贝(1 MiBbytearray的视图头仅 184 字节,sl.obj is data为真);sys.intern让运行期重复字符串共享同一对象。- 生成器把 100 万次求和的峰值从 39.5 MB 压到 0.5 KB;判断内存风险要看
tracemalloc的peak,不是跑完的current。 tracemalloc.compare_to定位分配点(哪一行涨了),objgraph.count/show_growth/show_backrefs定位引用链(谁持有它),两者互补。
内存这条线到这里收口。下一节回到「执行」,看 3.14 的自适应特化在字节码层到底改了什么,以及 JIT 现在到哪一步了。
阅读导航:上一节:10.1 剖析器内部与采样原理 · 下一节:10.3 自适应特化与 JIT 现状 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。