大家好,我是正在实战各种 AI 项目的程序员晚枫。

引用计数有个致命缺陷:无法处理循环引用。CPython 如何解决这个难题?

理解垃圾回收机制,能帮你避免内存泄漏,也能解释为什么有些对象的 __del__ 不会被调用。

想象一下,你有一堆书,每本书上贴着一个标签,记录有多少人正在借阅这本书。当标签上的数字变成 0 时,你就可以把书扔掉。这就是引用计数。但如果有两本书互相引用(A 书说 B 书在借阅,B 书说 A 书在借阅),即使没有人真正需要它们,标签数字也不会变成 0。这就是循环引用问题。


🔄 引用计数的局限性

循环引用问题

让我们看一个经典的循环引用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 简单的循环引用示例
a = {}
b = {}
a['ref'] = b # a 引用 b
b['ref'] = a # b 引用 a

# 此时引用关系:
# a.ob_refcnt = 2(变量 a + b['ref'])
# b.ob_refcnt = 2(变量 b + a['ref'])

del a # a.ob_refcnt = 1
del b # b.ob_refcnt = 1

# 现在两个对象都无法被访问,但引用计数都不为 0!
# 内存泄漏发生

这个例子展示了引用计数的致命缺陷:当两个或多个对象互相引用时,即使它们已经不再被程序使用,引用计数也不会归零,导致内存无法释放。

更隐蔽的例子

循环引用在实际代码中可能更隐蔽:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Node:
def __init__(self, value):
self.value = value
self.parent = None
self.children = []

def add_child(self, child):
child.parent = self
self.children.append(child)

# 创建树结构
root = Node("root")
child = Node("child")
root.add_child(child)

# 删除引用
del root
del child
# 如果 Node 没有正确清理,可能导致循环引用

在这个例子中,parent 和 children 形成了双向引用。如果程序没有正确清理这些引用,就可能导致内存泄漏。


🎯 分代垃圾回收器

CPython 使用分代垃圾回收(Generational GC)来解决循环引用问题。

核心思想

分代垃圾回收基于一个观察:对象存活时间越长,越可能长期存活。

基于这个观察,CPython 把对象分为三代:

1
2
3
4
5
0 代(年轻代):新创建的对象
↓ 存活下来
1 代(中年代):经过一次 0 代回收仍存活
↓ 存活下来
2 代(老年代):经过多次回收仍存活

回收策略:

  • 0 代:最频繁检查,因为大多数对象很快就死亡
  • 1 代:中等频率检查
  • 2 代:最少检查,因为老年代对象大多是长期存活的

三代对象的数据结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Modules/gcmodule.c
#define NUM_GENERATIONS 3

struct gc_generation {
PyGC_Head head; // 该代对象链表头
int threshold; // 触发回收的阈值
int count; // 当前分配的对象数
};

static struct gc_generation generations[NUM_GENERATIONS] = {
/* 0 代 */ { {}, 700, 0 }, // 年轻代:最频繁检查
/* 1 代 */ { {}, 10, 0 }, // 中年代
/* 2 代 */ { {}, 10, 0 } // 老年代:最少检查
};
代数特点检查频率
0 代新创建的对象最频繁
1 代经过一次 0 代回收仍存活中等
2 代经过多次回收仍存活最少

触发条件

1
2
3
4
5
6
7
8
9
import gc

# 查看各代阈值
print(gc.get_threshold()) # (700, 10, 10)

# 含义:
# - 0 代分配 700 个容器对象后检查
# - 0 代回收 10 次后检查 1 代
# - 1 代回收 10 次后检查 2 代

为什么是容器对象?因为只有容器对象(列表、字典、集合等)才可能参与循环引用。简单的整数、字符串不可能形成循环引用,所以不需要跟踪。


🔍 循环检测算法

可达性分析

GC 通过可达性分析来识别垃圾:

1
2
3
1. 从根对象开始(全局变量、栈上变量等)
2. 遍历所有可达对象,标记为存活
3. 未被标记的对象就是垃圾

这个过程被称为"标记 - 清扫"(Mark and Sweep)算法。

具体实现

1
2
3
4
5
6
7
8
9
10
11
12
static Py_ssize_t
gc_collect_generations(void)
{
// 1. 找出需要收集的代
for (i = NUM_GENERATIONS-1; i >= 0; i--) {
if (generations[i].count > generations[i].threshold) {
// 回收这一代及所有更年轻的代
return collect_with_callback(i);
}
}
return 0;
}

回收过程

一次完整的 GC 回收包括以下步骤:

