「Python 进阶之路」系列 Day22
写在前面
模块四把并发编程讲完了,从今天开始进入模块五------内存管理与性能。第一篇先解决一个基础但很多人说不清楚的问题:Python 的对象到底是怎么被回收的?为什么有时候 del 一个对象立刻就触发了销毁,有时候明明 del 了却好像什么都没发生?答案就在引用计数和垃圾回收这两套机制的分工里。
一、是什么:引用计数是主力,分代GC是兜底
CPython 的内存管理机制由两部分组成:
- 引用计数(Reference Counting) :每个对象内部维护一个计数器,记录有多少个地方引用着它。每多一个引用,计数加一;每少一个引用,计数减一;计数归零的瞬间,对象立即被销毁,这是内存回收的主力机制
- 分代垃圾回收(Generational Garbage Collector):引用计数解决不了循环引用问题(两个对象互相引用,即使外部都不再需要它们,彼此的计数也不会归零),需要一个专门的回收器定期扫描、找出这类"死循环"对象并清理掉,这是引用计数的兜底方案(Day23 细讲怎么解决循环引用)
二、为什么Python选择引用计数:及时、可预测
用引用计数做主力回收机制的好处是内存释放及时、行为可预测------对象一旦没人用了,立刻就被回收,不需要像纯粹的"标记清除"式 GC 那样,攒到某个时机才统一扫描回收,程序不会因为等 GC 而突然卡顿。
代价是:每一次赋值、传参、放入容器,都要维护那个计数器,有一定的性能开销;而且引用计数天生处理不了循环引用,需要额外的机制兜底------这就是为什么 Python 同时具备引用计数和分代 GC 两套机制,各自负责一部分工作。
三、怎么用
1. 实测:引用计数什么时候增加、减少
python
import sys
a = object()
print(sys.getrefcount(a)) # 2 ------ 注意:getrefcount本身传参时也会临时多算一次引用
b = a
print(sys.getrefcount(a)) # 3 ------ b = a 增加了一次引用
c = a
print(sys.getrefcount(a)) # 4 ------ c = a 又增加了一次
del b
print(sys.getrefcount(a)) # 3 ------ del b 减少了一次
del c
print(sys.getrefcount(a)) # 2 ------ 回到最初的值
函数传参会临时增加一次引用计数,函数返回后这个临时引用消失:
python
def show_count_inside(obj):
print(sys.getrefcount(obj)) # 3 ------ 函数参数多算了一次
x = object()
print(sys.getrefcount(x)) # 2
show_count_inside(x)
print(sys.getrefcount(x)) # 2 ------ 函数返回后,恢复原值
容器持有元素,同样会增加被持有对象的引用计数:
python
y = object()
print(sys.getrefcount(y)) # 2
lst = [y, y, y] # 同一个对象放进列表3次
print(sys.getrefcount(y)) # 5 ------ 增加了3
lst.clear()
print(sys.getrefcount(y)) # 2 ------ 清空后恢复
2. 引用计数归零,对象立即被销毁
用 __del__ 方法(对象被销毁时自动调用)验证销毁的时机:
python
class Watched:
def __init__(self, name):
self.name = name
def __del__(self):
print(f"{self.name} 被销毁了!")
w = Watched("w对象")
del w
# w对象 被销毁了! ← 这行紧跟着 del w 立刻打印出来
del w 执行完,__del__ 就立刻打印了销毁信息------不需要等待任何形式的 GC 扫描,引用计数归零的那一刻,对象就被销毁了,这正是引用计数"及时"这个特点最直接的体现。
3. 引用计数处理不了循环引用
python
class Node:
def __init__(self, name):
self.name = name
self.other = None
def __del__(self):
print(f"Node {self.name} 被销毁了")
n1 = Node("n1")
n2 = Node("n2")
n1.other = n2 # n1 引用 n2
n2.other = n1 # n2 引用 n1,形成循环引用
del n1
del n2
# 这里什么都没打印!n1、n2 互相引用着对方,各自的引用计数都没有归零
import gc
collected = gc.collect()
# Node n1 被销毁了
# Node n2 被销毁了
print(collected) # 2 ------ gc.collect() 手动触发后才真正清理掉
del n1、del n2 只是删掉了"外部变量"这两个引用,但 n1 和 n2 内部还互相持有着对方(n1.other 指向 n2,n2.other 指向 n1),它们的引用计数都还是 1,永远不会自然归零------单靠引用计数,这两个对象会一直"卡"在内存里,直到 gc.collect()(分代垃圾回收器)介入,识别出这个循环引用的孤岛并清理掉。这也就回答了开头的问题:del 一个普通对象通常会立刻销毁;但如果这个对象牵涉在循环引用里,del 只是拿走了外部引用,对象本身要等分代 GC 扫描到才会被真正清理。
4. gc模块:分代回收基础用法
python
import gc
print(gc.get_count()) # (1, 0, 0) ------ 三代对象数量:年轻代、中年代、老年代
print(gc.get_threshold()) # (700, 10, 10) ------ 三代各自的回收触发阈值
Python 的分代 GC 基于分代假设:大部分对象存活时间很短,活得越久的对象越不容易变成垃圾。所以把对象分成三代,新创建的对象放进第 0 代,年轻代扫描得勤(阈值低,700 个新对象就触发一次),扛过扫描没被回收的对象晋升到下一代,老年代扫描得少(阈值高),这样能在保证正确性的同时尽量减少扫描的开销,只重点盯着"最可能变成垃圾"的那批年轻对象。
四、面试追问
Q1:Python 的内存管理机制主要是什么?
以引用计数为主力:每个对象内部维护一个引用计数器,计数归零时对象立即被销毁;以分代垃圾回收为兜底:定期扫描找出引用计数解决不了的循环引用并清理掉,两套机制配合工作。
Q2:引用计数的优缺点分别是什么?
优点是内存释放及时、行为可预测,对象一旦没人用了立刻就被回收,不会因为等待 GC 扫描而导致程序卡顿;缺点是每次赋值、传参、放入容器都要维护这个计数器,带来一定的性能开销,而且天生处理不了循环引用问题。
Q3:什么时候一个对象的引用计数会增加/减少?
增加的场景:变量赋值指向它(b = a)、作为参数传给函数、被放进容器(list、dict 等);减少的场景:变量被 del 或重新赋值指向别的对象、变量离开作用域、从容器里被移除。
Q4:为什么需要分代垃圾回收,它解决了什么引用计数解决不了的问题?
解决循环引用问题------两个(或多个)对象互相引用,即使外部不再有任何引用指向它们,彼此之间的引用计数也不会归零,单靠引用计数永远不会主动回收这类对象,需要分代垃圾回收器定期扫描,识别并清理这些孤立的引用环。
Q5:怎么手动触发垃圾回收,什么场景需要?
调用 gc.collect() 可以手动触发一次完整扫描。一般场景不需要手动干预,Python 会按各代的阈值自动触发;但在明确知道刚产生了大量循环引用、并且希望立刻释放这部分内存的场景(比如处理完一批带有循环引用的大对象之后),可以主动调用一次。
下一篇预告
Day23 讲循环引用具体是怎么被处理的------gc 模块底层的检测原理,以及用 weakref(弱引用)从源头上避免循环引用产生的正确姿势。