CPython 内部机制:对象模型与内存管理
更新时间:2026-08-30。本文回答:Python 对象在内存里到底长什么样?为什么 del 一个对象有时立刻回收、有时不回收?循环引用谁来处理? 看懂这些,才知道 Python 的内存开销从哪来、GC 停顿怎么来的。
一、所有对象共享一个头:PyObject
CPython 用 C 实现,每个 Python 对象在 C 层都以 PyObject 开头——一个统一对象头,装着引用计数和类型指针:
// CPython 内部(简化)
typedef struct _object {
Py_ssize_t ob_refcnt; // 引用计数
PyTypeObject *ob_type; // 指向类型对象(int/str/list...)
} PyObject;后面紧跟对象自己的数据(比如 int 的 long ob_ival,list 的指针数组)。代价:每个对象至少有 16 字节头(64 位),哪怕存一个 True 也要这么大壳子——这就是 Python "内存开销大"的源头之一。和本站 L5 ELF/ABI 与内存布局 对照,C 的结构体可紧凑排列,Python 则层层引用、处处头部。
二、引用计数:del 为什么有时"立刻"回收
CPython 主要用**引用计数(reference counting)**管理内存:对象头里的 ob_refcnt 记录有多少名字指向它,降到 0 立即释放,不需要等 GC 周期。
import sys
a = [1, 2, 3]
print(sys.getrefcount(a)) # 2(a 本身 + getrefcount 临时引用)
b = a
print(sys.getrefcount(a)) # 3(多了一个 b)
del b
print(sys.getrefcount(a)) # 回到 2| 机制 | 行为 | 优点 | 缺点 |
|---|---|---|---|
| 引用计数 | 归零即释放 | 回收及时、无停顿 | 循环引用泄漏 |
| 分代 GC | 周期扫描 | 解决循环引用 | 有停顿、耗 CPU |
三、循环引用:引用计数管不到的死角
如果两个对象互相引用,即使外界都不再用它们,refcnt 也降不到 0,引用计数永远不回收。CPython 用**分代垃圾回收器(generational GC)**补这个洞:
class Node:
def __init__(self): self.parent = self # 自己指向自己
# 即使 del 掉外界引用,refcnt 仍是 1(自环),得靠 GC 周期清理GC 把对象分三代(0/1/2),新对象进第 0 代,熬过扫描就升代。分代基于"活久了的更可能继续活"的经验,减少全量扫描频率。可用 gc.get_threshold() 看触发阈值,gc.collect() 手动触发。
四、小对象内存池:arena / pool / block
频繁向系统 malloc/free 小对象很慢。CPython 自己建了一套内存池:
- arena(256KB):向系统申请的大块。
- pool(4KB):从 arena 切出,同类尺寸块聚在一起。
- block:实际对象用的小块,按 8 字节对齐(如 24/32/40...字节档位)。
小对象在这个池里循环复用,避免反复 syscall。代价是池不会轻易还回系统——程序峰值内存后,RSS 往往降不下来,这是 Python 服务常"看着内存只涨不跌"的原因之一。
五、实测 demo:用 tracemalloc 看对象来自哪
定位内存异常,Python 自带 tracemalloc:
import tracemalloc
tracemalloc.start()
# ... 你的业务代码 ...
snapshot = tracemalloc.take_snapshot()
top = snapshot.statistics("lineno")
for s in top[:5]:
print(s) # 哪行分配了最多内存实测对比:
# 列表存 100 万 int
import sys, tracemalloc
tracemalloc.start()
data = list(range(1_000_000))
print(tracemalloc.get_traced_memory()) # 约 28MB(每个 int 对象 ~28 字节)
tracemalloc.stop()同样是 100 万个整数,numpy.array('i') 只要约 4MB(连续 C 数组、无对象头)——差距 7 倍,正是 性能优化路径 里"向量化省内存"的底层原因。
六、int / str 的隐式缓存
CPython 对小整数和常用字符串做了缓存复用:
a = 256; b = 256
print(a is b) # True(小整数池 -5~256 共享)
x = 257; y = 257
print(x is y) # False(超出范围,各自新建)
s1 = "hello"; s2 = "hello"
print(s1 is s2) # 通常 True(字符串 intern 复用)这能省内存、加快比较,但别在业务里依赖 is 做值比较——用 == 才稳。
七、与本站内存主线的衔接
| CPython 机制 | 本站对应 | 衔接文档 |
|---|---|---|
| PyObject 16 字节头 | L5 内存布局 | 为什么 Python 对象比 C 结构体胖 |
| 引用计数 vs 分代 GC | GC 与逃逸分析 | Go 三色标记对照 |
| 小对象内存池 | L3 缓存组织 | 池化复用减少分配 |
| numpy 连续数组 | 缓存与程序性能 | 连续内存缓存友好 |
八、常见坑
- 循环引用导致泄漏:长期持有互相引用的对象(如缓存、观察者列表),记得断开或手动
gc.collect()。 __del__阻塞 GC:带__del__的循环引用对象,GC 无法安全回收,会进gc.garbage,需手动处理。- 内存只涨不跌:多是小对象池不归还系统,不是真泄漏;必要时用
tracemalloc确认分配来源。
一句话总结
CPython 用统一 PyObject 头 + 引用计数做即时回收,循环引用交给分代 GC,小对象走内存池复用;每对象 16 字节头与池化策略决定了它"省心但吃内存"——真要抠内存,热点下沉 numpy/C 扩展。
回到总纲:Python 进阶。