
谈一谈Python的垃圾回收机制
引言
最近在学大模型,先学了python,深感python的简洁与遍历,不像C或者C++需要手动管理内存,python是自动管理和自动回收,但这并不意味着我们可以完全放任不管,毕竟我们作为有高度探索欲的程序猿,想必对具体的细节还是有一些求知欲的(其实就是因为这是面试高频题🤣),作为我在小站的第一篇技术博客,我想写一些我对Python的垃圾回收机制的理解。
一、Python垃圾回收机制分类
1.1 引用计数
Python的基本垃圾回收机制是基于引用计数的。每个对象都有一个计数器,记录有多少个引用指向它。当计数器归零时,就意味着这是一个无家可归的对象,没有人需要他🤣,该对象可以被回收了,很简单对吗?
a = []
b = a
c = a
del a
del b
# c 仍然持有对列表的引用,因此列表不会被回收
上述只创建了一个列表,但有a,b,c三个变量指向列表,列表的计数器为3。del了a,b后指数器为1,无法被回收。
会导致对象引用次数+1的情况:
- 对象被创建,它的引用次数初始化为1
- 对象被其它对象引用
- 对象被作为函数参数传递
- 对象被存储在容器之中
del c # 把c也删掉 都没咯
如果我们继续del c,此时计数器归零,才可以真正被回收。
会导致对象引用次数-1的情况:
- 显式删除引用,可以使用del()函数
- 变量超出作用域,如将对象作为参数传入函数,函数执行完成之后,这个局部变量在其作用域就结束了,该变量的引用会被销毁,引用计数减一
- 对象被从容器中移除
- 对象被重新赋值
绝大多数时候,这套逻辑够用了,而且效率也不错,实时统计,一旦归零,立马回收,速度飞快。如果你觉得这就够了,显然你没有经历过复杂项目里循环引用的情况。
a = []
b = []
a.append(b)
b.append(a)
上述代码中创建了两个列表,互相嵌套,这就造成了循环引用的问题,a→b→a→b...无穷无尽,对于整个项目来说,他们跟业务无关,是回收掉也无伤大雅的对象,但是因为互相引用,所以他们的计数器都为1,是无法被引用计数回收掉的对象,这时候就需要新的方法了。
1.2 标记-清除
为了解决循环引用问题,Python引入了标记-清除算法
标记-清除算法简而言之,是通过可达性分析遍历所有对象来确定哪些是可以安全回收的,它像是一次彻底的大扫除,遍历到家里所有的边边角角,然后清除不到的地方,就可以认为是垃圾,全部扔掉!
这个过程分两步走:
- 标记阶段:从全局变量、栈帧、模块等根对象出发,递归标记所有可达对象。
- 清除阶段:遍历所有对象,回收未被标记的对象。
显然,它处理循环引用的垃圾非常有效,但这里有个性能陷阱——每次GC都要遍历所有对象!如果你的程序有几万个对象,每次垃圾回收都要全部扫一遍...
1.3 分代回收策略
基于上面的问题,我们又有了分代回收策略。该策略是基于“弱代假说”,这是来自程序员的观察——“大部分对象都短命”,即需要全程保持活跃的对象其实是很少的,大部分对象都是局部变量,用完就可以回收了。基于这种观察,我们将对象分为三代进行不同频率的垃圾回收检查。这个机制挺有意思,它是从 Java 世界借鉴过来的一个思路,基本逻辑是这样的:新创建的对象,先放在一个叫做“年轻代”的地方。如果这些对象存活了一段时间,还没有被回收掉,Python 就会认为它们生命力顽强,把它们升到“中年代”甚至“老年代”。
这么做的好处是什么?就是优化了回收效率。年轻代的对象通常是生命周期短的,比如函数里的局部变量,一结束就扔掉。而老年代的对象,比如模块级的缓存,通常一活就是全程,没必要每次都去检查它们是不是垃圾。
于是垃圾回收就被拆成了三代,每一代里都有一堆对象。Python 会更频繁地回收第 0 代,也就是最年轻的一批对象,而不是每次都把所有对象全检查一遍,这样做能显著降低性能开销。
import gc
print(gc.get_threshold()) # 查看当前的分代阈值
通过gc.get_threshold()
方法可以查看当前的分代阈值,默认得到的应该是(700,10,10),即为当新生代中的对象达到700个之后会触发新生代的垃圾回收,当新生代回收次数达到10次后会触发新生代与中年代的回收,当中年代回收次数达到10次之后,会触发一次三个分代的回收。如果有需要,也可以通过gc.set_threshold()
方法来设置分代阈值。
二、常见解决方案
2.1 需要循环引用时用弱引用
循环引用会导致内存泄漏,因为即使删除了相关变量,这些对象间的相互引用使得它们的引用计数永远不会降到零。使用weakref
模块可以有效避免此类问题:
import weakref
obj = [1, 2, 3] # 强引用
weak_ref = weakref.ref(obj) # 弱引用
- 强引用:每增加一个引用,对象的引用计数就会加 1;当引用计数为 0 时,对象会被引用计数垃圾回收机制回收。
- 弱引用:弱引用是一种特殊的引用,它不会增加对象的引用计数。当对象的其他正常引用都被移除后,即使存在弱引用,对象也会被垃圾回收。弱引用可以在对象存在时访问它,但不会阻止对象被销毁。
弱引用就像暧昧,在一起,但不会占有对象不松手,对方有了更好的结果(被回收)也会大大方方放手并祝福🤣
2.2 手动控制GC
python的自动GC已经非常成熟,但在某些场景下手动干预可能更有利。例如,在处理大量临时对象后手动触发GC可以帮助释放内存:
import gc
class Node:
def __init__(self, value):
self.value = value
self.next = None
# 创建循环引用
a = Node(1)
b = Node(2)
a.next = b
b.next = a
del a # 删除外部引用
del b
# 手动回收前检查
print("回收前:", gc.garbage) # 可能输出:[<Node object>, <Node object>]
gc.collect()
# 回收后检查
print("回收后:", gc.garbage) # 输出:[]
上述的代码创建了循环引用,引用计数是无法回收的,在del引用后我们用gc.garbage
可以看到这两个对象可能还在(也可能为[],即已经被回收了,因为垃圾回收是一个自动的过程,所以具体会不会展示出来得看gc的心情),如果还在,我们可以手动调用gc.collect()
方法来执行回收机制来回收掉循环引用的垃圾。
`
三、面试题
作为第一篇博客,我尽量将我的思路通俗易懂表达出来,下面是一些面试的提问:
- python的垃圾回收是怎么做的?(引用计数解决大多数垃圾对象,标记清除可以解决循环引用的垃圾对象,同时引入分代回收的策略来优化回收效率)
- 引用计数的优缺点?(优点:简单,实时,高效;缺点:需要维护多余的字段来计数,增加了开销,且计数无法解决循环引用的问题)
- 标记清除的优缺点?(优点:可以解决循环引用的问题;缺点:对象多的情况下每次都需要遍历一遍,性能开销大)
- python怎么回收循环引用的垃圾?(标记清除+分代回收)
- 强引用和弱引用的区别?(强引用把持着对象,对象引用计数会+1,无法被引用计数回收机制给回收;弱引用不会增加引用计数,对象可以被正常回收)
- 如果项目需要用到循环引用,你会怎么做?(使用弱引用/手动gc)
- 感谢你赐予我前进的力量