PyObject 与引用计数:Python 的内存真相 (Python Memory Model)


章节概述

你写了 a = [1, 2, 3],Python 到底做了什么?本章从 CPython 源码出发,揭示 Python 内存管理的核心机制:一切皆 PyObject、引用计数机制、垃圾回收的循环检测算法。我们将 Python 的动态内存模型与 C 的静态 malloc/free 模型逐项对比,让你彻底理解”为什么 Python 很慢”以及”为什么 Python 很安全”。

核心理念:Python 的内存模型不是魔法,而是一层构建在 C malloc/free 之上的引用计数系统。理解了 PyObject,你就理解了 Python 的一切。


第一节:一切皆 PyObject

在 CPython 的世界里,一切数据都是 PyObject。无论是整数 42、字符串 "hello"、列表 [1,2,3],还是函数、类、模块——它们在 C 层面都用一个结构体表示。

来看看 CPython 源码中 PyObject 的定义(简化自 Include/object.h):

// CPython 源码: Include/object.h
// 这是每个 Python 对象都有的"头部"
 
typedef struct _object {
 Py_ssize_t ob_refcnt; // 引用计数 — 这个对象被引用了多少次
 PyTypeObject *ob_type; // 指向类型对象的指针 — 这个对象是什么类型
} PyObject;

每一个 Python 对象在 C 层面至少包含这两个字段

字段含义C 类比
ob_refcnt引用计数:GC 用它判断对象能否被释放C 中不存在,C 由程序员手动决定 free 时机
ob_type类型指针:指向描述该对象类型的元对象类似于 void* 配合手动记录的类型标签

再看整数对象的实际定义(Include/longintrepr.h):

typedef struct {
 PyObject ob_base; // 继承"头部" — ob_refcnt + ob_type
 Py_ssize_t ob_size; // 数字的"位数"
 uint32_t ob_digit[1]; // 实际数字数据(柔性数组)
} PyLongObject;

关键对比:C 语言的 int 就是 4 字节的裸值,存储在寄存器或栈上。Python 的 int 是一个在堆上分配的 PyLongObject 结构体,包含类型信息、引用计数和实际数值。这就是 Python 比 C 慢几十倍的底层原因之一:x = 1 + 2 不是一条 CPU 指令,而是数十次函数调用和堆分配。

用 Python 验证:

python -c "
import sys
a = 42
b = [1, 2, 3]
print(f'int 大小: {sys.getsizeof(a)} 字节') # 28 字节!
print(f'list 大小: {sys.getsizeof(b)} 字节') # 远超数据本身的体积
print(f'a 的类型: {type(a)} -> {type(a).__mro__}')
"

输出:

int 大小: 28 字节
list 大小: 88 字节
a 的类型: <class 'int'> -> (<class 'int'>, <class 'object'>)

一个 C 的 int 只有 4 字节,Python 的 int 却要 28 字节——额外的 24 字节就是 PyObject 头部、引用计数槽位和动态精度管理的代价。


第二节:引用计数机制

2.1 引用计数的规则

CPython 使用引用计数作为主要的内存管理策略。规则很简单:

操作引用计数变化
a = SomeObject()ob_refcnt = 1
b = aob_refcnt += 1
del bob_refcnt -= 1
a 离开作用域ob_refcnt -= 1
ob_refcnt == 0立即调用 __del__,然后 free 内存
python -c "
import sys
 
a = [] # 创建空列表,refcnt = 1
print(sys.getrefcount(a)) # getrefcount 本身会增加 1 个临时引用!
 
b = a # refcnt = 2
print(sys.getrefcount(a))
 
del b # refcnt = 1 (b 的引用消失)
print(sys.getrefcount(a) - 1)
"

输出:

2 # getrefcount 的参数引用 + 变量 a = 2
3 # getrefcount + a + b = 3
1 # 实际 refcnt 是 2,减去 getrefcount 的临时引用 = 1

重要陷阱sys.getrefcount() 的结果总是比”实际”引用数大 1,因为它自己的参数就是一个临时引用。

