内存分配机制:Python 的内存哲学

聊双缓冲之前,得先把 Python 的内存机制摸透。我见过不少同学,代码写得飞起,一跑大数据就崩,根本不知道内存去哪了。说白了,Python 的内存管理是个「黑盒」,但你必须得懂它怎么运作。

我个人习惯把 Python 的内存机制分成三层:分配层、引用层、回收层。咱们一层层拆开看。

Python 内存管理基础

Python 的内存分配,不是直接跟操作系统打交道的。它自己维护了一个「内存池」。你想想看,每次创建一个整数都要去 OS 申请内存,那得多慢?

Python 的内存架构大概是这样的:

Python 内存分配层级 Python 对象(int, str, list...) Python 内存分配器(PyMem_Malloc) 内存池(Arena → Pool → Block) 小块对象(≤512字节)走内存池,大块对象直接 malloc 操作系统(malloc / free) 核心原则:减少系统调用,提升小对象分配效率

这里有个关键点:Python 优先用内存池。只有大于 512 字节的对象,才会直接走系统的 malloc。我刚开始做优化时,总觉得内存池是小事,直到有一次处理百万级的小对象——嗯,性能差距直接差了一个数量级。

内存池的核心优势:

  • 减少系统调用次数(一次 arena 申请顶几百次 malloc)
  • 避免内存碎片(固定大小 block 复用)
  • 分配速度极快(指针偏移,无锁操作)

对象引用与垃圾回收

Python 的垃圾回收,说白了就两招:引用计数 + 分代回收。

引用计数是最直接的。每个对象内部有个 ob_refcnt,你赋值、传参、放入容器,计数就加 1;del 或者离开作用域,计数就减 1。减到 0 的时候,内存立刻回收。

但引用计数有个坑——循环引用。两个对象互相指着,计数永远不为 0,内存就泄漏了。我曾在项目中遇到过这种情况:一个缓存系统里,对象互相引用,跑了一周内存涨了 3 个 G,查了半天才发现是循环引用导致的。

这时候就需要分代回收出马了。

代 触发条件 扫描频率 典型对象
第 0 代 分配次数 - 回收次数 > 700 最高 临时变量、局部对象
第 1 代 第 0 代回收 10 次后 中等 存活一轮的对象
第 2 代 第 1 代回收 10 次后 最低 长期存活的对象

分代回收的原理很简单:大部分对象活不久。你想想看,函数里的临时变量,用完就扔,根本没必要每次都扫描整个堆。所以 Python 把对象分了三代,新对象放第 0 代,熬过一轮就升到第 1 代,再熬过一轮升到第 2 代。回收时优先扫第 0 代,效率高得多。

我的建议:如果确定代码里没有循环引用,可以手动关闭分代回收:

import gc
gc.disable()  # 关闭自动回收
# 在合适的时机手动触发
gc.collect()

但别乱用。我一般只在性能敏感的循环里临时关一下,跑完再开。

内存池与小块对象

这块是 Python 内存优化的精髓。Python 的内存池,专门处理 ≤512 字节的小对象。它把内存分成三层:

  • Arena:256KB 的大块,从 OS 一次性申请
  • Pool:4KB 的块,从 Arena 切分出来
  • Block:8~512 字节的单元,从 Pool 切分

每个 Pool 只存放一种固定大小的 Block。比如 32 字节的 Pool,里面全是 32 字节的 Block。这样分配时直接拿一个 Block,释放时直接标记为空闲,完全不需要合并碎片。

我曾经优化过一个图像处理脚本,里面全是小尺寸的 numpy 数组和元组。默认情况下,每个小对象都走内存池,但频繁创建销毁导致 Pool 切换开销很大。后来我用了 __slots__ 和对象池复用,内存分配次数直接降了 60%。

注意:内存池不是万能的。如果你频繁创建大量小对象,即使内存池再快,分配和释放的开销也会累积。这时候可以考虑:

  • 用 array 模块代替 list 存数值
  • 用 __slots__ 减少实例字典开销
  • 手动实现对象池,复用对象

来看一个实际对比:

import sys

# 普通对象
class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

p = Point(1, 2)
print(sys.getsizeof(p))  # 56 字节(含 __dict__)

# 使用 __slots__
class PointSlots:
    __slots__ = ('x', 'y')
    def __init__(self, x, y):
        self.x = x
        self.y = y

ps = PointSlots(1, 2)
print(sys.getsizeof(ps))  # 32 字节(无 __dict__)

看到了吗?__slots__ 直接省了 24 字节。如果有一百万个点对象,那就是 24MB 的差距。在双缓冲场景下,这种优化尤其重要——缓冲区里存的就是大量小对象。

总结一下内存池的关键点:

  1. 小对象(≤512B)走内存池,大对象直接 malloc
  2. 内存池按固定大小 Block 分配,无碎片
  3. Arena 是 256KB 的整块,一次申请多次复用
  4. 频繁创建小对象时,考虑对象池或 __slots__