主题
第 3 章:内存管理与垃圾回收
"The question of whether a computer can think is no more interesting than the question of whether a submarine can swim." — Edsger W. Dijkstra
你写下
x = [1, 2, 3],Python 在某个看不见的角落为它安排了一块内存。 当x不再被需要时,Python 默默地回收了它——你甚至不知道这件事发生过。 这种"看不见"的优雅背后,是一套精密的内存管理系统。 理解它,不是为了手动管理内存——而是为了知道何时这种优雅会失效。
📖 本章内容
Python 内存架构
🧠 CS Master's Bridge:内存分配的层次
当你在 Python 中创建一个对象,内存分配要经过多个层次。 不是每次都直接调用操作系统——那太慢了。 CPython 有自己的内存分配器,像一个精明的仓库管理员, 预先从操作系统批发大块内存,再零售给各个对象。
内存层次结构
Layer 3: Python 对象层 list, dict, int, str, 自定义类...
↓ 调用
Layer 2: Python 内存分配器 pymalloc(处理 ≤ 512 字节的小对象)
↓ 调用
Layer 1: C 内存分配器 malloc/realloc/free
↓ 调用
Layer 0: 操作系统 mmap/brk/VirtualAllocpymalloc:小对象分配器
大多数 Python 对象都很小(int: 28 字节, 空 list: 56 字节)。频繁地调用 malloc 来分配几十字节的内存效率很低。所以 CPython 有专门的小对象分配器:
pymalloc 内存结构:
Arena(256 KB)
├── Pool(4 KB = 1 个内存页)
│ ├── Block(固定大小,如 16 字节)
│ ├── Block(16 字节)
│ ├── Block(16 字节)
│ └── ...
├── Pool(4 KB)
│ ├── Block(32 字节)
│ ├── Block(32 字节)
│ └── ...
└── Pool(4 KB)
├── Block(64 字节)
└── ...Arena
Arena 是 pymalloc 向操作系统申请的最大内存单元(256 KB)。
多个 Arena 组成一个链表。
┌─ Arena 1 (256KB) ─┐ ┌─ Arena 2 (256KB) ─┐ ┌─ Arena 3 ─┐
│ Pool Pool Pool │→│ Pool Pool Pool │→│ ... │
│ Pool Pool Pool │ │ Pool Pool Pool │ │ │
│ ... │ │ ... │ │ │
└────────────────────┘ └────────────────────┘ └────────────┘
关键设计:Arena 可以被释放回操作系统。
当一个 Arena 中所有的 Pool 都空了,整个 Arena 被 free()。
优先从"最满的" Arena 分配 → 让"最空的" Arena 有机会被完全释放。Pool
每个 Pool 管理固定大小的 Block。
Pool 大小 = 4 KB(一个内存页)。
Pool 的大小类别(Size Class):
请求大小 Block 大小 每个 Pool 的 Block 数
1-8 8 504
9-16 16 252
17-24 24 168
25-32 32 126
33-40 40 100
... ... ...
497-512 512 7
超过 512 字节的对象直接用 malloc 分配。Block
Block 是最小的分配单元。
分配和释放都是 O(1)——通过空闲链表。
已分配的 Pool(假设 Block 大小 = 16 字节):
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 占 │ 占 │ 空 │ 占 │ 空 │ 空 │ 占 │ 空 │
└────┴────┴──↓─┴────┴──↓─┴──↓─┴────┴──↓─┘
↓ ↓ ↓ ↓
└────→────┘ └────→────┘
空闲链表(Free List)
分配:从空闲链表头取一个 Block → O(1)
释放:把 Block 加回空闲链表头 → O(1)⚛️ The Science:为什么不直接用 malloc?
malloc是通用的内存分配器,处理各种大小的请求。 它的开销包括:
- 维护空闲块列表
- 合并相邻空闲块(防止碎片)
- 可能需要系统调用(brk/mmap)
- 线程同步(加锁)
对于 Python 频繁创建销毁的小对象,这些开销太大了。 pymalloc 通过限制块大小为固定类别,把分配/释放优化到 O(1)。
效果:pymalloc 比直接 malloc 快 2-5 倍(对小对象)。
引用计数的优缺点
第 2 章介绍了引用计数的基本原理,这里深入分析它的优缺点。
优点详解
1. 即时回收
python
def process_large_file(path):
data = open(path).read() # 打开文件,读取内容
result = transform(data) # 处理数据
return result
# 函数结束 → data 的引用计数归零 → 立即释放
# 不用等 GC 周期!
# 在追踪式 GC(如 Java)中,data 可能存活很久
# 直到下一次 GC 周期才被回收
# 这意味着内存峰值更高2. 确定性析构
python
class DatabaseConnection:
def __init__(self, url):
self.conn = connect(url)
def __del__(self):
self.conn.close() # 引用计数归零时立即调用
print("Connection closed")
# CPython 中,这几乎是确定性的:
def query():
db = DatabaseConnection("postgresql://...")
result = db.execute("SELECT ...")
return result
# 函数返回 → db 的 refcnt 归零 → __del__ 被调用 → 连接关闭
# 但在 PyPy 或其他实现中,__del__ 的调用时机是不确定的!
# 所以推荐用 with 语句而非依赖 __del__3. 延迟低
引用计数 vs 追踪式 GC 的延迟特征:
引用计数:
时间 ─────────────────────────────────→
│ │ │ │ │ │ │ │ │ │ │ │ │
小 小 小 小 小 小 小 小 小 小 小 小 小
每次回收一点点,延迟均匀
追踪式 GC:
时间 ─────────────────────────────────→
████ ████
大停顿 大停顿
日常很快,但偶尔有大停顿(Stop-The-World)缺点详解
1. 循环引用
python
# 最简单的循环引用
a = []
b = []
a.append(b) # a 引用 b
b.append(a) # b 引用 a
del a, b # 变量删除了,但对象互相引用,refcnt 都是 1
# 引用计数永远不会归零 → 泄漏!
# 更隐蔽的循环引用
class Node:
def __init__(self):
self.parent = None
self.children = []
def add_child(self, child):
self.children.append(child)
child.parent = self # parent ↔ children 形成循环引用2. 性能开销
c
// 每次赋值都要更新引用计数
// Python: a = b
// C 层面:
Py_INCREF(b_obj); // b 的引用 +1
Py_XDECREF(a_obj); // a 原来指向的对象引用 -1
a_obj = b_obj; // 赋值
// 在紧密循环中,引用计数操作可能占总时间的 30%+
// 这也是 GIL 存在的原因之一:
// 如果没有 GIL,每次 INCREF/DECREF 都需要原子操作
// 原子操作比普通 ++ 慢 10-100 倍3. 线程安全问题
为什么引用计数导致需要 GIL?
线程 A 线程 B
───────── ─────────
读取 refcnt (10) 读取 refcnt (10)
refcnt + 1 = 11 refcnt - 1 = 9
写回 11 写回 9 ← 错误!应该是 10
没有 GIL 的解决方案:
1. 原子操作(每次 +1/-1 用 atomic_fetch_add)
→ 单线程性能下降 ~30%
2. 每个对象一把锁
→ 内存开销大,死锁风险
3. 放弃引用计数(PEP 703 的方向)
→ 需要完全重新设计内存管理分代垃圾回收
引用计数搞不定循环引用,所以 CPython 额外引入了 分代垃圾回收 (Generational GC)。
分代假说
分代 GC 基于一个经验观察(弱分代假说):
"大多数对象在创建后很快就死了,少数对象会存活很久。"
对象年龄分布(典型):
数量
▲
│████████
│██████
│████
│███
│██
│█
│█
│█
│█ ████
└──────────────────────────────────→ 年龄
大量的临时对象(循环变量、中间结果)vs 少量的长寿对象(配置、缓存)三代机制
python
import gc
# 查看分代配置
print(gc.get_threshold()) # (700, 10, 10)
# 含义:
# 第 0 代:每 700 次分配-释放差 → 触发 gen0 回收
# 第 1 代:每 10 次 gen0 回收 → 触发 gen1 回收
# 第 2 代:每 10 次 gen1 回收 → 触发 gen2 回收分代垃圾回收流程:
新创建的对象
│
▼
┌──────────────────────────────────┐
│ 第 0 代 (Generation 0) │ ← 最频繁回收
│ 年轻对象,大部分很快死去 │
│ 阈值:700 次分配差 │
└──────────────┬───────────────────┘
│ 幸存者晋升
▼
┌──────────────────────────────────┐
│ 第 1 代 (Generation 1) │ ← 偶尔回收
│ 中年对象,已经存活过一次 GC │
│ 阈值:10 次 gen0 回收 │
└──────────────┬───────────────────┘
│ 幸存者晋升
▼
┌──────────────────────────────────┐
│ 第 2 代 (Generation 2) │ ← 很少回收
│ 老年对象,长期存活 │
│ 阈值:10 次 gen1 回收 │
└──────────────────────────────────┘GC 统计信息
python
import gc
# 查看各代对象数量
print(gc.get_count()) # (452, 3, 1)
# 452 个对象在第 0 代,3 个在第 1 代,1 个在第 2 代
# 手动触发 GC
gc.collect() # 返回回收的不可达对象数量
# 查看 GC 收集的对象
gc.set_debug(gc.DEBUG_STATS)
gc.collect()
# 输出:
# gc: collecting generation 2...
# gc: objects in each generation: 452 3 1
# gc: done, 35 unreachable, 0 uncollectable循环引用检测
🎭 The Drama:追踪式 GC 的工作原理
分代 GC 使用 标记-清除(Mark and Sweep)算法来检测循环引用。 但 Python 的实现有个巧妙的变体——它不需要遍历所有对象。
算法流程
循环引用检测算法(简化版):
步骤 1:收集候选对象
只有"容器对象"(list, dict, set, 自定义类实例)才可能形成循环引用。
int, str, float 等不会——因为它们不能引用其他对象。
步骤 2:复制引用计数
为每个候选对象创建一个临时的引用计数副本 gc_refs。
步骤 3:解除内部引用
遍历每个对象,找到它引用的其他容器对象,
将被引用对象的 gc_refs 减 1。
步骤 4:找到不可达对象
gc_refs 变为 0 的对象就是"只被容器内部引用"的——
它们形成了循环引用,是垃圾。图解示例:
初始状态(del a, b 之后):
obj_A (list) obj_B (list)
refcnt = 1 refcnt = 1
[ref to B] ──────────→
←──────────[ref to A]
步骤 2:复制引用计数
obj_A: gc_refs = 1 obj_B: gc_refs = 1
步骤 3:解除内部引用
obj_A 引用了 obj_B → obj_B 的 gc_refs -= 1 → gc_refs = 0
obj_B 引用了 obj_A → obj_A 的 gc_refs -= 1 → gc_refs = 0
步骤 4:gc_refs == 0 → 不可达 → 垃圾!
回收 obj_A 和 obj_B。不可回收对象
python
# 有 __del__ 方法的循环引用对象在 Python 3.4 之前不可回收
class Leaky:
def __del__(self):
print("Cleaned up")
a = Leaky()
b = Leaky()
a.ref = b
b.ref = a
del a, b
# Python 3.3-:放入 gc.garbage,不回收(因为不知道先调用谁的 __del__)
# Python 3.4+:PEP 442 改进,可以安全回收weakref:弱引用
弱引用不增加引用计数——它允许你引用一个对象,但不阻止它被回收。
基本用法
python
import weakref
class ExpensiveObject:
def __init__(self, name):
self.name = name
def __repr__(self):
return f"ExpensiveObject({self.name!r})"
# 创建对象和弱引用
obj = ExpensiveObject("data")
weak = weakref.ref(obj)
print(weak()) # ExpensiveObject('data') —— 对象还活着
print(weak) # <weakref at 0x...; to 'ExpensiveObject' at 0x...>
del obj # 删除强引用
print(weak()) # None —— 对象已被回收回调函数
python
import weakref
def on_finalize(ref):
print(f"对象被回收了: {ref}")
obj = ExpensiveObject("cache_data")
weak = weakref.ref(obj, on_finalize)
del obj
# 输出: 对象被回收了: <weakref at 0x...; dead>实际应用:弱引用缓存
python
import weakref
class ImageCache:
"""弱引用缓存——当内存紧张时,缓存自动释放。"""
def __init__(self):
self._cache = weakref.WeakValueDictionary()
def get_image(self, path: str):
# 尝试从缓存获取
image = self._cache.get(path)
if image is not None:
print(f"Cache hit: {path}")
return image
# 缓存未命中,加载图片
print(f"Loading: {path}")
image = load_image(path) # 假设这个函数存在
self._cache[path] = image
return image
# 当外部代码不再引用某个 image 对象时,
# WeakValueDictionary 会自动移除对应的条目。
# 不需要手动管理缓存大小!观察者模式中的弱引用
python
import weakref
class EventEmitter:
"""使用弱引用的事件系统,避免监听器泄漏。"""
def __init__(self):
self._listeners = weakref.WeakSet()
def add_listener(self, listener):
self._listeners.add(listener)
def emit(self, event):
# WeakSet 自动移除已被回收的监听器
for listener in self._listeners:
listener.handle(event)
class MyListener:
def handle(self, event):
print(f"Received: {event}")
emitter = EventEmitter()
listener = MyListener()
emitter.add_listener(listener)
emitter.emit("hello") # Received: hello
del listener
emitter.emit("hello") # 无输出——listener 已被回收,WeakSet 自动清理🧰 Toolbox:弱引用的适用场景
场景 工具 原因 缓存(值可能被回收) WeakValueDictionary内存紧张时自动清理 缓存(键可能被回收) WeakKeyDictionary对象删除时自动清理 观察者/监听器集合 WeakSet避免泄漏 跟踪对象实例 weakref.ref不影响生命周期 打破循环引用 weakref.ref弱引用不计入 refcnt 注意:
int、str、tuple等不可变内置类型不支持弱引用。
__del__ 的陷阱
__del__ 是 Python 的"析构函数"——但它和 C++ 的析构函数非常不同。
陷阱 1:调用时机不确定
python
class Resource:
def __del__(self):
print("Resource released")
# CPython 中通常是确定性的
r = Resource()
del r # 立即输出 "Resource released"(引用计数归零)
# 但在这些情况下不确定:
# 1. 循环引用中的对象
# 2. 解释器关闭时
# 3. 其他 Python 实现(PyPy)陷阱 2:复活对象
python
zombies = []
class Zombie:
def __del__(self):
zombies.append(self) # 在析构中把自己加回全局列表
print("I'm back!") # 对象被"复活"了
z = Zombie()
del z # "I'm back!" —— 对象复活了
print(len(zombies)) # 1 —— 对象还在!
# 这是一个可怕的反模式——不要这么做陷阱 3:异常被吞掉
python
class Broken:
def __del__(self):
raise RuntimeError("cleanup failed!") # 异常被忽略
b = Broken()
del b
# 输出: Exception ignored in: <function Broken.__del__ at 0x...>
# 异常不会传播——只打印一个警告陷阱 4:解释器关闭时的混乱
python
import os
class FileManager:
def __init__(self, path):
self.path = path
def __del__(self):
# 解释器关闭时,os 模块可能已经被清理
os.remove(self.path) # 可能抛出 TypeError: 'NoneType' is not callable正确的做法:上下文管理器
python
# ❌ 依赖 __del__
class DatabaseConnection:
def __del__(self):
self.close()
# ✅ 使用上下文管理器
class DatabaseConnection:
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
# 使用
with DatabaseConnection() as db:
db.execute("SELECT ...")
# 离开 with 块时,__exit__ 一定会被调用🧘 Zen of Code:确定性 > 便利性
__del__的诱惑是"自动清理"。但"自动"意味着"不确定"。with语句的智慧是:把清理的责任交给调用者,而不是被调用者。 这是"显式优于隐式"的又一个体现。
内存泄漏排查
🧠 CS Master's Bridge:Python 也会内存泄漏?
严格来说,有 GC 的语言不会有 C/C++ 那种"忘记 free"的泄漏。 但 Python 有 逻辑泄漏——对象还被引用着,但你以为它已经被回收了。
常见原因:
- 全局变量/类变量中的列表/字典不断增长
- 闭包意外捕获了大对象
- 循环引用 +
__del__(Python 3.4 之前)- C 扩展中的引用计数 bug
tracemalloc:追踪内存分配
python
import tracemalloc
# 开始追踪
tracemalloc.start()
# 你的代码
data = [list(range(1000)) for _ in range(1000)]
# 获取快照
snapshot = tracemalloc.take_snapshot()
# 按文件统计
top_stats = snapshot.statistics('lineno')
print("[ Top 10 内存分配 ]")
for stat in top_stats[:10]:
print(stat)
# 输出类似:
# /path/to/script.py:6: size=7874 KiB, count=1001, average=7872 B对比快照
python
import tracemalloc
tracemalloc.start()
# 快照 1:初始状态
snapshot1 = tracemalloc.take_snapshot()
# 执行一些操作
cache = {}
for i in range(10000):
cache[i] = f"value_{i}" * 100
# 快照 2:操作后
snapshot2 = tracemalloc.take_snapshot()
# 对比差异
top_stats = snapshot2.compare_to(snapshot1, 'lineno')
print("[ 内存增长 Top 10 ]")
for stat in top_stats[:10]:
print(stat)
# 输出类似:
# /path/to/script.py:9: size=8234 KiB (+8234 KiB), count=10001 (+10001)gc 模块:检测循环引用
python
import gc
# 开启 GC 调试
gc.set_debug(gc.DEBUG_SAVEALL) # 不可达对象存入 gc.garbage
# 创建循环引用
class Node:
def __init__(self, name):
self.name = name
self.ref = None
a = Node("A")
b = Node("B")
a.ref = b
b.ref = a
del a, b
# 手动触发 GC
collected = gc.collect()
print(f"回收了 {collected} 个对象")
print(f"不可达对象: {gc.garbage}")
# 清理
gc.garbage.clear()
gc.set_debug(0)objgraph:可视化对象引用
python
# pip install objgraph
import objgraph
# 查看最常见的对象类型
objgraph.show_most_common_types(limit=10)
# dict 12345
# list 6789
# tuple 5432
# function 3210
# ...
# 查看某类型对象的增长
objgraph.show_growth(limit=10) # 与上次调用相比
# 查看对象的引用链
class LeakyCache:
_data = [] # 类变量——每个实例的数据都会累积在这里!
for i in range(1000):
cache = LeakyCache()
cache._data.append(f"item_{i}")
# 查看谁在引用这些字符串
objgraph.show_backrefs(
objgraph.by_type('str')[-1],
max_depth=5,
filename='refs.png' # 生成引用图
)实战:排查内存泄漏的流程
内存泄漏排查流程图:
1. 发现症状
└── 进程内存持续增长,不回落
2. 初步定位
├── tracemalloc.start() → 对比快照
│ └── 找到分配最多内存的文件和行号
└── objgraph.show_growth()
└── 找到数量持续增长的对象类型
3. 深入分析
├── objgraph.show_backrefs()
│ └── 找到谁在引用这些对象
├── gc.collect() + gc.garbage
│ └── 检查是否有不可回收的循环引用
└── 代码审查
├── 全局/类变量中的容器是否只增不减?
├── 闭包是否捕获了大对象?
├── 事件监听器是否忘记取消注册?
└── C 扩展是否正确管理引用计数?
4. 修复
├── 使用 weakref 打破循环引用
├── 给缓存加上大小限制(LRU)
├── 使用 with 语句确保资源释放
└── 避免在类变量中累积数据总结:Python 内存管理的哲学
🌌 The Big Picture:自动 ≠ 免费
Python 的内存管理是"自动"的——但不是"免费"的。 它需要你理解:
你不需要做的 你需要知道的 手动 malloc/free 循环引用的存在 计算对象大小 弱引用的用途 管理内存池 __del__ 的陷阱 处理碎片化 缓存可能造成泄漏 选择分配策略 tracemalloc 的用法 Python 的承诺是:90% 的情况你不需要想内存的事。 但剩下的 10%——大缓存、长时间运行的服务、C 扩展—— 你需要理解底层机制才能做出正确的决定。
这又是一个 "Practicality beats purity" 的例子: 不追求完美的自动化(如 Rust 的零开销抽象), 而是用一个"足够好"的方案覆盖大多数场景, 然后提供工具让你处理剩下的边界情况。
参考资源
- Memory Management in CPython (PEP 3118)
- gc — Garbage Collector Interface
- weakref — Weak References
- tracemalloc — Trace Memory Allocations
- objgraph — Object Reference Graphs
- PEP 442 — Safe Object Finalization