小飞是一名刚接管仓库的新管理员。他以为内存优化就像“多买几个货架”那么直观,直到有一天,服务器像仓库门被货物堵实一样,直接 OOM 崩溃了。老板丢给他一句话:真正的高手不是会买货架,而是知道每件货怎么放、放多少、什么时候可以不搬货。Python 里的内存也正是一座仓库:每个对象是一件货物,列表是一排货架,生成器是一条随取随走的传送带,__slots__ 是把流动账本换成固定抽屉,memoryview 是透过玻璃窗看货而不搬货,array 是统一规格的标准化货箱。这篇文章就用这座仓库的日常运营,带你搞懂 Python 内存优化的全部关键手段。

岗位关联

学会这一课,你就能在数据处理、服务端开发、模型训练场景中让程序跑得更省内存、更少 OOM、更稳更久,这是每个 Python 后端与 AI 工程师的基本功。

目录
  1. 可视化演示
  2. 对象内存账本
  3. 生成器省内存
  4. slots瘦身
  5. memoryview零拷贝
  6. array紧凑数组
  7. 泄漏检测排查
  8. AI 应用场景
  9. Python 演示
  10. 常见误区
  11. 面试题

可视化演示

动态展示哈希表使用线性探测法解决冲突的过程,直观呈现哈希函数映射与冲突时顺序探测下一个空槽的机制。

本节配有交互动效(进入视口后自动加载,约 3KB)。

哈希表插入操作 计算哈希值定位目标槽位 冲突时线性探测下一空槽 h=k%N 哈希函数计算索引

对象内存账本

小飞第一天盘点仓库,发现每件货物不是只有“净重”这么简单。外包装泡沫箱、贴在上面的运单、放在门口的扫码枪,都要占空间。Python 里的对象也是这样:一个整数、一个列表、一个字典,除了数据本身,还带着对象头、引用指针、哈希表等“包装材料”。你看内存用量时,不能只盯着业务数据的大小。

sys.getsizeof() 就是一把量包装箱的尺子。它告诉你一个对象“自己占了多少内存”,但不会递归去量它引用的其他对象。比如一个列表,它内部其实只是存放了指向各元素对象的指针,真正的大头往往在元素对象身上。列表为了快速追加,还会预留一部分空位,这就是“扩容余量”。了解这三层,你才看得懂内存账单。

CPython 64 位环境下,一个包含 n 个指针的列表,内存大约可以这样估算:

小飞总算明白,仓库爆仓不一定因为货物重,也可能因为包装箱太多。优化内存的第一步,就是学会看每件 Python 对象的“包装账本”。

Mlist=56+8n+8⌊n8⌋≈9n+56M_{\text{list}} = 56 + 8n + 8\left\lfloor\frac{n}{8}\right\rfloor \approx 9n + 56
公式的含义是:列表有一个固定的 56 字节对象头,每个元素指针占 8 字节,并且每次扩容平均多预留约 1/8 的指针空位。
  1. 列表对象头每个列表有一块固定结算区,存放长度、容量、指针数组地址等信息。
    56 字节56 \text{ 字节}
  2. 指针数组列表本身不装数据,只装指向数据的“快递单号”,每张单号 8 字节。
    8n 字节8n \text{ 字节}
  3. 扩容余量为了避免每追加一个元素就搬家,CPython 大约每 8 个元素多留 1 个指针坑位。
    8⌊n8⌋≈n 字节8\left\lfloor\frac{n}{8}\right\rfloor \approx n \text{ 字节}
  4. 总账把固定开销、指针、余量加在一起。
    Mlist≈9n+56M_{\text{list}} \approx 9n + 56

生成器省内存

小飞接到新任务:要把一万件货逐件贴上标签再发走。最笨的办法,是把所有货先搬进仓库、整整齐齐码好,再一件件处理;聪明一点的办法,是在仓库门口架一条传送带,货从左边进来,处理完从右边直接发走,仓库里永远只有一件货。生成器就是这条传送带。

