2. 垃圾回收机制
聊到Python的内存管理,很多人第一反应就是「Python有垃圾回收,不用管内存」。嗯,这话对了一半。Python确实有垃圾回收,但它不是万能的。我在生产环境里见过太多因为不懂GC机制而搞出来的内存泄漏,有的甚至把32G内存吃满,服务直接OOM挂掉。
说白了,Python的垃圾回收是三层架构:引用计数是主力,标记清除是救火队,分代回收是日常保洁。这三层配合起来,大部分场景都没问题。但每一层都有它的盲区,咱们一个一个说。
引用计数:最基础也最脆弱
Python里每个对象都有一个ob_refcnt字段,记录着有多少个引用指向它。当这个计数降到0,对象立即被回收。这是最直接、最实时的回收方式。
import sys
a = []
print(sys.getrefcount(a)) # 输出2,因为getrefcount本身也引用了a
b = a
print(sys.getrefcount(a)) # 输出3
del b
print(sys.getrefcount(a)) # 输出2
看起来很简单对吧?但这里有个坑:循环引用。两个对象互相引用,它们的引用计数永远不会降到0。
class Node:
def __init__(self):
self.ref = None
a = Node()
b = Node()
a.ref = b
b.ref = a
del a
del b
# 此时两个Node对象引用计数都是1,永远不会被回收
我在项目中遇到过这种情况:一个缓存系统里,对象之间互相持有引用,导致内存只增不减。当时查了两天才定位到问题,就是因为循环引用把引用计数给「锁死」了。
标记清除:救火队也有局限
为了解决循环引用,Python引入了标记清除算法。它从根对象(比如全局变量、调用栈)出发,遍历所有可达对象,打上标记。没被标记的就是垃圾,直接清理。
这个过程分两步:
- 标记阶段:从根出发,遍历所有引用,标记可达对象
- 清除阶段:扫描所有对象,回收未标记的
听起来很完美?其实不然。标记清除有两个问题:
- 性能开销大:需要遍历整个对象图,对象越多越慢
- 会暂停程序:标记清除期间,Python解释器会暂停所有线程
我曾经在一个高并发服务里,因为对象数量太大,标记清除一次耗时好几秒,导致请求超时。嗯,那场面相当尴尬。
分代回收:日常保洁的艺术
Python把对象分成三代:
| 代 | 特点 | 回收频率 |
|---|---|---|
| 第0代 | 新创建的对象,存活时间短 | 最频繁 |
| 第1代 | 经过一次回收还活着的对象 | 中等 |
| 第2代 | 长期存活的对象 | 最低 |
这个设计基于一个经验规律:大部分对象都是朝生暮死的。你想想看,函数里的临时变量、循环里的中间结果,用完就扔。只有少数对象能活下来。
分代回收的策略很简单:
- 第0代回收最频繁,因为垃圾最多
- 第1代次之
- 第2代很少回收,因为能活到这一代的对象通常都很「稳定」
这样做的好处很明显:大部分回收只发生在第0代,速度快、影响小。只有第0代回收次数达到阈值,才会触发第1代回收,以此类推。
gc.get_threshold() 查看当前的回收阈值。默认是 (700, 10, 10),意思是第0代回收700次后触发第1代,第1代回收10次后触发第2代。
它们如何失效?
好了,现在咱们聊聊最核心的问题:这三层机制在什么情况下会失效?
引用计数失效的场景:
- 循环引用(最常见)
- 使用
__del__方法时,可能导致对象无法被回收 - C扩展模块中手动管理引用计数出错
标记清除失效的场景:
- 对象数量巨大时,标记清除本身会成为性能瓶颈
- 如果对象图过于复杂,标记阶段可能耗尽CPU
- 无法回收那些被C扩展模块「偷偷」引用的对象
分代回收失效的场景:
- 长期存活的对象不断创建新引用,导致第2代对象越来越多
- 如果程序创建对象的速度远大于回收速度,分代回收也扛不住
- 某些对象虽然存活了很久,但其实是「僵尸对象」,占着内存不干活
weakref 来避免循环引用。结果因为没处理好弱引用的回调,导致对象被提前回收,数据丢失。嗯,弱引用也不是银弹,用之前一定要想清楚生命周期。
一张图看懂Python垃圾回收
下面这张图展示了Python垃圾回收的整体流程。你可以看到引用计数是第一条防线,分代回收是日常维护,标记清除是最后的兜底方案。
从这张图你可以看到,Python的垃圾回收是一个层层递进的过程。引用计数挡不住的问题,交给分代回收;分代回收搞不定的,最后由标记清除兜底。但每一层都有它的失效场景,了解这些场景,你才能写出真正内存安全的代码。
可可精品资料库