2.2 引用计数 vs C 手动内存管理

// C 语言:手动 malloc/free — 完全由程序员负责
#include <stdlib.h>
#include <stdio.h>
 
typedef struct {
 int *data;
 int size;
} IntArray;
 
IntArray* create_array(int size) {
 IntArray *arr = malloc(sizeof(IntArray));
 arr->data = malloc(sizeof(int) * size);
 arr->size = size;
 return arr;
}
 
void destroy_array(IntArray *arr) {
 free(arr->data);
 free(arr);
}
 
// 使用 — 危险!忘记 free 导致泄漏,过早 free 导致悬垂指针
int main() {
 IntArray *a = create_array(100);
 IntArray *b = a; // 两个指针指向同一块内存
 destroy_array(a); // free 后 b 是悬垂指针!
 // b->data[0] = 42; // 段错误或未定义行为!
 return 0;
}
# Python:引用计数自动管理
a = [0] * 100
b = a # refcnt += 1,两个名字指向同一对象
del a # refcnt -= 1,[] 的 refcnt 仍 >= 1,不会被释放
print(b[0]) # 安全!b 仍然持有引用
del b # refcnt -= 1 → 0,解释器自动释放内存

关键差异:C 中 b = a 只是复制指针值,程序员需要跟踪”所有权”。Python 中 b = a 是增加引用计数,无论谁先释放都不会影响另一个。


第三节:循环引用与 GC 模块

引用计数有一个致命弱点:无法处理循环引用

python -c "
import sys
 
a = []
b = []
a.append(b) # a[0] 引用 b → b 的 refcnt +1
b.append(a) # b[0] 引用 a → a 的 refcnt +1
 
print('a refcnt:', sys.getrefcount(a) - 1) # 2 (变量 a + b[0])
print('b refcnt:', sys.getrefcount(b) - 1) # 2 (变量 b + a[0])
 
del a
del b
# 此时 a 和 b 变量的引用消失,但 a[0] 和 b[0] 互相引用
# 两个列表的 refcnt 都是 1(互相持有)→ 永远不会归零 → 内存泄漏!
"

输出:

a refcnt: 2
b refcnt: 2
# del a, del b 后,两个列表互相引用,但已经没有任何外部引用 → 垃圾!

3.1 GC 的循环检测

CPython 除了引用计数,还有一个分代垃圾回收器专门处理循环引用:

import gc
import sys
 
# 查看 GC 配置
print(gc.get_threshold()) # (700, 10, 10) — 三代阈值
 
# 手动触发 GC
gc.collect() # 强制运行垃圾回收
 
# 禁用自动 GC(仅保留引用计数)
gc.disable()
# 现在循环引用会导致内存泄漏!
 
# 重新启用
gc.enable()

GC 将对象分为三代:

说明回收频率
gen0新创建的对象每次阈值达到都回收
gen1经历一次回收后存活的对象gen0 回收 10 次后回收一次
gen2经历两次回收后存活的对象gen1 回收 10 次后回收一次
python -c "
import gc
print('GC 启用状态:', gc.isenabled())
print('三代阈值:', gc.get_threshold())
print('可回收对象数:', len(gc.get_objects()))
print('统计:', gc.get_stats())
"

3.2 __del__ vs C 的析构/free

// C 语言:手动释放
typedef struct {
 FILE *fp;
 char *buffer;
} FileWrapper;
 
void file_wrapper_free(FileWrapper *fw) {
 if (fw->fp) fclose(fw->fp); // 必须手动销毁每个资源
 free(fw->buffer);
 free(fw);
}
class FileWrapper:
 def __init__(self, path):
 self.fp = open(path, 'w')
 self.buffer = []
 
 def __del__(self):
 """析构函数 — 在 ob_refcnt 归零时由解释器调用"""
 self.fp.close()
 print("资源已释放")
 
# 使用 with 语句更可靠(不依赖引用计数归零的时机)
with open('test.txt', 'w') as f:
 f.write('hello')
