为什么 del 一个对象有时立刻消失,有时却纹丝不动?一文讲透引用计数与垃圾回收

「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. 引用计数处理不了循环引用

flowchart TB subgraph del之前有外部引用 E1[外部变量n1] --> N1[NodeA] E2[外部变量n2] --> N2[NodeB] N1 --> N2 N2 --> N1 end subgraph del之后只剩循环引用 N3[NodeA] --> N4[NodeB] N4 --> N3 end
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 n1del n2 只是删掉了"外部变量"这两个引用,但 n1n2 内部还互相持有着对方(n1.other 指向 n2n2.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(弱引用)从源头上避免循环引用产生的正确姿势。

相关推荐
编码浪子1 小时前
Rust 智能指针深度实战:Box/Rc/Arc/RefCell 选型决策与内存管理避坑
开发语言·后端·rust
yichudu1 小时前
Python 装饰器 decorator
开发语言·python
一晌小贪欢1 小时前
Python办公14:批量解压文件夹内的所有 ZIP/RAR 并自动分类
开发语言·python·excel·数据可视化·python办公
Python 实战手记1 小时前
企业微信主体变更公证书办理全解析:适用场景、材料规范、踩坑点与Python信息校验脚本
开发语言·python·企业微信
郝学胜_神的一滴1 小时前
Effective Python 条款 2:遵循 PEP 8 编码风格,写出高质量 Python 代码
python·算法
郝学胜-神的一滴1 小时前
Effective Python 条款 1:确认你正在使用的 Python 版本
开发语言·数据结构·python·程序人生·算法
提线木偶1 小时前
CORS 到底谁说了算?一份跨域配置的避坑指南
前端·后端
RanMatrix2 小时前
Python列表精讲
python
大模型丫丫2 小时前
FastAPI 入门指南:从零开始构建高性能 Python API
开发语言·python·fastapi