跳到主要内容

Python 与内存管理

前面已经回答过「对象在哪」:在 CPython 的私有堆上。这一篇把剩下的问题串起来——对象由什么组成、小块怎么分配、引用怎么计数、环怎么回收、为什么删了对象 RSS 还不降、以及怎么用工具看见这一切。

1. 一张总表​

问题机制观测工具
一个对象占多少字节?PyObject 头 + 类型负载sys.getsizeof
谁在引用它?ob_refcnt 引用计数sys.getrefcount
小块内存从哪来?pymalloc:arena → pool → blocksys._debugmallocstats
大块内存从哪来?malloc / mmaptracemalloc、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用途
RawPyMem_RawMalloc不持有 GIL 时也能用的原始内存
MemPyMem_Malloc通用缓冲区
ObjectPyObject_MallocPython 对象

对象域的小块分配走 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)) # 2

sys.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单个对象自身「这个对象多大?」
tracemallocPython 分配器「哪一行代码分配了最多内存?」
psutil / resource进程 RSS「进程实际占了多少物理内存?」
gc.get_objectsGC 跟踪的对象「现在有哪些容器对象?」
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] = valuefunctools.lru_cache(maxsize=...) / WeakValueDictionary
资源未关闭文件、socket、DB 连接with / try/finally
闭包持有大对象回调里引用大 list只捕获需要的字段,或显式 del
异常 traceback异常对象保存了帧和局部变量不要长期保存异常对象
线程本地存储threading.local() 持有请求数据请求结束清理
C 扩展分配第三方库内部缓存查库文档 / tracemalloc + RSS
循环 + __del__旧版本 Python3.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」,而是给缓存设上限、用上下文管理资源、避免长期持有大对象。
加载评论中...