列表推导式会一次性把结果全部装进列表,而生成器表达式和 yield 函数会在每次被 for 循环索要时,才产出下一个值。处理大文件时尤其明显:for line in open(file) 不会把整个文件读进内存,而 readlines() 会。这样做的好处是内存占用不再随数据规模线性上涨。

传送带虽然省地方,但也有代价:货一旦发走就没了。生成器只能从头到尾消费一次,不能回头重新取。小飞在仓库门口立了块牌子:“流水线货物,过后不候。”

Mgen(n)=O(1),Mlist(n)=O(n)M_{\text{gen}}(n) = O(1), \quad M_{\text{list}}(n) = O(n)
生成器的内存复杂度是常数级,列表是线性级。数据量越大,两者的差距越悬殊。
  1. 列表的内存增长n 个数据就要先囤 n 份,内存和 n 成正比。
    Mlist(n)=O(n)M_{\text{list}}(n) = O(n)
  2. 生成器的内存恒定无论处理 1 万条还是 1 亿条,传送带上始终只有当前正在处理的那一条。
    Mgen(n)=O(1)M_{\text{gen}}(n) = O(1)
  3. 对比结论当 n 趋近无穷大时,生成器相对列表的省内存优势趋近于无限。
    lim⁡n→∞Mgen(n)Mlist(n)=0\lim_{n \to \infty} \frac{M_{\text{gen}}(n)}{M_{\text{list}}(n)} = 0

slots瘦身

仓库里有一种特殊货箱,每个箱子都自带一本厚厚的流动账本,记录“这个箱子今天能不能加个备注、明天能不能贴张新标签”。对小飞来说,大多数箱子规格固定,根本不需要账本,只需要几个固定的抽屉就够了。Python 普通类实例里的 __dict__,就是那本流动账本。

默认情况下,每个实例都用一个字典保存属性,这让 Python 很灵活,可以随时 obj.new_attr = 1。但灵活性是有代价的:空字典也要约 64 字节,每个实例还要为弱引用预留指针。当你创建几百万个数据对象时,账本就会把内存吃光。在类里声明 __slots__ = ("x", "y"),就是告诉 Python:“别给我配账本了,我就要这两个固定抽屉。”于是实例不再有 __dict__,内存明显变小,属性访问也更快。

小飞把仓库里的流动账本全部换成了编号抽屉,地方一下子宽裕了。但要注意:如果父类用了 slots,子类不重复声明,子类实例仍会带着 __dict__。想彻底省内存,继承链上每一层都要自己规划好抽屉。

M普通≈56+64+24+8d,Mslots≈56+8sM_{\text{普通}} \approx 56 + 64 + 24 + 8d, \quad M_{\text{slots}} \approx 56 + 8s
普通实例要额外背一本字典账本加弱引用指针;slots 实例只剩下对象头和固定数量的属性指针。
  1. 对象头所有实例都有的固定头部,无论是否使用 slots 都存在。
    56 字节56 \text{ 字节}
  2. 流动账本普通实例的 __dict__ 是一个字典,即使只有两个属性也要约 64 字节。
    64 字节64 \text{ 字节}
  3. 弱引用指针普通实例默认支持 weakref,需要额外约 24 字节。
    24 字节24 \text{ 字节}
  4. 固定货位slots 方案下,每个声明的属性只占一个 8 字节指针。
    8s 字节8s \text{ 字节}

memoryview零拷贝

客户要看一箱大货中间夹着的一小片标签。小飞原本的做法,是把整箱货搬到客户面前;更聪明的做法,是让客户隔着玻璃窗直接看,连货箱都不用挪。普通 bytes 切片就是“搬货”,每切一次就复制一份新内存;memoryview 则是那扇玻璃窗,它直接暴露对象的缓冲区,让你只读或者修改底层数据,却不产生拷贝。

memoryview 支持缓冲区协议的对象,比如 bytes、bytearray、array。对 memoryview 做切片,返回的仍然是一个 memoryview,它记录的是“从哪看到哪、长度多少”,而不是把内容复制出来。处理大文件、网络包、二进制协议时,这能省下大量不必要的临时内存。

