《Python高级编程》10.2 内存优化与数据结构选型

用本机实测把「省内存」量化到底:sys.getsizeof 与 tracemalloc 两种口径、list/array/bytes/memoryview/numpy 装 100 万个整数的真实字节数、sys.intern 字符串驻留、生成器把峰值从 39.5MB 压到 0.5KB,以及 objgraph 3.6.2 定位引用增长与泄漏。

本节目标:掌握「按对象布局选容器」的量化方法,能用 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 028PyLongObject = 24 头 + 1 个 30-bit digit
int 2**3032需要 2 个 digit
float24
'' / 'a' / 'abc'41 / 42 / 44ASCII 紧凑表示,每字符 +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,05636,000,0568 + 28 = 36 字节
array('i', range(N))4,091,9484,091,948~4 字节
bytes(N)1,000,0331,000,0331 字节
np.arange(N, int32)4,000,1124,000,1124 字节

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 MiB bytearray 的视图头仅 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 现状 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「python」更多文章

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