1
2
3
4
5
6
7
8
9
10
static Py_ssize_t
gc_collect(int generation)
{
// 1. 合并待回收代的对象到年轻代
// 2. 更新引用计数(减去内部引用)
// 3. 识别 unreachable 对象
// 4. 处理 weakref 回调
// 5. 清除 unreachable 对象
// 6. 恢复 reachable 对象的引用计数
}

这个过程比较复杂,但核心思想是:暂时减去内部引用,如果某个对象的引用计数变成 0,说明它是循环引用的一部分,应该被回收。


💡 实战:观察垃圾回收

强制触发 GC

1
2
3
4
5
6
7
8
9
10
11
import gc

# 查看 GC 统计
print(gc.get_stats())

# 手动触发全量回收
collected = gc.collect()
print(f"回收了 {collected} 个对象")

# 设置调试模式
gc.set_debug(gc.DEBUG_STATS)

检测循环引用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import gc

class Obj:
def __init__(self, name):
self.name = name
self.ref = None

def __repr__(self):
return f"Obj({self.name})"

def __del__(self):
print(f"{self} 被销毁")

# 创建循环引用
a = Obj("A")
b = Obj("B")
a.ref = b
b.ref = a

# 删除引用
del a
del b

# 此时对象还存在(循环引用)
print(f"不可达对象:{gc.garbage}")

# 触发 GC
gc.collect() # 输出:Obj(A) 被销毁,Obj(B) 被销毁

使用 objgraph 可视化

1
pip install objgraph
1
2
3
4
5
6
7
8
9
10
11
12
import objgraph

# 显示最常见的类型
objgraph.show_most_common_types(limit=10)

# 查找循环引用
a = []
b = []
a.append(b)
b.append(a)

objgraph.show_backrefs([a], filename='backrefs.png')

objgraph 可以生成引用关系图,帮助你可视化内存中的对象引用。


⚠️ GC 的陷阱与最佳实践

1. del 方法的问题

1
2
3
4
class BadExample:
def __del__(self):
# 危险!在 GC 过程中调用__del__可能导致问题
pass

__del__ 方法的对象,GC 不能保证调用它。因为如果两个互相引用的对象都有 __del__ 方法,GC 无法确定先调用哪个。

推荐使用上下文管理器:

1
2
3
4
5
6
class GoodExample:
def __enter__(self):
return self

def __exit__(self, exc_type, exc_val, exc_tb):
self.cleanup() # 确定性的清理

2. 禁用 GC 的风险

1
2
3
4
5
import gc

gc.disable() # 禁用自动 GC
# ... 你的代码 ...
gc.enable() # 记得重新启用

某些性能敏感的场景(如游戏帧渲染)会临时禁用 GC,但要小心内存膨胀。

3. 弱引用打破循环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import weakref

class Node:
def __init__(self, value):
self.value = value
self._parent = None
self.children = []

@property
def parent(self):
return self._parent() if self._parent else None

@parent.setter
def parent(self, node):
self._parent = weakref.ref(node) if node else None

使用 weakref 可以避免循环引用。弱引用不增加引用计数,所以不会阻止对象被回收。


🎯 本讲总结

通过本讲,我们深入理解了:

引用计数的局限:无法处理循环引用,需要 GC 补充。

分代垃圾回收:三代对象的分级管理,提高回收效率。

可达性分析:如何识别循环引用的垃圾。

GC 触发时机:阈值机制与手动触发。

最佳实践:weakref、上下文管理器等避免循环引用的技巧。


📚 推荐教材

《Python 编程从入门到实践(第 3 版)》 - Eric Matthes 著

Python 零基础入门首选。本书分为基础语法和项目实战两部分,适合完全没有编程经验的读者。

《流畅的 Python(第 2 版)》 - Luciano Ramalho 著

Python 进阶经典之作。深入讲解 Python 的高级特性,包括数据模型、函数式编程、面向对象、元编程等。

《CPython 设计与实现》 - Anthony Shaw 著

本书深入讲解 CPython 内部机制,从内存管理到字节码执行,从对象模型到并发编程。配合本课程学习,效果更佳。

学习路线建议:

1
零基础 → 《从入门到实践》 → 《流畅的 Python》 → 本门课程 → 《CPython 设计与实现》

🔗 课程导航

上一讲:内存管理机制 | 下一讲:词法分析器 Tokenizer


💬 联系我

平台账号/链接
微信扫码加好友
微博@程序员晚枫
知乎@程序员晚枫
抖音@程序员晚枫
小红书@程序员晚枫
B 站Python 自动化办公社区

主营业务:AI 编程培训、企业内训、技术咨询

🎓 AI 编程实战课程

想系统学习 AI 编程?程序员晚枫的 AI 编程实战课 帮你从零上手!