玻璃窗虽然不搬货,但窗口信息必须准确。使用 memoryview 时,要保证原对象还活着,底层缓冲区没有失效。小飞在仓库贴上标语:“能隔着窗户看的货,绝不搬出来看。”

Mcopy=O(n),Mview=O(1)M_{\text{copy}} = O(n), \quad M_{\text{view}} = O(1)
普通切片需要复制 n 字节,内存消耗随切片大小增长;memoryview 的视图本身只占固定大小的描述信息。
  1. 普通切片复制data[100:200] 会创建一个新的 bytes 对象,把 100 字节内容搬过去。
    Mcopy=O(n)M_{\text{copy}} = O(n)
  2. memoryview 视图memoryview(data)[100:200] 只是记录起始位置和长度。
    Mview=O(1)M_{\text{view}} = O(1)
  3. 修改回写如果底层是 bytearray 这类可变缓冲区,对视图的修改会直接写回原数据,不需要复制一份再写。
    view[i]=v  ⟹  原缓冲区[i]=v\text{view}[i] = v \implies \text{原缓冲区}[i] = v

array紧凑数组

仓库里有一批同规格的小零件,比如一万个 8 字节的浮点数。如果每件都用 Python 的 float 对象单独包装,再加上列表里 8 字节的指针,内存会非常浪费。小飞的做法,是找厂商定制一排标准货格:每个格子大小完全一样,所有零件紧紧挨在一起。Python 的 array 模块就是这种标准货格。

array('d') 会创建一个 C 语言 double 类型的连续数组,元素在内存里一个挨一个地排布,没有 Python 对象头和指针开销。它适合存储大量同类型的数值数据,类型码 'i' 是整数、'd' 是双精度浮点、'f' 是单精度浮点。相比之下,一个 Python 列表存放 float 对象,每个元素要算上 8 字节指针和 28 字节左右的 float 对象。

小飞把散装货统一装进标准货格后,仓库使用率大幅提升。但标准货格也有约束:所有元素必须是同一种类型,不能像列表那样混装字符串和对象。适合数值密集场景,不适合放异构数据。

Mlist-int≈56+36n,Marray≈80+8nM_{\text{list-int}} \approx 56 + 36n, \quad M_{\text{array}} \approx 80 + 8n
同样存 n 个数值,array 方案只占约 8n 字节,而列表加整数对象约 36n 字节,差距接近 4.5 倍。
  1. 列表指针列表每个槽位是一个 8 字节的指针,指向真正的对象。
    8n 字节8n \text{ 字节}
  2. 整数对象普通 Python 整数对象有对象头和数据区,约 28 字节(小整数缓存除外)。
    28n 字节28n \text{ 字节}
  3. array 的连续存储array('d') 直接按 C 的 double 格式存放,每元素 8 字节。
    8n 字节8n \text{ 字节}
  4. array 头部array 对象自身有约 80 字节的固定开销,与元素数量无关。
    80 字节80 \text{ 字节}

泄漏检测排查

小飞发现最近仓库数据不对劲:明明每天出货量差不多,仓库却越来越满。有人把货搬进了“环形走廊”,A 箱指着 B 箱,B 箱又指着 A 箱,谁都不觉得自己应该被送到垃圾站。在 Python 里,这种互相引用的循环垃圾,引用计数无法归零,需要专门的垃圾回收器来识别。

排查内存泄漏有三件常用工具。tracemalloc 可以记录每块内存分配时的代码位置,对比两次快照就能看出内存被谁持续占用;gc 模块可以手动触发垃圾回收、查看无法回收的对象;objgraph 则能把对象之间的引用关系画成引用图,让你一眼找到“环形走廊”的入口。

小飞终于在环形走廊尽头找到那批“永远走不出去”的货,用弱引用拆掉了回路。内存曲线终于恢复了平稳。真正的优化高手,不只是会省,还会定期盘点,让每个字节的去向都清清楚楚。

