Python(CPython)采用引用计数为主、分代 GC 为辅的混合策略。引用计数提供了即时、确定性的内存回收,分代 GC 专门解决引用计数无法处理的循环引用问题。
目录
| 章节 | 说明 |
|---|---|
| 引用计数机制 | ob_refcnt 与即时回收 |
| 循环引用问题 | 引用计数的致命缺陷 |
| 分代循环检测器 | gc 模块的工作原理 |
| 内存分配器 | pymalloc 与对象池 |
| GIL 与 GC 的关系 | 为什么 CPython 需要 GIL |
| 其他 Python 实现 | PyPy、Jython 的 GC 方案 |
引用计数机制
每个对象都有引用计数
CPython 的每个 Python 对象都在内存中以 PyObject 结构开头:
// CPython 源码:Objects/object.h
typedef struct _object {
Py_ssize_t ob_refcnt; // 引用计数
PyTypeObject *ob_type; // 类型指针
// ... 后续是对象实际数据
} PyObject;
引用计数的变化时机
import sys
a = [1, 2, 3] # 列表对象创建,refcnt = 1
print(sys.getrefcount(a)) # → 2(getrefcount 本身也持有一个引用)
b = a # refcnt = 2(+1 因为 b 引用它)
c = a # refcnt = 3
del b # refcnt = 2(-1)
del c # refcnt = 1(-1)
def func(x):
pass # 函数调用时 refcnt 临时 +1,返回后 -1
func(a)
del a # refcnt = 0 → 立即调用 __del__,释放内存
引用计数变化的场景
| 操作 | 效果 |
|---|---|
变量赋值 a = obj |
+1 |
变量赋值覆盖 a = other |
原对象 -1 |
| 函数参数传入 | +1 |
| 函数返回 | 参数 -1 |
放入容器 list.append(obj) |
+1 |
从容器删除 list.remove(obj) |
-1 |
del a |
-1 |
| 对象超出作用域 | -1 |
引用计数的优势
- 即时性: refcnt 降为 0 时立即释放,无 GC 停顿
- 确定性:
__del__在对象销毁时立即被调用,可用于资源管理 - 局部性: 内存释放紧跟分配,缓存友好
# with 语句利用了引用计数的确定性
with open("file.txt") as f:
data = f.read()
# f 超出 with 块,引用计数降为 0,__exit__ 立即被调用,文件立即关闭
循环引用问题
引用计数无法处理循环引用:
# 构造循环引用
class Node:
def __init__(self, val):
self.val = val
self.next = None
a = Node(1)
b = Node(2)
a.next = b # b 的 refcnt = 2(b 变量 + a.next)
b.next = a # a 的 refcnt = 2(a 变量 + b.next)
del a # a 的 refcnt = 1(只剩 b.next 引用它)
del b # b 的 refcnt = 1(只剩 a.next 引用它)
# 此时:
# - a 和 b 的 refcnt 都是 1,永远不会降为 0
# - 但从 GC Roots 出发,它们完全不可达
# - 内存泄漏!
典型的循环引用场景:
- 父子节点互相引用
- 对象持有自身的闭包(
__del__中引用 self) - 缓存中的对象引用
分代循环检测器
Python 的 gc 模块专门用于检测和回收循环引用,使用分代策略:
三代结构
import gc
print(gc.get_threshold()) # → (700, 10, 10)
# 第 0 代:分配 - 释放 > 700 时触发
# 第 1 代:第 0 代 GC 次数 > 10 时触发
# 第 2 代:第 1 代 GC 次数 > 10 时触发
只追踪容器对象
Python GC 只追踪可以包含其他对象的容器(list、dict、set、class 实例等),不追踪 int、str、float 等不可变对象:
import gc
gc.is_tracked([1, 2, 3]) # → True(列表被追踪)
gc.is_tracked((1, 2, 3)) # → False(纯数字元组不追踪)
gc.is_tracked(42) # → False(整数不追踪)
循环引用检测算法
1. 对所有第 0 代对象,创建引用计数副本(rc_copy)
2. 遍历第 0 代所有对象:
对于对象 A 引用的每个对象 B:
rc_copy[B] -= 1 # 减去来自第 0 代内部的引用
3. 减完之后:
rc_copy > 0 → 还有来自外部(如全局变量)的引用 → 可达,存活
rc_copy = 0 → 只被第 0 代内部互相引用 → 循环垃圾!
4. 标记为可达的对象晋升到第 1 代
循环垃圾被回收
内存分配器
pymalloc:小对象池
CPython 使用 pymalloc 管理小于等于 512 字节的对象:
pymalloc 的三级结构:
Arena(256KB)
└── Pool(4KB,按大小分类)
└── Block(实际分配单元,8~512 字节)
分配路径:
请求 24 字节 → 找对应大小 Pool → 从 Pool 取 Block
比系统 malloc 快很多(减少系统调用,利用缓存)
整数对象池
CPython 预分配了 [-5, 256] 范围内的整数对象:
a = 256
b = 256
print(a is b) # → True(同一个对象!)
a = 257
b = 257
print(a is b) # → False(超出预分配范围,不同对象)
# 这就是为什么 Python 中比较整数应该用 == 而不是 is
字符串驻留(String Interning)
a = "hello"
b = "hello"
print(a is b) # → True(字符串驻留,同一对象)
a = "hello world"
b = "hello world"
print(a is b) # → 可能 True,也可能 False(取决于编译器优化)
GIL 与 GC 的关系
GIL(Global Interpreter Lock) 是 CPython 的全局解释器锁,每次只允许一个线程执行 Python 字节码。
GIL 与引用计数密切相关:
引用计数是全局状态(每个对象的 ob_refcnt)
多线程同时修改引用计数 → 竞态条件
如果没有 GIL:
线程 A:读 ob_refcnt = 5
线程 B:读 ob_refcnt = 5
线程 A:写 ob_refcnt = 4(减 1)
线程 B:写 ob_refcnt = 4(也减 1!少减了一次)
→ refcnt 错误 → 内存提前释放 → 悬空指针
GIL 解决:任意时刻只有一个线程修改引用计数
代价:多核 CPU 无法真正并行执行 Python 代码
Python 3.12 免 GIL(PEP 703): 引入每对象锁替代全局 GIL,是 Python 性能的重要里程碑,但需要用原子操作替代引用计数的简单 ++/--。
其他 Python 实现
| 实现 | GC 策略 | 特点 |
|---|---|---|
| CPython | 引用计数 + 分代循环检测 | 标准实现,即时回收 |
| PyPy | 追踪式 GC(类似 JVM) | JIT 编译,速度快,GC 停顿略长 |
| Jython | JVM GC(G1/ZGC) | 运行在 JVM 上,复用 JVM GC |
| IronPython | .NET GC | 运行在 .NET 上,复用 CLR GC |
| MicroPython | 标记-清除(嵌入式) | 内存极小,专为微控制器设计 |
PyPy 的例子说明了引用计数不是 Python 的必然选择,而是 CPython 的历史设计决策:
# PyPy 中:
import gc
gc.collect() # PyPy 用的是追踪式 GC,需要显式触发
# __del__ 的调用时机不确定(取决于 GC 何时运行)
参考资料
- CPython 源码:
Objects/object.c,Modules/gcmodule.c- Pablo Galindo:"Design of CPython's Garbage Collector"
- PEP 703:"Making the Global Interpreter Lock Optional in CPython"
- GC 调优实践
评论 (0)
发表评论