码农高天
Python通过在容器创建时直接注册到底层双向链表来捕获代码层无法访问的循环引用,并要求最老代新增对象占比超过25%才触发全量扫描以避免程序性能衰退。
AI-assisted summary of 【python】Unreachable的对象咋回收的?generation又是啥? — original source. Check the original for full context.
Source published:
Key ideas
- CPython 针对 unreachable 循环引用对象的回收,依赖于将所有容器对象在创建阶段即挂载至底层的 GC 专属双向链表中进行追踪。 (5:00)
- 分代垃圾回收(Generation 0、1、2)采用梯级触发:Gen 0 计数值为新分配容器净增数,而 Gen 1 与 Gen 2 的计数本质上是上一代 GC 执行的次数。 (7:10)
- 针对 Generation 2 中长生命周期对象堆积导致的扫描耗时膨胀,CPython 引入了 25% 新增未检测对象比例的触发前置限制。 (9:50)
Detailed notes
## Background 该视频由码农高天于 2024 年 8 月 17 日发布在 YouTube 平台。视频简介指出,本期内容主要介绍 Python 垃圾回收的基本机制,详细拆解哪些对象会被记录跟踪,以及分代回收(generation)的具体运作原理。
## TL;DR 高天从知识星球用户关于二叉树循环引用导致对象无法访问(unreachable)却仍能被回收的困惑切入,首先梳理了单纯引用计数在面对循环引用时的局限性。随后,他从 CPython 源码底层阐明了容器与非容器对象的差异:所有容器对象在创建并执行 PyObject_GC_Track 时都会被登记进全局双向链表,因此垃圾回收器能准确找到程序变量已无法触及的孤立环。最后,视频详解了 Generation 0、1、2 的分代回收假说、各代 count 与阈值的具体含义、链表合并逻辑,以及针对 Generation 2 长生命周期对象累积所引入的 25% 比例优化机制。
## Atmosphere & style 视频保持了码农高天一贯严谨而清晰的技术硬核风格,如同资深工程师在白板前层层剖析底层原理。讲解从实际代码问题自然过渡到 C 源码实现,条理分明且直击痛点。整体语调平实自信、循循善诱,既有抽丝剥茧解答疑惑的顿悟感,又充满对底层系统设计权衡的深入洞察。
## Bulletin - [05:00](https://www.youtube.com/watch?v=8cOJwmvcl90&t=300s) CPython 针对 unreachable 循环引用对象的回收,依赖于将所有容器对象在创建阶段即挂载至底层的 GC 专属双向链表中进行追踪。 - [07:10](https://www.youtube.com/watch?v=8cOJwmvcl90&t=430s) 分代垃圾回收(Generation 0、1、2)采用梯级触发:Gen 0 计数值为新分配容器净增数,而 Gen 1 与 Gen 2 的计数本质上是上一代 GC 执行的次数。 - [09:50](https://www.youtube.com/watch?v=8cOJwmvcl90&t=590s) 针对 Generation 2 中长生命周期对象堆积导致的扫描耗时膨胀,CPython 引入了 25% 新增未检测对象比例的触发前置限制。
## Key takeaways - [00:00](https://www.youtube.com/watch?v=8cOJwmvcl90&t=0s) 引用计数归零是 CPython 最基础且直接的内存回收机制,该过程由底层自动处理,不依赖复杂的循环垃圾回收器。 - [00:00](https://www.youtube.com/watch?v=8cOJwmvcl90&t=0s) 当对象之间形成循环引用(reference cycle)且外部变量被删除时,引用计数无法归零,导致内存泄漏并形成 unreachable 孤立环。 - [05:00](https://www.youtube.com/watch?v=8cOJwmvcl90&t=300s) CPython 区分容器(container)与非容器对象:只有能引用其他对象的容器类型(如 list、dict、自定义实例)才会被 GC 追踪并可能产生循环引用。 - [05:00](https://www.youtube.com/watch?v=8cOJwmvcl90&t=300s) 非容器对象(如 int、string)直接通过 PyObject_Malloc 分配内存,不附加垃圾回收结构体开销。 - [05:00](https://www.youtube.com/watch?v=8cOJwmvcl90&t=300s) 容器对象在创建时会调用 PyObject_GC_New 分配带 GC 头部的内存,并通过 PyObject_GC_Track 将其直接插入垃圾回收器的全局双向链表。 - [05:00](https://www.youtube.com/watch?v=8cOJwmvcl90&t=300s) 即使 Python 代码层面删除了所有变量引用,垃圾回收器依然可以通过底层的专属双向链表遍历并找到这些不可达对象。 - [07:10](https://www.youtube.com/watch?v=8cOJwmvcl90&t=430s) 弱分代假说表明大多数对象生命周期极短,分代回收机制旨在避免对已知存活很久的对象进行频繁且无意义的重复全量扫描。 - [07:10](https://www.youtube.com/watch?v=8cOJwmvcl90&t=430s) 所有新建的容器对象初始统一进入 Generation 0,触发检查时遵循从老到新(Gen 2 -> Gen 1 -> Gen 0)的优先级顺序。 - [07:10](https://www.youtube.com/watch?v=8cOJwmvcl90&t=430s) Generation 0 的 count 统计新增容器净增量(默认阈值 700),而 Generation 1 和 2 的 count 统计的是上一代 GC 的触发次数(默认阈值均为 10)。 - [07:10](https://www.youtube.com/watch?v=8cOJwmvcl90&t=430s) 每当高代际触发垃圾回收时,所有比其年轻的代际链表都会合并到该代统一检查,扫描后存活对象沉淀至当前代中。 - [09:50](https://www.youtube.com/watch?v=8cOJwmvcl90&t=590s) 若不对老生代(Generation 2)加以限制,随着长寿命对象积累,全量扫描会使单位时间内 GC 耗时占比失控,导致程序运行变慢。 - [09:50](https://www.youtube.com/watch?v=8cOJwmvcl90&t=590s) CPython 为 Generation 2 增加了优化规则:仅当该代中未检测过的新对象占比超过 25% 时,才会真正执行全量垃圾回收。
## Detailed walkthrough ### [00:00](https://www.youtube.com/watch?v=8cOJwmvcl90&t=0s) 循环引用与 Unreachable 对象的回收疑惑
高天首先分享了本期视频的背景:话题来源于他在知识星球上收到的一位朋友的提问。提问者给出了一个基于二叉树节点(`TreeNode`)的简化代码示例。`TreeNode` 是一个标准的二叉树节点,包含父节点(`parent`)以及左右子节点(`left`、`right`)。代码创建了两个节点 `node1` 和 `node2`,将 `node1.left` 指向 `node2`,同时把 `node2.parent` 指向 `node1`,随后使用 `del node1` 和 `del node2` 删除了这两个变量。在 Python 中,当这两个变量被删除后,程序在代码层面上就再也无法获取这两个节点了,这种无法被再次访问到的对象被称为“unreachable object”。
```mermaid flowchart LR accTitle: 循环引用对象结构 accDescr: 展示 node1 与 node2 互相引用并在变量被删除后形成孤立环的状态
subgraph Program["代码层面(已 del 变量)"] V1["变量 node1<br>(已删除)"] -.->|无法访问| N1 V2["变量 node2<br>(已删除)"] -.->|无法访问| N2 end
subgraph Heap["内存堆中的孤立环"] N1["TreeNode (node1)<br>引用计数: 1"] N2["TreeNode (node2)<br>引用计数: 1"] N1 -->|".left"| N2 N2 -->|".parent"| N1 end ```
此时如果手动执行 `gc.collect()`,垃圾回收器会返回并回收这两个对象。那位提问的朋友感到非常困惑:“明明这些 object 都已经 unreachable 了,你都拿不到它了,怎么垃圾回收机制还有能力回收它们呢?”提问者还提到官方文档中有一个叫做“generation”(代)的概念,也不理解究竟代表什么。
为了解答这两个疑惑,高天先讲解了 Python 最基础的内存管理机制——引用计数(reference counting)。在 Python 中,每当创建一个新对象,Python 都会在底层记录它有多少个引用。当运行完创建 `node1` 的语句时,对应的 `TreeNode` 对象上就有一个来自于变量 `node1` 的引用。在日常写 Python 时,开发者无需手动关心引用的增减,“因为引用的增加跟减少都是 CPython 内部帮你处理好了的”。当一个对象的引用计数归零时,就会被自动释放回收,这个过程完全不涉及后面复杂的循环垃圾回收机制,单纯是“一个简单的计数到零了,用不着了,free 掉”。
然而纯粹的引用计数存在致命的局限性: - 当创建 `node2` 并执行 `node2.parent = node1` 时,`node1` 的引用计数变为 2(变量 `node1` 和属性 `node2.parent`)。 - 若没有后续让 `node1.left = node2` 的代码,删除 `node2` 时其引用归零直接被回收;它被回收后,对 `node1` 的引用随之消失,`node1` 的计数也降为 0,两者都能自动回收,unreachable object 为 0。 - 一旦加上 `node1.left = node2`,事情就彻底变复杂了:`node1` 被 `node2.parent` 引用,`node2` 被 `node1.left` 引用,构成了“循环引用”(reference cycle)。 - 此时即使删除了变量名 `node1` 和 `node2`,两个对象在内存中依然互相引用,各自保留着 1 的引用计数,无法被引用计数机制释放,仍然白白占用内存。 - 循环引用在实际程序中可能非常庞大复杂,包含许多个对象。为此 Python 必须引入更复杂的垃圾回收机制(`gc.collect`),其核心工作就是识别并找出这些只有循环引用的孤立对象环并将其释放。
[05:00](https://www.youtube.com/watch?v=8cOJwmvcl90&t=300s) 容器与非容器:GC 是如何“找到”无法访问的对象的
为了回答“程序已经拿不到这个对象了,GC 怎么还能找得到它并进行回收”的问题,高天引入了“容器”(container)的概念。所谓容器,是指那些可以引用其他对象的对象,比如 `list`、`dict` 以及自定义的带有属性的实例对象。相反,像 `int`(在 CPython 内部为 `longobject`)或 `string` 这样的对象不能引用其他对象,因此不是容器,永远不会产生循环引用。
在 CPython 源代码层面,容器与非容器在分配内存和初始化时有着截然不同的路径: - 非容器对象(如整数)直接调用 `PyObject_Malloc` 来申请内存空间。 - 容器类型对象(如 `list`)在建立时则会调用 `PyObject_GC_New`。高天强调:“所有容器类型的 object 在建立的时候就要告诉它:你是一个容器,你要被 garbage collection 所意识到。” - `PyObject_GC_New` 随后调用 `_PyObject_GC_Alloc`,底层虽然同样使用 `PyObject_Malloc` 分配内存,但在内存头部带有垃圾回收专属信息,并通过 `_PyObject_GC_Link` 初始化其 GC 属性。 - 内存分配完成后,容器会执行 `PyObject_GC_Track`。这个“track”操作的本质,就是把该对象塞入垃圾回收器专门维护的全局双向链表中。
这就彻底解开了提问者的第一个疑惑:尽管在 Python 用户代码层面通过变量找不到该对象了,但在 C 语言底层,“每建立一个新的容器,我就把这个容器扔到了一个垃圾回收专门的链表里,这就是为什么尽管我们的 Python 程序已经找不到这个 object,垃圾回收还是能找得着,因为建立的时候就告诉了这个 garbage collection:这块有个 object,你注意点它。”每次触发垃圾回收时,GC 只需遍历这个链表中的容器,检测并清理那些只剩下内部循环引用的对象。
```mermaid flowchart TD accTitle: CPython 容器对象的内存分配与跟踪流程 accDescr: 展示容器对象通过 PyObject_GC_New 分配并在 track 时加入双向链表的过程
subgraph Allocation["内存分配阶段"] A["创建容器对象 (如 list)"] --> B["PyObject_GC_New"] B --> C["_PyObject_GC_Alloc"] C --> D["PyObject_Malloc 分配内存"] C --> E["_PyObject_GC_Link 初始化 GC 属性"] end
subgraph Tracking["GC 跟踪阶段"] E --> F["PyObject_GC_Track"] F --> G["加入 GC 双向链表<br>(Generation 0)"] end ```
[07:10](https://www.youtube.com/watch?v=8cOJwmvcl90&t=430s) 分代回收机制(Generation)与阈值合并逻辑
解答了追踪机制后,高天转入提问者的第二个问题:分代(generation)究竟是什么?为什么需要分代?
分代回收基于一个普遍的经验规律(弱分代假说):在实际编程中,绝大多数对象的生命周期都极短,往往用完立刻就能释放;只有少部分对象(如全局配置、长生命周期缓存)会存活很久。如果将所有容器都挂在同一个链表里进行全量扫描,检测循环引用的代价会极其高昂,高天指出:“随着程序的进展,这些生命周期很长的对象可能越来越多,就导致我们每一次垃圾回收都很慢,因为对一些我们已经检查了好几次、知道它已经存活了挺久的 object,我们还在不停地检查它。”而若单纯为了省时而降低垃圾回收频率,那些海量的短命对象又无法被及时释放。
为了平衡回收效率与开销,CPython 引入了 Generation 0、1、2 三个代际机制: - **初始分配**:所有新建的容器对象都会先被扔进 Generation 0。 - **触发顺序**:当需要检查时,程序会从老到新、从高到低检查是否达到阈值,即先检查 Generation 2,接着检查 1,最后检查 0。 - **计数值(count)的差异**:Generation 0 的 count 表示“新创建的容器对象数量”,新容器数量超过阈值就会触发 Generation 0 的 GC;但 Generation 1 和 2 的 count 并不代表该代内的对象数量,而是“代表上一个 generation 运行了多少次”,“也就是运行一次 generation 0 的垃圾回收,generation 1 的 count 只加 1”。 - **链表合并**:每次对某一代理论上做垃圾回收时,所有比当前代更年轻的代(younger generations)里面的对象都会被合并到当前代中统一进行检查。这意味着在完成一次 Generation 2 的检查后,Generation 0 和 1 的链表直接被清空,所有存活对象全部沉淀在 Generation 2 中。
| 代(Generation) | 默认阈值(Threshold) | count 的具体含义 | |:--- |:--- |:--- | | **Generation 0** | 700 | 自上次 GC 以来新分配的容器对象净增数量 | | **Generation 1** | 10 | Generation 0 垃圾回收触发并执行的次数 | | **Generation 2** | 10 | Generation 1 垃圾回收触发并执行的次数 |
- 在当前 CPython 版本中,Generation 0、1、2 的默认触发阈值分别是 700、10、10。 - 换算下来:每建立 700 个新容器触发一次 Gen 0 回收;每触发 10 次 Gen 0 回收触发一次 Gen 1 回收;每触发 10 次 Gen 1 回收(即经历了 100 次 Gen 0 回收后)才会触发一次 Gen 2 回收。
[09:50](https://www.youtube.com/watch?v=8cOJwmvcl90&t=590s) Final Generation 的长生命周期瓶颈与 25% 优化
在视频最后,高天深入分析了这套经典分代机制潜在的性能瓶颈。在创建容器对象速率恒定的情况下,每一代垃圾回收的触发频率基本是固定的。然而,这种机制在“最老的代”——Generation 2(Final Generation)中遇到了麻烦。
随着程序长时间运行,存活下来的长生命周期对象可能会不断累积,导致 Generation 2 中的对象数量越来越庞大。如果依旧严格按照固定频率对 Generation 2 进行全量扫描,由于长生命周期对象大概率不会被回收,“我们在每一次对 Generation 2 进行垃圾回收的时候,也是在不停地检查我们之前已经检查过的对象”,其直接后果就是“单位时间内做垃圾回收的时间占比越来越大,这样的结果就是我们程序越跑越慢,不是很理想”。
为了解决 Final Generation 的这一性能陷阱,CPython 引入了一项额外的优化策略: - 针对 Generation 2,即使达到触发计数的阈值,也不会盲目启动全量扫描。 - 垃圾回收器会评估新增未检测对象的比例,只有当 Generation 2 中尚未被检测过的对象数量占总对象数量的 25% 以上时,才会真正开启该代的全量垃圾回收。这一机制有效避免了在庞大且稳定的长寿对象集合上进行无意义的频繁扫描。
最后,高天总结了本期内容,表示希望这些原理解析能彻底解答那位星球同学的疑惑,并欢迎大家继续提问交流,提醒观众关注与一键三连。
## Notable quotes > [05:00](https://www.youtube.com/watch?v=8cOJwmvcl90&t=300s) 每建立一个新的容器,我就把这个容器扔到了一个垃圾回收专门的链表里,这就是为什么尽管我们的 Python 程序已经找不到这个 object,垃圾回收还是能找得着 — 高天
## Mentioned 高天, Python, CPython, 知识星球, TreeNode, gc.collect, PyObject_Malloc, PyObject_GC_New, _PyObject_GC_Alloc, _PyObject_GC_Link, PyObject_GC_Track
## Video description 这期视频我们介绍一下垃圾回收的基本机制,什么对象会被记录跟踪,以及generation是怎么回事。
[Source](https://www.youtube.com/watch?v=8cOJwmvcl90)