Mleak=Mafter−Mbefore>ϵM_{\text{leak}} = M_{\text{after}} - M_{\text{before}} > \epsilon
在业务逻辑稳定的前提下,如果一段时间后内存占用明显大于起始基线,就说明有对象只进不出,需要锁定分配位置。
  1. 基线快照在程序稳定运行时记录一次内存占用。
    MbeforeM_{\text{before}}
  2. 二次快照运行一段时间后再记录一次。
    MafterM_{\text{after}}
  3. 差值判断如果差值大于可忽略的阈值,就说明存在泄漏。
    Mafter−Mbefore>ϵM_{\text{after}} - M_{\text{before}} > \epsilon
  4. 定位与修复用 tracemalloc 找出分配最多的代码行,用 weakref 或显式 del 打破循环引用。
    修复后: Mafter′≈Mbefore\text{修复后: } M_{\text{after}}' \approx M_{\text{before}}

AI 应用场景

日志流式统计引擎

阶段:数据预处理

解决的问题:AI 项目常要解析数百 GB 的日志来构造训练样本。传统 readlines() 会把整个文件读入内存,轻则内存吃紧,重则 OOM 崩溃。用生成器逐行产出,再配合 memoryview 零拷贝解析每一行中的二进制字段,可以让内存峰值恒定在几十 MB。

具体案例:某推荐系统团队处理一天 800 万条 Nginx 日志,统计不同状态码的出现次数。改造前内存峰值约 1.2GB,改用生成器逐行处理和 array 存储计数后,峰值降到 96MB,任务从需要两台高配机器缩到一台低配机器。

特征缓存服务瘦身

阶段:特征工程与在线推理

解决的问题:在线推理服务要常驻内存保存用户特征,如果每个特征对象都带 __dict__,一亿个特征实例会占用数百 GB 内存。给特征类加上 __slots__,并把数值特征改用 array 紧凑存储,能大幅降低常驻内存。

具体案例:某 CTR 预估服务的特征缓存模块,原方案用普通类实例保存 500 万用户特征,常驻内存 720MB。使用 __slots__ 后降到 310MB,再把浮点特征放入 array('d'),最终降到 128MB,服务承载量提升约 4 倍。

长时训练任务泄漏排查

阶段:模型训练与调试

解决的问题:大模型训练跑到十几个 epoch 后内存逐渐上涨直到进程被杀,往往是数据加载器或自定义 callback 里形成了循环引用。用 tracemalloc 做两次快照、用 objgraph 画引用图,可以迅速定位泄漏点。

具体案例:某 NLP 训练任务每跑完 1000 步,内存就多出 2GB。工程师用 tracemalloc 快照对比发现是 Dataset 中的 callback 反向引用了 Dataloader,形成环形引用。改成 weakref 后,内存曲线变得平稳,训练时间缩短了 30%。

Python 演示

python56 行
import sysimport tracemalloc  def sum_squares_with_list(n):    nums = [i for i in range(n)]    return sum(x * x for x in nums)  def sum_squares_with_gen(n):    return sum(x * x for x in range(n))  class NoSlots:    def __init__(self, x, y):        self.x = x        self.y = y  class WithSlots:    __slots__ = ("x", "y")     def __init__(self, x, y):        self.x = x        self.y = y  def total_size(obj):    size = sys.getsizeof(obj)    if hasattr(obj, "__dict__"):        size += sys.getsizeof(obj.__dict__)    return size  def peak_mb(fn):    tracemalloc.start()    fn()    cur, peak = tracemalloc.get_traced_memory()    tracemalloc.stop()    return peak / 1024 / 1024  def main():    n = 300000    list_peak = peak_mb(lambda: sum_squares_with_list(n))    gen_peak = peak_mb(lambda: sum_squares_with_gen(n))    print("list 方案峰值 = %.1f MB" % list_peak)    print("generator 方案峰值 = %.1f MB" % gen_peak)     no_slots = total_size(NoSlots("a", 1))    with_slots = total_size(WithSlots("a", 1))    print("普通实例 = %d 字节, slots实例 = %d 字节" % (no_slots, with_slots))  if __name__ == "__main__":    main()
目的:用实际内存测量对比生成器与列表、slots 与普通实例的真实差距,让你对内存优化有直观的数字感受。
  1. nums = [i for i in range(n)]一次性把 30 万个整数全部囤进列表,内存压力瞬间拉满。
  2. return sum(x * x for x in nums)虽然求和部分用了生成器表达式,但 nums 仍然占着大量内存。
  3. return sum(x * x for x in range(n))不建任何列表,range 本身是惰性的,边取边算。
  4. __slots__ = ("x", "y")告诉 Python 只要两个固定抽屉,不要为实例创建 __dict__ 账本。
  5. if hasattr(obj, "__dict__")手动把字典本身的大小也加进去,才能算出普通实例的真实内存。
  6. tracemalloc.start()启动内存追踪,记录从这一刻开始的每一次分配。
  7. tracemalloc.get_traced_memory()返回当前占用和峰值占用,用来对比两种方案的峰值。

