内容提要
CPython计划新增分代增量循环垃圾回收器,交替进行年轻代与老年代增量收集,仅扫描更可能产生垃圾的较老对象,以减少GC暂停时间和总耗时。旧分代回收器保留为选项。新方案将堆分为三代及多个空间,默认启用增量模式,可降低峰值内存与暂停时间。
延伸解读
新回收器如何减少暂停时间
新方案将堆分为三代,并进一步拆分为多个空间。年轻代收集与老年代增量收集交替进行,每次只扫描堆的一部分,而非整个堆。老年代增量收集从最久未扫描的对象开始,形成传递闭包并回收其中的不可达循环。这种增量方式避免了单次全堆扫描导致的长暂停,尤其对大型堆效果显著,可将最大暂停时间降低多达百倍。
对内存占用的实际影响
新回收器通过频繁的小规模收集,防止老年代无限增长,从而可能降低峰值内存。但年轻代总大小变为 threshold0 * (threshold1 + 2) kB,比旧版更大,因此小型短命应用的内存占用可能增加,默认上限为24MB,实际增幅通常远小于此。大型长命应用的峰值内存可能略有下降,但差异不大。总体而言,峰值内存与旧版大致相当,具体取决于工作负载。
兼容性与配置变化
旧的分代回收器保留为选项,可通过启动参数 -X gc=legacy 启用,默认使用增量回收器。gc.set_threshold() 仍可配置,但 threshold2 被忽略,threshold0 和 threshold1 的含义有细微变化。gc.collect() 无参数调用仍受支持,但带参数的调用已弃用,因为会干扰回收器的平滑运行。旧回收器的触发条件也从净分配对象数改为自上次收集以来的总分配内存,多数应用无显著变化。
当前实现的局限与未来方向
参考实现比主分支快1-2%,但在大型对象图上仍可能出现长暂停。许多基准测试使用单个大树作为对象图,从树根开始的增量会包含几乎整个堆,导致暂停时间较长。未来可能通过调整增量策略或移植到自由线程构建来改进。移植到自由线程构建需要一些修改,但具体细节未在文中展开。
Q&A
PEP 848 提出的新垃圾回收器是什么?
PEP 848 提出为 CPython 添加一个新的循环垃圾回收器,它既是分代的又是增量的。回收会交替进行:一次年轻代回收,然后一次(增量)老年代回收,如此反复。每次回收只处理堆的一部分,针对垃圾更可能集中的区域。目的是减少 GC 暂停时间和总体 GC 耗时。现有的分代回收器将保留为选项。
为什么需要分代增量垃圾回收?
当前分代回收器存在效率低下和暂停时间长的问题:它会对每个存活对象扫描两次才移入老年代,且将年轻和年老对象混在一起扫描,导致许多扫描是无效的。此外,它一次性扫描整个堆,对于大堆会造成很长的暂停时间。最老代很少被回收,导致大量循环垃圾积累,暂停时间可能非常长。新方案通过只扫描更老的对象(更可能已死亡)和增量扫描来减少暂停时间和总耗时。
新的增量回收器是如何工作的?
新回收器在年轻代和老年代回收之间交替。当托儿所半满时,会以增量方式回收老年代中不可达的循环,每次增量从最久未扫描的对象开始形成传递闭包,直到扫描足够对象以跟上年轻代回收添加到老年代的速度。当托儿所满时,回收最老的 aging 空间中的不可达循环。老年代被分为 pending 和 visited 两个空间,通过交换空间和扫描可达对象来推进回收。
新回收器如何划分堆?
堆被分为三代:托儿所(第0代)、aging 代(第1代)和老年代(第2代)。托儿所是单个空间。aging 代由可配置数量的空间组成(原型中默认为5个),用于增量回收器;传统回收器则使用单个空间。老年代在增量回收器中分为 pending 和 visited 两个空间,传统回收器中为单个空间。老年代空间没有大小限制。
如何选择使用增量还是传统垃圾回收器?
可以在启动时通过 -X gc 选项选择 GC 算法。选项有“incremental”表示增量 GC,“legacy”表示传统分代 GC。默认是“incremental”。
新回收器对内存使用有什么影响?
总体峰值内存消耗应该大致相同,但取决于工作负载。对于小型短命应用,由于年轻代更大,内存消耗可能增加,但额外空间默认限制在 24MB 以内。对于大型长命应用,峰值内存使用可能减少,因为增量回收器防止老年代增长到当前分代回收器那么大,但差异可能很小。
gc.collect() 的行为有什么变化?
通常 GC 由虚拟机定期运行,但可以通过调用 gc.collect() 显式触发。带显式参数的 gc.collect() 已被弃用,因为它会干扰垃圾回收器的平稳运行。不带参数的 gc.collect() 继续支持,等价于 gc.collect(2)。
新回收器的性能提升来自哪里?
性能提升来自在年轻代做更少的工作(每个对象只回收一次,而不是两次),以及在老年代做更少的工作,因为年轻代的存活率更低。参考实现比当前主分支(使用分代 GC)快 1-2%。