# with 块结束,文件立即关闭——类似 C 的 RAII

C vs Python 析构对比:C 的 free() 是确定性的(调用即释放);Python 的 __del__ 调用时机不确定(取决于 GC),甚至永远不会被调用(如果发生循环引用且 GC 被禁用)。Python 中推荐使用 with 上下文管理器而不是 __del__


第四节:Python 内存布局 vs C 内存布局

4.1 C 数组:连续内存块

// C 数组:所有元素在内存中紧密排列
int arr[5] = {10, 20, 30, 40, 50};
// 内存布局:|10|20|30|40|50| ← 连续 20 字节
// arr[3] 的地址 = arr + 3 * sizeof(int) = arr + 12 字节
// CPU 缓存友好,向量化友好

4.2 Python 列表:PyObject* 指针数组

# Python 列表:存储的是 PyObject* 指针,不是值本身
arr = [10, 20, 30, 40, 50]

Python 列表在 C 层面的结构(简化):

typedef struct {
 PyObject ob_base; // ob_refcnt + ob_type
 Py_ssize_t ob_size; // 列表长度
 PyObject **ob_item; // 指向指针数组的指针!
 Py_ssize_t allocated; // 预分配容量
} PyListObject;

内存布局对比图:

C 数组:
 [10][20][30][40][50]
 ↑ ↑
 arr arr+4

Python 列表:
 ob_item → [*][*][*][*][*]
 ↓ ↓ ↓ ↓ ↓
 PyLongObject PyLongObject ...
 (28 字节) (28 字节)
 
 每个元素是一个独立的堆对象,通过指针间接访问!

性能启示:遍历 Python 列表时,CPU 不仅要读取指针数组,还要跳转到各个 PyLongObject 的地址。每次访问都有指针追逐开销,严重破坏 CPU 缓存。C 数组遍历是可以被编译器自动向量化(SIMD)的连续内存访问。这是 Python 数值计算慢的另一个根本原因——也是 NumPy 存在的理由。

4.3 小整数缓存

CPython 有一个有趣的小整数缓存机制:

python -c "
a = 256
b = 256
print(a is b) # True — 小整数被缓存,a 和 b 指向同一个对象
 
c = 257
d = 257
print(c is d) # False — 超出缓存范围,各自创建新对象
 
# 缓存范围:-5 到 256(包含)
# 这些整数在解释器启动时预分配,永不释放
"

输出:

True
False

这是 CPython 的实现细节(NSMALLPOSINTSNSMALLNEGINTS 宏),不要在生产代码中依赖 is 比较整数。


第五节:弱引用

有时我们需要引用一个对象,但不希望阻止它被垃圾回收——这就是弱引用。

import weakref
import sys
 
class BigObject:
 def __del__(self):
 print("BigObject 被回收了")
 
obj = BigObject()
ref = weakref.ref(obj) # 弱引用 — 不增加引用计数!
print(ref()) # <__main__.BigObject object at 0x...>
print(sys.getrefcount(obj)) # 2 (变量 + getrefcount),不含弱引用
 
del obj # refcnt → 0,对象被回收
print(ref()) # None — 对象已不存在

输出:

2
BigObject 被回收了
None

C 对比:C 中没有弱引用的概念。如果你需要”观察但不拥有”的语义,只能手动标记指针为”非所有者”并用注释或约定来管理——非常容易出错。

弱引用的典型用途:

  • 缓存系统中,缓存对象的引用不阻止其被回收
  • 观察者模式中,观察者不阻止被观察对象销毁
  • 循环引用的解决方案之一
import weakref
 
class Cache:
 def __init__(self):
 self._cache = weakref.WeakValueDictionary()
 
 def get(self, key, factory):
 obj = self._cache.get(key)
 if obj is None:
 obj = factory()
 self._cache[key] = obj
 return obj
 # 如果外部不再引用对象,Cache 中的条目自动消失

练习

以下题目用于验证本章所学内容:

题号题目链接涉及知识点