常见误区

误区只要把列表改成生成器,内存一定变小,而且生成器还能像列表一样反复遍历、随时按下标取值。

正解生成器是“过闸式”的数据流,每一份数据被消费后就消失了。如果你要多次遍历同一份数据,或者要随机访问中间某一条,生成器就不合适,只能重新生成或转成列表。省内存的代价是失去“回顾”能力。

为何常见:初学者看到生成器省内存案例后,容易只记住结果,忽略它“单次消费”的底层约束。

误区用了 `__slots__ = ("x", "y")` 之后,就完全不能给实例动态添加任何属性了。

正解它去掉的是自动生成的 __dict__,所以默认不能随意添加新属性。但你可以把 "__dict__" 放进 __slots__ 里保留动态能力,只是这样省内存的效果就没那么明显。slots 的初衷是用固定描述符代替动态字典,换取更小的实例体积和更快的属性访问。

为何常见:很多人把“去掉 __dict__”理解成“禁止一切属性”,但其实它只是把属性集合从动态字典变成固定声明。

误区对 `memoryview` 做切片和普通 `bytes` 切片一样,都会生成一个独立的副本,修改切片不影响原数据。

正解普通 bytes 切片确实会新建 bytes 对象并复制内容;但 memoryview 切片返回的是一个新的 memoryview 对象,它只记录底层缓冲区的位置和长度,不会复制数据。如果底层是可变缓冲区,通过视图修改内容会直接反映到原数据上。

为何常见:大家习惯了 bytes 的“切片即复制”行为,很容易把同样的直觉套到 memoryview 上。

误区`array` 就是长得像列表、但只能存同类型元素,其他方面和 list 差不多。

正解array 的内存模型和 list 完全不同。list 存的是指向 Python 对象的指针,元素对象各自分散;array 直接以 C 语言类型的连续内存存储元素,没有对象头和指针,内存紧凑得多。它支持切片、迭代、索引等类似操作,但不能保存任意异构 Python 对象,也不是通用序列的替代品。

为何常见:用起来确实像 list,容易让初学者忽略它底层是“ C 数组”这一本质。

面试题

Q1. 要逐行读取 5GB 日志文件且不把整个文件载入内存,最合适的方式是?

单选题 · easy

  • A.read() 一次性读取成字符串
  • B.readlines() 存进列表处理
  • C.生成器逐行产出并处理← 正确答案
  • D.先把文件复制到内存盘

答案:C。read() 和 readlines() 都会把大量数据一次装入内存,违背了省内存的目标。生成器逐行读取时,内存里始终只有一行数据,这才是应对超大文件的正确姿势。

Q2. sys.getsizeof([1, 2, 3]) 返回的主要是什么?

单选题 · easy

  • A.列表本身加三个整数总大小
  • B.列表自身和整数 id 的大小
  • C.仅列表自身结构的大小← 正确答案
  • D.Python 进程总内存的大小

答案:C。sys.getsizeof 只统计对象自身占用的内存,包括对象头和内部结构,但不会递归统计它引用的对象。列表里的整数对象要另外用 getsizeof 逐个查看。

Q3. __slots__ = ("x", "y") 最主要的作用是?

