搞 Python 三年了,最近才搞懂这个垃圾回收机制,原来引用计数只是冰山一角.
搞 Python 三年了,最近才搞懂这个垃圾回收机制,原来引用计数只是冰山一角。
三年前我开始学 Python。那时候看到变量不用声明类型,感觉真方便。后来写代码越来越熟练,但心里一直有个疙瘩。程序跑着跑着内存就涨上去了,有时候还会报内存错误。我查了很多资料,大家说的最多的就是引用计数。说是每个对象都有个计数器,记着有多少个引用指向它。计数归零的时候,对象就被回收了。听起来挺简单的,对吧?
但我心里总觉得不对劲。那循环引用呢?两个对象互相指来指去,引用计数都大于零,那不就永远删不掉了吗?我问过几个朋友,有人告诉我说 Python 还有别的垃圾回收机制。我没当回事,觉得引用计数够用了。直到有一天,我写了个缓存系统,里面用字典存了各种对象。这些对象之间有关系,有时候形成环状引用。系统跑了两天,内存占用涨到十几个 G,服务直接挂掉了。
那天晚上我翻来覆去睡不着。我打开电脑,把 Python 的垃圾回收源码翻出来看。这一看不要紧,发现之前想的太简单了。引用计数只是最基础的一层,在它下面还有更复杂的机制。
原来 Python 内部维护着一个双向链表,叫做零代链表。所有新创建的对象都先挂在这上面。这里的零代不是指引用计数为零,而是指对象没有被任何垃圾回收器扫描过。当这个链表里的对象数量超过一个阈值,Python 就开始垃圾回收了。它会标记所有能直接从根对象到达的对象。根对象是什么?就是全局变量、当前栈帧里的局部变量这些。那些无法到达的对象,就是垃圾。
但光标记清除还不够,因为有些对象虽然被引用了,但没有被外部任何变量指向。这就是循环引用的问题。 Python 的垃圾回收器用一个很巧妙的办法来解决。它会把引用计数减一,然后看哪些对象的引用计数变成零了。那些就说明是只有内部引用,没有被外部引用的。最后把这些对象清理掉。
这个方法需要扫描所有对象。对象多了就很慢。所以 Python 把对象分成三代,零代、一代、二代。新对象先进零代链表。零代链表够大了就扫描一次,找到的存活对象挪到一代链表。一代链表也满了再扫描,存活对象挪到二代。二代里都是长期存活的对象,很少被扫描。这样大部分时间只处理新创建的对象,效率高很多。
我当时看代码看到这里,后背都出汗了。 原来我一直用的引用计数,根本没法处理循环引用。Python 背后还做了这么多工作。我以前写代码的时候,从来不管对象之间的循环引用。觉得 Python 自己能管好。现在想想,太天真了。虽然 Python 能回收循环引用的对象,但这个过程不是实时的。你得等它触发垃圾回收。如果你程序里临时创建大量循环引用的对象,还没来得及触发回收,内存就爆了。
后来我改了自己的代码。在缓存系统里,我避免使用循环引用。实在避不开,我就用弱引用。弱引用不增加引用计数,对象不会被它拖住。我还定期手动调用 gc.collect()。这样内存就稳定多了。有时候我在想,写代码不能光靠感觉。你觉得自己懂了,其实只是冰山一角。真正深入进去,才发现背后有那么多细节。
现在写 Python 代码,我总会想想对象的生命周期。引用计数和垃圾回收配合起来,才撑起了 Python 的内存管理。引用计数处理短生命周期的小对象,快且高效。垃圾回收应对循环引用和长生命周期的大对象。两者配合,各有分工。我搞了三年才明白这个道理,虽然有点丢人,但总比一直蒙在鼓里强。