GC 与内存管理深挖:分代回收、循环引用、weakref 与 __slots__
更新时间:2026-09-02。本文回答:Python 对象到底什么时候被回收,为什么有时候
del了内存却不还?分代 GC 的「三代」「阈值」具体是什么、什么时候触发、代价多大?循环引用是怎么被找到的?怎么让对象「不被引用计数算进去」(weakref)、怎么让百万个小对象少占一半内存(__slots__)? 02 · CPython 内部机制 讲了对象头、引用计数基础和 arena/pool 内存池;这一篇专门往下挖垃圾回收器(gc模块)这一层——它和引用计数是两套协作的机制。本文 5 个 demo 全部在服务器(CentOS 7,4 核,Python 3.6.8)实测,代码见文末「代码位置」。
一、两套回收机制:引用计数立即回收,分代 GC 兜底循环
CPython 有两套回收机制,分工不同:
- 引用计数(reference counting):每个对象头里有
ob_refcnt,多一个引用 +1、少一个 -1,归零立刻释放。它是主力,回收是确定性的、即时的。 - 分代垃圾回收器(generational GC,
gc模块):引用计数管不到循环引用——几个对象互相引用形成环,外界都不用了,每个的refcnt仍 ≥1,永远降不到 0。分代 GC 周期性地扫描、找出这种「只有环内互相引用、外部已不可达」的对象群并回收。
demo1(d1_cycle_refcount/cycle_refcount.py)用 weakref.finalize(对象被回收的瞬间触发回调)精确演示三者差异:
== A: no cycle -> refcnt hits 0, freed immediately on del ==
[finalized] A
== B: cycle (a.peer=b, b.peer=a) -> refcnt stuck at 1 ==
after del a,b: NOT finalized yet (cycle keeps refcnt>=1)
[finalized] A
[finalized] B
gc.collect() found 6 unreachable objects -> now they die
== C: gc-tracked objects count ==
before=5966 after 1000 cycles=6158 after collect=5966- A(无环):
del a后[finalized]立刻打印——引用计数归零,当场释放。 - B(互指环):关掉 GC 后
del a,b,两个对象都不释放(refcnt 被对方吊着);手动gc.collect()一次,找到 6 个不可达对象(两个 Node + 各自的__dict__+ 环相关对象)才回收。 - C:持续造环,GC 跟踪的对象数在分配后上升(5966→6158),
collect()后回落到 5966——环垃圾被清掉。
注意:只有「容器对象」才被分代 GC 跟踪(list、dict、class 实例、tuple 等可能持有别人引用的);int、str 这类不可能参与循环的对象不进 GC 链表,纯靠引用计数。这也是为什么
gc.get_objects()返回的数量远小于程序里的对象总数。
引用计数即时回收是有代价的:每次 refcnt 的增减都要原子操作,多线程下这正是 08 篇讲的 GIL 要保护的共享状态之一——频繁改引用计数会成为多核并行的瓶颈,这也是 Go 干脆不用引用计数、改走并发标记-清除的原因之一。但它换来的是确定性回收:绝大多数对象死的当下就还内存,不必等 GC 周期,也没有 Java/Go 那种需要调优的大堆 STW 停顿。
二、分代 GC:三代、阈值、计数器与对象迁移
GC 把所有跟踪对象分三代(gen0 / gen1 / gen2)。新对象进 gen0;在一次 gen0 扫描中活下来(仍被引用)的对象晋升 gen1;在 gen1 扫描中活下来的晋升 gen2。依据是弱分代假说:「越年轻的对象越可能很快死掉,活久了的对象大概率继续活」。所以只需频繁扫 gen0(年轻、垃圾多、扫描便宜),很少全量扫 gen2(老对象、垃圾少、但要遍历整个堆)。
demo2(d2_generations/generations.py)观察阈值、计数器和迁移过程:
thresholds (th0, th1, th2): (700, 10, 10)
start (all collected) counts=(7, 0, 0)
after 300 short-lived objs counts=(10, 0, 0)
after collect(0) counts=(0, 1, 0)
after 3x collect(0), 1000 long-lived counts=(0, 5, 0)
after collect(1) counts=(0, 0, 1)读数对应 GC 的触发规则:
get_threshold() = (700, 10, 10):gen0 的「分配减去释放」计数器超过 700 就触发一次 gen0 扫描;同时每触发一次 gen0 扫描,gen1 的计数器 +1,当它超过 10 就顺带做 gen1 扫描;gen1 扫描计数超过 10 才做最贵的 gen2 全堆扫描。get_count()是三代的当前计数器。造 300 个短命对象后 gen0 计数只从 7 涨到 10(多数对象引用计数当场释放,没进 GC 跟踪或已注销)。- 迁移可见:
collect(0)后计数变(0,1,0)——gen0 清零、gen1 +1(gen0 幸存者晋升上来);造 1000 个长寿命对象、连续 3 次 gen0 扫描后(0,5,0)(它们都晋升到 gen1 了);collect(1)后变(0,0,1)——gen1 清零、gen2 +1(长寿命对象最终沉淀到 gen2)。
注意触发是代际联动的:一次 gen0 分配计数超 700 触发 gen0 扫描时,CPython 会顺手检查 gen1 的扫描计数是否超 10,超了就连 gen1 一起扫,扫 gen1 时同理可能连带 gen2。所以三代不是独立定时,而是「高频的 gen0 带动低频的 gen1,再带动极低频的 gen2」——这正是分代设计用少量全堆扫描覆盖所有环垃圾的机关。
gc.get_stats() 还能看每代的累计扫描次数/回收数/不可回收数;gc.set_threshold(th0, th1, th2) 可调。例如把 th2 调小(如 set_threshold(700, 10, 5))会让 gen2 全堆扫描更频繁——回收更及时但单次停顿更常出现,长跑服务要权衡。
三、回收的代价:gen2 是全堆扫描,批量任务干脆关掉 GC
分代设计的目的就是让昂贵的全堆扫描极少发生。gen0/gen1 扫描只看新生代,代价小且基本恒定;gen2 扫描要遍历整个堆里所有长寿命对象,代价随存活对象数线性增长。
demo3(d3_gc_cost/gc_cost.py)先把不同数量的长寿命对象都晋升到 gen2,分别测三次 collect() 的最佳耗时,再对比批量产生循环垃圾时 GC 开/关的吞吐:
long-lived objects= 20000 : collect(0)=0.0000s collect(1)=0.0000s collect(2)=0.0031s
long-lived objects=100000 : collect(0)=0.0000s collect(1)=0.0000s collect(2)=0.0343s
produce 100000 cyclic garbage pairs: GC-on=0.065s GC-off=0.052s (1.2x faster)两条结论:
- gen0/gen1 几乎免费,gen2 随堆增长:长寿命对象从 2 万涨到 10 万(5 倍),
collect(2)从 0.003s 涨到 0.034s(约 11 倍,含缓存效应);而 gen0/gen1 始终接近 0。这就是为什么阈值让 gen2 极少触发。 - 短命批处理任务可以直接
gc.disable():批量产生循环垃圾时,GC 开着要反复跟踪/回收,关掉快约 1.2x(对象越重、环越大收益越明显)。代价是循环垃圾不回收、内存一路涨——但批处理脚本跑完进程就退出,操作系统回收全部内存,泄漏无所谓。这是数据处理/构建脚本常用的加速手段;长跑服务(Web/Worker)绝不能关,否则环垃圾无限堆积。
一个衔接 08 · 多进程 的利器:
gc.freeze()会把当前所有跟踪对象移到一个永久的「冻结代」,之后 GC 扫描不再看它们。在多进程「父进程预加载大模型/大数据再 fork」的场景里,fork 前调一次gc.freeze(),子进程的 GC 就不会去扫描那批由 COW 共享的、本就长寿命的对象——既省 gen2 扫描时间,又避免扫描时触碰这些页、因引用计数改动而破坏写时复制(08 篇讲过引用计数会让 COW 退化)。
四、循环引用是怎么被「找出来」的
简述 GC 找环的思路(不必记细节,理解原理即可):GC 维护着所有「容器对象」的双链链表。一次扫描时,它:
- 遍历所有候选容器,沿每个对象的
tp_traverse找到它引用的对象,模拟地把「来自这批对象内部」的引用计数扣掉; - 扣除之后,引用计数仍然 >0 的,说明还有来自环外(根集合)的引用,是活对象;被扣到 0 的,说明只被环内同伴引用、外界已不可达——就是垃圾,回收;
- 如果垃圾对象定义了
__del__(旧版本叫 finalizer),早期 CPython 无法判断能否安全销毁,会把它丢进gc.garbage列表不敢回收(Py3.4+ 改了规则,大多能正常回收,但仍建议少在环对象上写__del__)。
这也是为什么非容器对象(int/str)不参与 GC:它们不可能持有对别的对象的引用,永远不可能构成环,让引用计数管就够了,GC 不必跟踪它们。
顺带对比一下两条语言路线:CPython 是「引用计数为主、分代 GC 只补环」,所以平时回收即时、停顿小而频繁地分摊在每次引用变化上;Go/JVM 是「纯可达性分析 GC,无引用计数」,对象死活全靠周期性标记-清除,平时无计数开销、多线程友好,但要面对明确的 GC 周期和调参(见 Go · GC 与逃逸分析)。理解这个差异,就明白为什么 Python 谈 GC 常谈「消环、weakref、关 GC」,而 Go 谈 GC 常谈「触发频率、STW、逃逸到堆」。
五、weakref:不算引用计数的「弱引用」
与其等 GC 兜底,更好的工程实践是从根上避免循环引用。weakref.ref(obj) 给对象一个「弱引用」——它不增加 refcnt,不参与引用计数:
- 对象活着时,
weak()返回它; - 对象一旦被回收,
weak()立刻返回None(弱引用自动失效)。
demo4(d4_weakref/weakref_demo.py)对比强/弱反向引用,以及弱引用缓存:
== A: strong back-reference => cycle, refcount cannot free ==
after del (GC off): child still alive (cycle; refcnt stuck)
after gc.collect(): child DEAD
== B: weak back-reference => no cycle, freed immediately on del ==
after del (GC still OFF): root DEAD (refcnt hit 0 right away)
== C: weakref cache auto-evicts ==
cache has 'report': True | big alive: True
after big is dropped, cache ref resolves to: None -> auto-evicted典型用法是树/图结构里「孩子→父亲」的反向指针:父对象持有孩子用强引用(父在孩子在),孩子指回父亲用 weakref——父对象被丢弃时,不会因为孩子的反向指针形成环,引用计数直接回收,不必劳烦 GC、也没有任何停顿。另一个经典场景是缓存:用 weakref.WeakKeyDictionary / WeakValueDictionary 存对象,当对象在别处没人用时自动从缓存消失,缓存不会越攒越大、也不用手动淘汰。
六、__slots__:砍掉每个实例的 __dict__
普通对象的属性都存在一个独立的 __dict__ 字典里,灵活(能随时加属性),但每个实例都背一个字典,对「海量小数据对象」是巨大浪费。在类里声明 __slots__ = ("x","y","z") 后:
- 实例不再有
__dict__,属性固定为声明的那几个,存在对象体内像 C 的结构体成员; - 省下每个实例一个字典(约 112 字节空字典);
- 属性访问也略快(走描述符而非哈希查找);
- 代价:不能再动态加属性、多继承受限。
demo5(d5_slots/slots_demo.py)实测单实例与 50 万实例的占用:
one Regular instance: sizeof=56 + __dict__=112 = 168 bytes
one Slotted instance: sizeof=64 bytes (no __dict__, attrs fixed)
Regular x500000: approx 80.1 MB (instances + dicts)
Slotted x500000: approx 30.5 MB (instances only)
savings: 2.6x smaller (62% less)
Slotted rejects new attribute: AttributeError: 'Slotted' object has no attribute 'w'50 万个三字段小对象,普通写法约 80MB,__slots__ 后约 30.5MB,省 62%。对 ORM 行对象、树节点、向量/坐标这类「量大、字段固定」的数据结构非常划算。注意 __slots__ 优化的是实例数 × 每实例字典,只有几百万量级才明显;几个对象的类没必要加。
七、什么时候用什么:一张速查表
| 场景 | 做法 |
|---|---|
| 普通对象回收 | 不用管,引用计数归零立即释放 |
| 树/图的「子→父」反向指针 | 用 weakref,从根上消环,别等 GC |
| 想让缓存随对象自动失效 | weakref.WeakValueDictionary / WeakKeyDictionary |
| 百万级字段固定的小对象 | __slots__ 砍 __dict__(省内存、略提速) |
| 短命批处理/数据管道脚本 | 开头 gc.disable(),跑完进程退出、内存全还 |
| 长跑服务(Web/Worker) | 保持 GC 开启;必要时低峰 gc.collect() |
| fork 前预加载大模型/大数据 | fork 前 gc.freeze(),保护 COW、免扫共享对象 |
| 排查「谁在泄漏/循环引用」 | gc.set_debug(gc.DEBUG_SAVEALL) 后看 gc.garbage;tracemalloc 定位分配点(见 02 篇) |
八、常见坑
- 环对象上的
__del__与gc.garbage:虽然 Py3.4+ 大多能回收,但在无法确定销毁顺序时,带 finalizer 的环对象仍可能被留进gc.garbage,不释放。排查泄漏先print(gc.garbage)、开gc.set_debug(gc.DEBUG_SAVEALL)。 - 以为
del就会释放:del只减一个引用;对象还被列表、闭包、循环引用、缓存持有就不会释放。「del 了内存没掉」多半是别处还引用着,或是进入了环等 GC。 - 长跑服务误用
gc.disable():批处理脚本关 GC 是合理优化,但长期运行的进程关掉后环垃圾无限累积、内存只涨不跌。要么别关,要么在任务边界周期性gc.collect()。 __slots__不是银弹:它让类失去动态属性(加未声明属性直接AttributeError)、多继承多个带 slots 的父类麻烦;实例少时毫无收益,只有海量同构对象才值得。- 弱引用解引用可能瞬间变 None:
r = weakref.ref(x); r().method()之间对象可能被回收,要先取到本地变量o = r()判空再用,别链式r().foo()。 gc.collect()不是越多越好:尤其collect(2)是全堆扫描,堆大时几十毫秒,频繁手动调用会制造停顿;让阈值自动调度即可。
代码位置
所有 demo 均为纯标准库,在服务器(CentOS 7,4 核,Python 3.6.8)实测通过:
demos/python-expert/gc-memory/
├── run_all.sh # 一键顺序跑 5 个 demo
├── d1_cycle_refcount/cycle_refcount.py # 引用计数立即释放 vs 互指环不 collect 不释放(finalize 观测)
├── d2_generations/generations.py # 阈值(700/10/10)/计数器/gen0→1→2 迁移/get_stats/set_threshold
├── d3_gc_cost/gc_cost.py # gen0/1/2 回收耗时(gen2 随堆 0.003→0.034s);批量关 GC 快 1.2x
├── d4_weakref/weakref_demo.py # 弱反向引用消环立即释放;弱引用缓存自动淘汰
└── d5_slots/slots_demo.py # __slots__ 砍 __dict__:168B→64B,50 万实例省 62%本篇是并发/内存叙事的收束:07 · asyncio 讲 I/O 并发、08 · 多进程 讲 CPU 真并行,本篇讲这些进程/对象背后的内存回收;分代回收是跨语言通用思想,Go 的 GC 走的是另一条路线(并发标记-清除 + 逃逸分析,无引用计数),可对照 Go 专家层 · GC 与逃逸分析。
一句话总结
Python 用引用计数做「即时回收」、分代 GC 做「循环引用兜底」:无环对象 del 即释放,互指环要等 GC 扫描(gen0/gen1 便宜、gen2 全堆扫描随堆变贵);工程上用 weakref 从根上消环、用 __slots__ 给海量小对象省内存、批处理脚本可 gc.disable()、fork 前 gc.freeze() 保护 COW。