单选题 · easy

  • A.允许类使用任意新属性
  • B.自动给属性加上访问锁
  • C.去掉实例字典减小内存← 正确答案
  • D.把属性持久化到磁盘

答案:C。声明 __slots__ 后,实例不再自动生成 __dict__,属性由固定描述符管理,所以内存更小、访问更快。它不是加锁,也不是持久化,更不会允许任意新属性。

Q4. memoryview(b'abcdef')[1:4] 返回的对象是?

单选题 · mid

  • A.bytes 类型且内容为 b'bcd'
  • B.memoryview 零拷贝视图← 正确答案
  • C.bytearray 可变副本
  • D.str 类型字符串对象

答案:B。对 memoryview 做切片,返回的仍是 memoryview,它记录的是底层缓冲区的窗口位置,不会复制出新的 bytes。普通 bytes 切片才会生成新副本。

Q5. array('d', data) 中的元素以什么形式存储?

单选题 · mid

  • A.Python 浮点对象列表
  • B.C 语言 double 类型连续内存← 正确答案
  • C.字符串编码后逐字节存储
  • D.二进制打包的 bytes 数组

答案:B。array('d') 是 C 语言 double 类型的紧凑数组,元素在内存中连续排列,没有 Python 对象头和指针。因此它比普通列表省很多内存,适合数值密集场景。

Q6. 下列哪些手段能显著降低处理大量数据时的内存峰值?

多选题 · mid

  • A.把列表推导改成生成器表达式← 正确答案
  • B.用 readlines() 一次读全文件
  • C.memoryview 切片避免数据复制← 正确答案
  • D.用 slots 优化 Python 整数列表

答案:AC。生成器表达式不一次性囤积数据,memoryview 切片避免拷贝,都能降低峰值。readlines() 会加载整个文件;__slots__ 只能用于自定义类,不能作用于整数对象的列表。

Q7. 关于循环引用和垃圾回收,下列说法正确的有?

多选题 · mid

  • A.引用计数无法单独回收循环引用← 正确答案
  • B.gc 模块能回收不可达的环← 正确答案
  • C.weakref 可帮助打破循环依赖← 正确答案
  • D.tracemalloc 会自动删除泄漏对象

答案:ABC。循环引用中每个对象引用计数都大于 0,引用计数机制无法归零,需要 gc 从根集合出发找不可达对象。weakref 不增加引用计数,可以拆掉环。tracemalloc 只负责追踪和定位,不会自动删除对象。

Q8. 100 万个普通 Python 整数放进列表,估算包含整数对象和指针的总内存约为?

单选题 · hard

  • A.约 8MB
  • B.约 36MB← 正确答案
  • C.约 56MB
  • D.约 1GB

答案:B。每个列表指针 8 字节,每个非驻留的 Python 整数对象约 28 字节,合计约 36 字节一个元素。100 万乘以 36 字节约为 36MB。如果只要 8MB,那只是指针数组的大小。

Q9. 用生成器替代列表处理数据,最主要的限制是?

单选题 · hard

  • A.无法用 for 循环遍历结果
  • B.数据只能被消费一次← 正确答案
  • C.生成速度慢几个数量级
  • D.必须一次性传入所有参数

答案:B。生成器是惰性数据流,遍历到哪就消费到哪,无法回头。for 循环完全支持生成器,生成速度也不慢,但“只能前进不能后退”的特性让它不适合需要多次访问同一批数据的场景。

Q10. 关于继承与 __slots__ 的组合使用,正确的说法有?

多选题 · extreme

  • A.父类无 slots 时子类仍可声明 slots← 正确答案
  • B.父类有 slots 子类不写仍会生成 dict← 正确答案
  • C.多继承多个槽描述符可能报错← 正确答案
  • D.子类必须重复声明父类已有槽

答案:ABC。如果父类没有 __slots__,子类声明 slots 也只能管理子类新增属性,且通常仍带着字典;如果父类有 slots 而子类不声明,子类实例会自动获得 __dict__。多继承时多个非空 slots 可能引发 TypeError。已声明的槽会被继承,不需要重复声明。