Python 与内存管理
Python 与内存管理
前面已经回答过「对象在哪」:在 CPython 的私有堆上。这一篇把剩下的问题串起来——对象由什么组成、小块怎么分配、引用怎么计数、环怎么回收、为什么删了对象 RSS 还不降、以及怎么用工具看见这一切。
1. 一张总表
| 问题 | 机制 | 观测工具 |
|---|---|---|
| 一个对象占多少字节? | PyObject 头 + 类型负载 | sys.getsizeof |
| 谁在引用它? | ob_refcnt 引用计数 | sys.getrefcount |
| 小块内存从哪来? | pymalloc:arena → pool → block | sys._debugmallocstats |
| 大块内存从哪来? | malloc / mmap | tracemalloc、psutil |
| 循环引用怎么回收? | 分代 GC(只跟踪容器) | gc.collect、gc.get_objects |
| 为什么内存不还给 OS? | free list / arena 缓存 | RSS、/proc/<pid>/smaps |
| 怎么避免泄漏? | 弱引用、上下文管理、快照对比 | tracemalloc、objgraph |
2. 对象布局:头部 + 负载
每个 Python 对象都以两个字段开头:
typedef struct _object {
Py_ssize_t ob_refcnt; // 引用计数
PyTypeObject *ob_type; // 类型指针
} PyObject;变长对象再多一个长度字段:
typedef struct {
PyObject ob_base;
Py_ssize_t ob_size; // 元素个数
} PyVarObject;具体类型在头部后面放自己的负载:
PyLongObject: [refcnt][type][ob_size][ob_digit...] 任意精度整数
PyListObject: [refcnt][type][ob_size][ob_item][allocated]
PyDictObject: [refcnt][type][ma_used][ma_keys][...]
PyBytesObject: [refcnt][type][ob_size][ob_shash][data...]import sys
print(sys.getsizeof(1)) # 本机 3.13:28 = 24 字节头 + 4 字节数值
print(sys.getsizeof(2**100)) # 40:24 字节头 + 4 个 30-bit digit × 4 字节
print(sys.getsizeof([])) # 56:40 字节 PyListObject + 16 字节 GC 头;item 数组未分配
print(sys.getsizeof([1, 2, 3]))# 88:56 + 4 槽 × 8 字节id(obj) 在 CPython 里就是对象地址;CPython 的 GC 不移动对象,所以对象存活期间 id 不变。sys.getsizeof 只算对象自身,不递归引用对象,也不包含 malloc 的元数据。
3. 分配器:pymalloc 的三层
CPython 把内存请求分成三个域:
| 域 | API | 用途 |
|---|---|---|
| Raw | PyMem_RawMalloc | 不持有 GIL 时也能用的原始内存 |
| Mem | PyMem_Malloc | 通用缓冲区 |
| Object | PyObject_Malloc | Python 对象 |
对象域的小块分配走 pymalloc:
arena(64 位 3.11+ 实测 1 MiB,由 mmap 申请)
└─ pool(16 KiB,按 4 KiB 页对齐)
└─ block(16/32/48/…/512 B,共 32 个 size class)- ≤ 512 字节:按 size class 从 pool 的空闲链取 block;
- > 512 字节:交给
malloc/mmap; - 释放:block 回到 pool 的 free list,pool 回到 arena 的空闲链;arena 只有全部空闲时才可能
munmap/free还给 OS。
import sys
sys._debugmallocstats()本机 3.13 的片段:
Small block threshold = 512, in 32 size classes.
2 arenas * 1048576 bytes/arena
23 unused pools * 16384 bytes版本提醒:arena/pool 的尺寸随平台和版本变化。64 位 3.11+ 实测 1 MiB / 16 KiB;更早版本或 32 位可能不同。不要把这些数字当成语言规范。
可以用环境变量切换分配器:
PYTHONMALLOC=malloc python app.py # 关闭 pymalloc,全部走 malloc
PYTHONMALLOC=debug python app.py # 加保护字节,检测越界/重复释放debug 模式会显著变慢,只适合排查内存破坏。
4. 引用计数:对象头的第一个字段
每次绑定名字、放进容器、存到属性里,引用计数通常加 1;删除绑定、移出容器、覆盖属性,通常减 1。
import sys
a = []
print(sys.getrefcount(a)) # 2:a 本身 + getrefcount 的实参临时引用
b = a
print(sys.getrefcount(a)) # 3:a、b、临时引用
del b
print(sys.getrefcount(a)) # 2sys.getrefcount() 的实参本身会临时增加一个引用,所以读数总比「业务引用数」多 1 左右。它也不等于源码里的变量个数:容器、属性、闭包 cell、解释器缓存都可能持有引用。
几个特殊点:
- 小整数缓存:
-5..256在解释器启动时创建并复用;a = 100; b = 100可能指向同一个对象。 - 字符串驻留:符合标识符规则的短字符串、编译期常量可能被 intern;不要依赖
is判断值相等。 - 不朽对象:3.12+ 把
None、True/False、小整数、静态类型等标记为 immortal,引用计数不再变化。sys.getrefcount(1)会返回一个极大的数(本机 3.13 是4294967295)。 - 自由线程构建:3.13 实验性、3.14 起官方支持;它使用偏向引用计数/延迟计数等机制,读数的语义与 GIL 构建不同。
引用计数归零后,调用类型的 tp_dealloc:list 释放 ob_item 数组和对象本身,dict 释放 keys/entries,int 释放 digit 段。内存回到分配器,不代表回到操作系统。
5. 循环引用与分代 GC
class Node:
def __init__(self):
self.peer = None
a = Node()
b = Node()
a.peer = b
b.peer = a
del a, b # 两个对象仍然互相引用,计数都不为 0引用计数只看「有多少引用」,分不清引用来自活跃代码还是同一批废弃对象。CPython 的循环 GC 不是从进程根集合做完整 mark-sweep,而是用 trial deletion:先把每个对象对其它 GC 对象的引用数从 gc_refs 里减掉,剩下 gc_refs > 0 的对象作为内部根,再从这些根标记可达对象,未标记的才回收。
关键事实:
- GC 只跟踪容器类对象(list、dict、set、自定义类等),int/str 不参与。
- 分三代:新对象在 gen0,活过一次回收进入 gen1,再进入 gen2;gen0 回收最频繁。
- 阈值随版本变化:3.11/3.12 常见
(700, 10, 10),3.13 常见(2000, 10, 10),不要背死。 gc.collect()默认回收全部三代;gc.collect(0)只回收 gen0。- 3.7+ 的
gc.freeze()把当前对象移到永久代,fork 前调用可以避免子进程反复扫描父进程的只读对象。 - 3.4+(PEP 442)支持在循环中正确调用
__del__,但__del__仍然不是可靠的析构时机;资源释放用with/try/finally。
import gc
print(gc.get_count()) # 当前各代计数
print(gc.collect()) # 返回回收的对象数
gc.disable() # 关闭自动回收(慎用)
gc.enable()6. 弱引用:观察但不延长寿命
弱引用不增加引用计数,适合做缓存、观察者、父子关系里的反向指针。
import weakref
class User:
pass
u = User()
r = weakref.ref(u)
print(r()) # <User object>
del u
print(r()) # None:对象已被回收
cache = weakref.WeakValueDictionary()
u = User() # 必须保留一个强引用
cache["alice"] = u
print(cache["alice"]) # 有强引用,能取到
del u
print(cache.get("alice")) # None:对象被回收,条目自动消失WeakValueDictionary 只持有弱引用:赋值后如果没有任何其它强引用,条目会立刻消失,随后 cache["alice"] 会抛 KeyError。
注意:
list、dict本身不能直接被弱引用;自定义类默认可以,除非定义了__slots__且没有包含__weakref__。WeakKeyDictionary的键是弱引用,适合「对象销毁时自动清理元数据」。- 弱引用回调在对象即将销毁时执行,不要在回调里做重活。
7. 内存观测:三个工具,三种视角
| 工具 | 视角 | 适合回答 |
|---|---|---|
sys.getsizeof | 单个对象自身 | 「这个对象多大?」 |
tracemalloc | Python 分配器 | 「哪一行代码分配了最多内存?」 |
psutil / resource | 进程 RSS | 「进程实际占了多少物理内存?」 |
gc.get_objects | GC 跟踪的对象 | 「现在有哪些容器对象?」 |
objgraph(第三方) | 引用图 | 「谁在引用它?为什么没释放?」 |
7.1 tracemalloc 快照对比
import tracemalloc
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
# ... 跑一段业务 ...
snap2 = tracemalloc.take_snapshot()
for stat in snap2.compare_to(snap1, "lineno")[:5]:
print(stat)
current, peak = tracemalloc.get_traced_memory()
print(f"current={current / 1024 / 1024:.2f} MiB, peak={peak / 1024 / 1024:.2f} MiB")tracemalloc 看不到 C 扩展直接 malloc 的内存,也不跟踪所有解释器内部结构;它擅长定位「Python 层谁分配了什么」。
7.2 RSS 为什么没降
import os
import psutil
p = psutil.Process(os.getpid())
print(p.memory_info().rss) # 实际驻留内存删除大量对象后 RSS 不降,通常是因为:
- 对象内存回到了 pymalloc 的 free list,没有还给 OS;
- 大块内存回到了 glibc 的 arena,glibc 保留着;
- Python 进程的堆高水位已经上去,后续分配复用空闲块。
这不一定是泄漏。判断泄漏要看「持续增长且不回落」,而不是看单次删除后的 RSS。
8. 常见泄漏与优化
8.1 常见泄漏形态
| 形态 | 例子 | 处理 |
|---|---|---|
| 全局缓存无上限 | CACHE[key] = value | functools.lru_cache(maxsize=...) / WeakValueDictionary |
| 资源未关闭 | 文件、socket、DB 连接 | with / try/finally |
| 闭包持有大对象 | 回调里引用大 list | 只捕获需要的字段,或显式 del |
| 异常 traceback | 异常对象保存了帧和局部变量 | 不要长期保存异常对象 |
| 线程本地存储 | threading.local() 持有请求数据 | 请求结束清理 |
| C 扩展分配 | 第三方库内部缓存 | 查库文档 / tracemalloc + RSS |
循环 + __del__ | 旧版本 Python | 3.4+ 已改善,但仍应避免依赖 __del__ |
8.2 省内存的写法
# 1. __slots__:去掉实例 __dict__
class Point:
__slots__ = ("x", "y")
def __init__(self, x, y):
self.x = x
self.y = y
# 2. 生成器:不要一次物化整个列表
def read_lines(path):
with open(path) as f:
for line in f:
yield line.strip()
# 3. 紧凑数值数组
from array import array
nums = array("i", range(1_000_000)) # 每个元素 4 字节,不是 PyObject
# 4. 字符串拼接用 join
parts = ["a", "b", "c"]
s = "".join(parts)
# 5. 缓存设上限
from functools import lru_cache
@lru_cache(maxsize=1024)
def expensive(n):
...__slots__ 的代价:不能动态加属性;需要弱引用时要把 "__weakref__" 加进 slots;继承时子类仍可能重新引入 __dict__。
9. 版本差异
- 3.11:解释器帧和求值栈的布局调整,帧对象按需创建;
sys._debugmallocstats的输出格式可能变化。 - 3.12:PEP 683 不朽对象;
sys.getrefcount(1)变成极大值;GC 默认阈值可能调整。 - 3.13:实验性 free-threaded 构建(PEP 703),对象分配默认使用 mimalloc,引用计数采用偏向/延迟机制;GIL 构建仍然使用 pymalloc。
- 3.14+:GC 的分代策略仍在演进,具体阈值与代数行为以对应版本文档为准。
10. 动手实验
import gc
import sys
import tracemalloc
import weakref
# 1. 引用计数与不朽对象
a = []
print("list refcount:", sys.getrefcount(a))
print("int 1 refcount:", sys.getrefcount(1)) # 3.12+:极大值
# 2. 循环引用
class Node:
pass
x, y = Node(), Node()
x.peer, y.peer = y, x
del x, y
print("collected:", gc.collect())
# 3. 弱引用
u = Node()
r = weakref.ref(u)
del u
print("weakref after del:", r())
# 4. tracemalloc
tracemalloc.start()
snap1 = tracemalloc.take_snapshot()
data = [str(i) for i in range(100_000)]
snap2 = tracemalloc.take_snapshot()
print(snap2.compare_to(snap1, "lineno")[0])11. 小结
- 对象 = 头部(引用计数 + 类型) + 类型负载;
id是地址,对象不移动。 - 小块走 pymalloc(arena → pool → block),大块走
malloc/mmap;释放回空闲链,不保证还 OS。 - 引用计数负责大多数对象的即时回收;分代 GC 只负责容器之间的环。
del删除的是绑定,不是对象;对象是否消失看引用计数和可达性。- 观测要分三层:
getsizeof看对象、tracemalloc看分配、RSS 看进程。 - 防泄漏的关键不是「记得
del」,而是给缓存设上限、用上下文管理资源、避免长期持有大对象。