小飞是一名刚接管仓库的新管理员。他以为内存优化就像“多买几个货架”那么直观,直到有一天,服务器像仓库门被货物堵实一样,直接 OOM 崩溃了。老板丢给他一句话:真正的高手不是会买货架,而是知道每件货怎么放、放多少、什么时候可以不搬货。Python 里的内存也正是一座仓库:每个对象是一件货物,列表是一排货架,生成器是一条随取随走的传送带,__slots__ 是把流动账本换成固定抽屉,memoryview 是透过玻璃窗看货而不搬货,array 是统一规格的标准化货箱。这篇文章就用这座仓库的日常运营,带你搞懂 Python 内存优化的全部关键手段。
学会这一课,你就能在数据处理、服务端开发、模型训练场景中让程序跑得更省内存、更少 OOM、更稳更久,这是每个 Python 后端与 AI 工程师的基本功。
可视化演示
动态展示哈希表使用线性探测法解决冲突的过程,直观呈现哈希函数映射与冲突时顺序探测下一个空槽的机制。
本节配有交互动效(进入视口后自动加载,约 3KB)。
哈希表插入操作 计算哈希值定位目标槽位 冲突时线性探测下一空槽 h=k%N 哈希函数计算索引
对象内存账本
小飞第一天盘点仓库,发现每件货物不是只有“净重”这么简单。外包装泡沫箱、贴在上面的运单、放在门口的扫码枪,都要占空间。Python 里的对象也是这样:一个整数、一个列表、一个字典,除了数据本身,还带着对象头、引用指针、哈希表等“包装材料”。你看内存用量时,不能只盯着业务数据的大小。
sys.getsizeof() 就是一把量包装箱的尺子。它告诉你一个对象“自己占了多少内存”,但不会递归去量它引用的其他对象。比如一个列表,它内部其实只是存放了指向各元素对象的指针,真正的大头往往在元素对象身上。列表为了快速追加,还会预留一部分空位,这就是“扩容余量”。了解这三层,你才看得懂内存账单。
CPython 64 位环境下,一个包含 n 个指针的列表,内存大约可以这样估算:
小飞总算明白,仓库爆仓不一定因为货物重,也可能因为包装箱太多。优化内存的第一步,就是学会看每件 Python 对象的“包装账本”。
- 列表对象头每个列表有一块固定结算区,存放长度、容量、指针数组地址等信息。
- 指针数组列表本身不装数据,只装指向数据的“快递单号”,每张单号 8 字节。
- 扩容余量为了避免每追加一个元素就搬家,CPython 大约每 8 个元素多留 1 个指针坑位。
- 总账把固定开销、指针、余量加在一起。
生成器省内存
小飞接到新任务:要把一万件货逐件贴上标签再发走。最笨的办法,是把所有货先搬进仓库、整整齐齐码好,再一件件处理;聪明一点的办法,是在仓库门口架一条传送带,货从左边进来,处理完从右边直接发走,仓库里永远只有一件货。生成器就是这条传送带。
列表推导式会一次性把结果全部装进列表,而生成器表达式和 yield 函数会在每次被 for 循环索要时,才产出下一个值。处理大文件时尤其明显:for line in open(file) 不会把整个文件读进内存,而 readlines() 会。这样做的好处是内存占用不再随数据规模线性上涨。
传送带虽然省地方,但也有代价:货一旦发走就没了。生成器只能从头到尾消费一次,不能回头重新取。小飞在仓库门口立了块牌子:“流水线货物,过后不候。”
- 列表的内存增长
n个数据就要先囤n份,内存和n成正比。 - 生成器的内存恒定无论处理 1 万条还是 1 亿条,传送带上始终只有当前正在处理的那一条。
- 对比结论当
n趋近无穷大时,生成器相对列表的省内存优势趋近于无限。
slots瘦身
仓库里有一种特殊货箱,每个箱子都自带一本厚厚的流动账本,记录“这个箱子今天能不能加个备注、明天能不能贴张新标签”。对小飞来说,大多数箱子规格固定,根本不需要账本,只需要几个固定的抽屉就够了。Python 普通类实例里的 __dict__,就是那本流动账本。
默认情况下,每个实例都用一个字典保存属性,这让 Python 很灵活,可以随时 obj.new_attr = 1。但灵活性是有代价的:空字典也要约 64 字节,每个实例还要为弱引用预留指针。当你创建几百万个数据对象时,账本就会把内存吃光。在类里声明 __slots__ = ("x", "y"),就是告诉 Python:“别给我配账本了,我就要这两个固定抽屉。”于是实例不再有 __dict__,内存明显变小,属性访问也更快。
小飞把仓库里的流动账本全部换成了编号抽屉,地方一下子宽裕了。但要注意:如果父类用了 slots,子类不重复声明,子类实例仍会带着 __dict__。想彻底省内存,继承链上每一层都要自己规划好抽屉。
- 对象头所有实例都有的固定头部,无论是否使用 slots 都存在。
- 流动账本普通实例的
__dict__是一个字典,即使只有两个属性也要约 64 字节。 - 弱引用指针普通实例默认支持
weakref,需要额外约 24 字节。 - 固定货位slots 方案下,每个声明的属性只占一个 8 字节指针。
memoryview零拷贝
客户要看一箱大货中间夹着的一小片标签。小飞原本的做法,是把整箱货搬到客户面前;更聪明的做法,是让客户隔着玻璃窗直接看,连货箱都不用挪。普通 bytes 切片就是“搬货”,每切一次就复制一份新内存;memoryview 则是那扇玻璃窗,它直接暴露对象的缓冲区,让你只读或者修改底层数据,却不产生拷贝。
memoryview 支持缓冲区协议的对象,比如 bytes、bytearray、array。对 memoryview 做切片,返回的仍然是一个 memoryview,它记录的是“从哪看到哪、长度多少”,而不是把内容复制出来。处理大文件、网络包、二进制协议时,这能省下大量不必要的临时内存。
玻璃窗虽然不搬货,但窗口信息必须准确。使用 memoryview 时,要保证原对象还活着,底层缓冲区没有失效。小飞在仓库贴上标语:“能隔着窗户看的货,绝不搬出来看。”
n 字节,内存消耗随切片大小增长;memoryview 的视图本身只占固定大小的描述信息。- 普通切片复制
data[100:200]会创建一个新的 bytes 对象,把 100 字节内容搬过去。 - memoryview 视图
memoryview(data)[100:200]只是记录起始位置和长度。 - 修改回写如果底层是
bytearray这类可变缓冲区,对视图的修改会直接写回原数据,不需要复制一份再写。
array紧凑数组
仓库里有一批同规格的小零件,比如一万个 8 字节的浮点数。如果每件都用 Python 的 float 对象单独包装,再加上列表里 8 字节的指针,内存会非常浪费。小飞的做法,是找厂商定制一排标准货格:每个格子大小完全一样,所有零件紧紧挨在一起。Python 的 array 模块就是这种标准货格。
array('d') 会创建一个 C 语言 double 类型的连续数组,元素在内存里一个挨一个地排布,没有 Python 对象头和指针开销。它适合存储大量同类型的数值数据,类型码 'i' 是整数、'd' 是双精度浮点、'f' 是单精度浮点。相比之下,一个 Python 列表存放 float 对象,每个元素要算上 8 字节指针和 28 字节左右的 float 对象。
小飞把散装货统一装进标准货格后,仓库使用率大幅提升。但标准货格也有约束:所有元素必须是同一种类型,不能像列表那样混装字符串和对象。适合数值密集场景,不适合放异构数据。
n 个数值,array 方案只占约 8n 字节,而列表加整数对象约 36n 字节,差距接近 4.5 倍。- 列表指针列表每个槽位是一个 8 字节的指针,指向真正的对象。
- 整数对象普通 Python 整数对象有对象头和数据区,约 28 字节(小整数缓存除外)。
- array 的连续存储
array('d')直接按 C 的double格式存放,每元素 8 字节。 - array 头部array 对象自身有约 80 字节的固定开销,与元素数量无关。
泄漏检测排查
小飞发现最近仓库数据不对劲:明明每天出货量差不多,仓库却越来越满。有人把货搬进了“环形走廊”,A 箱指着 B 箱,B 箱又指着 A 箱,谁都不觉得自己应该被送到垃圾站。在 Python 里,这种互相引用的循环垃圾,引用计数无法归零,需要专门的垃圾回收器来识别。
排查内存泄漏有三件常用工具。tracemalloc 可以记录每块内存分配时的代码位置,对比两次快照就能看出内存被谁持续占用;gc 模块可以手动触发垃圾回收、查看无法回收的对象;objgraph 则能把对象之间的引用关系画成引用图,让你一眼找到“环形走廊”的入口。
小飞终于在环形走廊尽头找到那批“永远走不出去”的货,用弱引用拆掉了回路。内存曲线终于恢复了平稳。真正的优化高手,不只是会省,还会定期盘点,让每个字节的去向都清清楚楚。
- 基线快照在程序稳定运行时记录一次内存占用。
- 二次快照运行一段时间后再记录一次。
- 差值判断如果差值大于可忽略的阈值,就说明存在泄漏。
- 定位与修复用
tracemalloc找出分配最多的代码行,用weakref或显式del打破循环引用。
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 演示
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()
nums = [i for i in range(n)]一次性把 30 万个整数全部囤进列表,内存压力瞬间拉满。return sum(x * x for x in nums)虽然求和部分用了生成器表达式,但nums仍然占着大量内存。return sum(x * x for x in range(n))不建任何列表,range本身是惰性的,边取边算。__slots__ = ("x", "y")告诉 Python 只要两个固定抽屉,不要为实例创建__dict__账本。if hasattr(obj, "__dict__")手动把字典本身的大小也加进去,才能算出普通实例的真实内存。tracemalloc.start()启动内存追踪,记录从这一刻开始的每一次分配。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 日志文件且不把整个文件载入内存,最合适的方式是?
- A.read() 一次性读取成字符串
- B.readlines() 存进列表处理
- C.生成器逐行产出并处理← 正确答案
- D.先把文件复制到内存盘
答案:C。read() 和 readlines() 都会把大量数据一次装入内存,违背了省内存的目标。生成器逐行读取时,内存里始终只有一行数据,这才是应对超大文件的正确姿势。
Q2. sys.getsizeof([1, 2, 3]) 返回的主要是什么?
- A.列表本身加三个整数总大小
- B.列表自身和整数 id 的大小
- C.仅列表自身结构的大小← 正确答案
- D.Python 进程总内存的大小
答案:C。sys.getsizeof 只统计对象自身占用的内存,包括对象头和内部结构,但不会递归统计它引用的对象。列表里的整数对象要另外用 getsizeof 逐个查看。
Q3. __slots__ = ("x", "y") 最主要的作用是?
- A.允许类使用任意新属性
- B.自动给属性加上访问锁
- C.去掉实例字典减小内存← 正确答案
- D.把属性持久化到磁盘
答案:C。声明 __slots__ 后,实例不再自动生成 __dict__,属性由固定描述符管理,所以内存更小、访问更快。它不是加锁,也不是持久化,更不会允许任意新属性。
Q4. memoryview(b'abcdef')[1:4] 返回的对象是?
- A.bytes 类型且内容为 b'bcd'
- B.memoryview 零拷贝视图← 正确答案
- C.bytearray 可变副本
- D.str 类型字符串对象
答案:B。对 memoryview 做切片,返回的仍是 memoryview,它记录的是底层缓冲区的窗口位置,不会复制出新的 bytes。普通 bytes 切片才会生成新副本。
Q5. array('d', data) 中的元素以什么形式存储?
- A.Python 浮点对象列表
- B.C 语言 double 类型连续内存← 正确答案
- C.字符串编码后逐字节存储
- D.二进制打包的 bytes 数组
答案:B。array('d') 是 C 语言 double 类型的紧凑数组,元素在内存中连续排列,没有 Python 对象头和指针。因此它比普通列表省很多内存,适合数值密集场景。
Q6. 下列哪些手段能显著降低处理大量数据时的内存峰值?
- A.把列表推导改成生成器表达式← 正确答案
- B.用 readlines() 一次读全文件
- C.memoryview 切片避免数据复制← 正确答案
- D.用 slots 优化 Python 整数列表
答案:AC。生成器表达式不一次性囤积数据,memoryview 切片避免拷贝,都能降低峰值。readlines() 会加载整个文件;__slots__ 只能用于自定义类,不能作用于整数对象的列表。
Q7. 关于循环引用和垃圾回收,下列说法正确的有?
- A.引用计数无法单独回收循环引用← 正确答案
- B.gc 模块能回收不可达的环← 正确答案
- C.weakref 可帮助打破循环依赖← 正确答案
- D.tracemalloc 会自动删除泄漏对象
答案:ABC。循环引用中每个对象引用计数都大于 0,引用计数机制无法归零,需要 gc 从根集合出发找不可达对象。weakref 不增加引用计数,可以拆掉环。tracemalloc 只负责追踪和定位,不会自动删除对象。
Q8. 100 万个普通 Python 整数放进列表,估算包含整数对象和指针的总内存约为?
- A.约 8MB
- B.约 36MB← 正确答案
- C.约 56MB
- D.约 1GB
答案:B。每个列表指针 8 字节,每个非驻留的 Python 整数对象约 28 字节,合计约 36 字节一个元素。100 万乘以 36 字节约为 36MB。如果只要 8MB,那只是指针数组的大小。
Q9. 用生成器替代列表处理数据,最主要的限制是?
- A.无法用 for 循环遍历结果
- B.数据只能被消费一次← 正确答案
- C.生成速度慢几个数量级
- D.必须一次性传入所有参数
答案:B。生成器是惰性数据流,遍历到哪就消费到哪,无法回头。for 循环完全支持生成器,生成速度也不慢,但“只能前进不能后退”的特性让它不适合需要多次访问同一批数据的场景。
Q10. 关于继承与 __slots__ 的组合使用,正确的说法有?
- A.父类无 slots 时子类仍可声明 slots← 正确答案
- B.父类有 slots 子类不写仍会生成 dict← 正确答案
- C.多继承多个槽描述符可能报错← 正确答案
- D.子类必须重复声明父类已有槽
答案:ABC。如果父类没有 __slots__,子类声明 slots 也只能管理子类新增属性,且通常仍带着字典;如果父类有 slots 而子类不声明,子类实例会自动获得 __dict__。多继承时多个非空 slots 可能引发 TypeError。已声明的槽会被继承,不需要重复声明。