前言
Unity 历史上长期使用的默认垃圾回收器是 Boehm-Demers-Weiser Garbage Collector (Boehm GC)。它是一个 保守的(Conservative)、非移动式(Non-Moving) 的 标记-清除(Mark-Sweep) 垃圾回收器。
对惹,这里有一个游戏开发交流小组,希望大家可以点击进来一起交流一下开发经验呀!
- 保守式 (Conservative):
- 它无法精确知道内存中的每一个比特是指针还是其他数据(如整数、浮点数)。
- 当扫描内存(栈、全局变量、寄存器、堆块)时,它会把任何看起来像一个有效堆内地址的值都当作潜在的指针。
- 如果一个潜在的指针指向一个有效的、已分配的堆块,那么这个堆块就被认为是可达的(Reachable),即不是垃圾。
- 优点: 易于集成到现有系统(如 Unity 的 C++ 引擎与 C# 脚本交互),不需要严格的类型信息。
- 缺点: 可能误判非指针数据为指针,导致少量内存无法回收(内存泄漏);无法进行内存压缩。
- 非移动式 (Non-Moving):
- 回收过程中,不会移动存活对象在内存中的位置。
- 优点: 实现相对简单,不会破坏指向对象的原始指针(这在保守式系统中很重要)。
- 缺点: 会产生内存碎片(Fragmentation),长时间运行后可能导致分配大对象失败,即使总空闲内存足够。
- 标记-清除 (Mark-Sweep):
- 标记阶段 (Mark Phase): 从“根”(Roots - 如静态变量、当前执行栈上的局部变量、寄存器等)出发,递归地遍历所有通过指针可达的对象,将它们标记为“存活”。
- 清除阶段 (Sweep Phase): 线性遍历整个堆。所有在标记阶段未被标记的对象都被认为是垃圾。这些垃圾对象所占用的内存块被添加到一个“空闲列表(Free List)”中,供后续分配重用。
- 优点: 概念简单,直接回收不可达对象。
- 缺点: 经典的 Mark-Sweep 会在标记和清除阶段暂停整个用户程序(Stop-The-World, STW),这对于需要高帧率(如 60FPS,每帧只有 16.6ms)的实时应用(如游戏)来说可能是灾难性的,会造成明显的卡顿(GC Spike)。
增量式垃圾回收 (Incremental Garbage Collection) 的必要性
STW 暂停是游戏流畅性的主要敌人。增量式 GC 的核心目标就是将一个完整的、长时间的垃圾回收过程分解成多个小的、时间可控的步骤(增量),穿插在用户程序的正常执行(Mutator)之间。这样,每次只暂停程序很短的时间(通常目标是控制在几毫秒以内),大大降低了单次暂停对帧率的影响,使游戏感觉更流畅。
Boehm GC 的增量模式 (Incremental Mode) 原理
Boehm GC 实现增量回收主要是对标记阶段进行增量式改造。清除阶段通常仍然是一次性完成的(但相对较快,且可以进一步优化),或者在后台进行。其核心机制是写屏障(Write Barrier)。
- 增量标记 (Incremental Marking):
- 传统的标记是递归地、一口气从根标记完所有可达对象。
- 增量标记则将这个过程划分为多个小步骤:
- 步骤1 (初始标记 - Initial Marking, STW): 短暂暂停程序,快速扫描根(Roots) 直接指向的对象,将它们标记为存活,并放入一个特殊的队列(通常称为灰色对象集合 - Grey Set)。这个暂停通常很短。
- 步骤2 (并发/增量标记 - Concurrent/Incremental Marking): 恢复用户程序运行。在用户程序执行的间隙,GC 线程(或主线程分时)开始处理灰色对象集合。
- 从灰色集合中取出一个对象(将其标记为黑色 - 表示已完全处理)。
- 扫描这个对象内部的所有字段(潜在的指针)。
- 对于每个扫描到的、指向堆内其他对象(白色对象 - 表示尚未处理/未被发现)的指针:
- 将该目标对象标记为灰色(表示已发现但未处理其内部指针)。
- 关键点: 将这个新标记的灰色对象加入到灰色集合中。
- 重复这个过程,每次增量步骤只处理灰色集合中的一小部分对象(比如 N 个),然后让出 CPU 给用户程序运行一段时间。
- 步骤3 (最终标记/重标记 - Final Marking/Remark, STW): 当灰色集合几乎为空时(表示大部分可达图已扫描完),需要再进行一次短暂的 STW 暂停。这次暂停的目的是:
- 处理在增量标记阶段可能新加入的根(如新创建的局部变量)。
- 更重要的是:处理在增量标记期间用户程序修改对象引用可能导致的“漏标”问题(见下文写屏障部分)。 这个暂停比初始标记稍长,但仍比完整 STW 短得多。
- 写屏障 (Write Barrier) - 增量模式的核心保障:
- 问题: 在增量标记阶段(步骤2),用户程序(Mutator)是并发运行的。用户程序可能会修改对象的指针字段。这可能导致两种破坏标记正确性的情况:
- 漏标新引用 (Storing a Pointer to a White Object): 用户程序将一个指向白色(未标记)对象 Y 的指针,写入到一个黑色(已标记完成)对象 X 的字段中。因为 X 已经是黑色,GC 在后续的增量标记中不会再扫描 X 的字段,所以 Y 及其可达的子图可能会被错误地当作垃圾回收掉。这是严重错误!
- 悬挂引用 (破坏旧引用): 用户程序清除了一个指向存活对象的唯一引用(比如设置为 null 或指向其他对象),或者移动了一个指针。这在保守式 GC 中处理起来更复杂,但保守式的特性(可能误判)反而降低了这种错误的危害性(它可能不会立即回收,但最终会被回收)。主要问题是漏标。
- 解决方案:写屏障 (Write Barrier):
- 这是一个由 GC 注入到用户程序写操作(对象字段赋值、数组元素赋值)之前或之后的一小段额外代码。
- 在 Boehm GC 增量模式下,写屏障的主要职责是解决漏标新引用问题。它实现的策略通常是 Dijkstra 写屏障 或其变种:
- 每当用户程序执行一个写操作
*field = new_pointer
时,写屏障代码被触发。 - 检查条件:如果
new_pointer
指向一个白色(未标记)对象,并且field
所属的对象obj
是黑色(已标记完成)对象。 - 关键动作: 如果条件满足,则立即将这个新指向的白色对象(
new_pointer
的目标)标记为灰色,并将其加入到灰色集合中。 - 效果: 确保了即使新引用被写入了黑色对象,新引用的目标对象也会被 GC 发现并标记,避免了漏标。用户程序在修改引用时付出了额外检查的代价(性能开销)。
- 每当用户程序执行一个写操作
- 清除阶段 (Sweep Phase):
- 一旦最终标记(步骤3)完成,GC 就知道了所有存活对象(黑色)和垃圾对象(白色)。
- 清除阶段可以:
- 一次性 STW 清除: 短暂暂停,遍历堆,将白色对象的内存回收回空闲列表。由于标记已完成,这个阶段通常很快。
- 增量/并发清除: Boehm GC 也支持将清除阶段拆分成增量步骤,与用户程序交替执行。在清除过程中分配新对象需要小心处理(例如,避免分配到即将被回收的块)。Unity 的 Boehm GC 实现通常倾向于较快的 STW 清除或高效的后台清除。
- 分配 (Allocation) 与增量 GC 的交互:
- 在增量标记期间分配的新对象,通常会被直接标记为黑色(如果标记阶段还未结束)或者根据阶段标记为灰色/黑色。
- 分配器会从空闲列表中获取内存。如果空闲内存不足,可能会触发一次完整的 GC 回收(包括增量标记完成和清除)。
增量模式在 Unity 中的特点与权衡
- 优点:
- 显著减少卡顿 (Reduced GC Spikes): 主要目标达成。将长时间的 STW 暂停分解成多个微小的暂停(通常在 1-5ms 级别),使得游戏帧率更加平滑,用户体验更好。
- 保留 Boehm 优势: 仍然是保守式、非移动式,易于与 Unity 的 C++ 引擎集成。
- 缺点/代价:
- 写屏障开销 (Write Barrier Overhead): 这是增量 GC 最主要的性能代价。每次对象字段的指针写入操作,都需要执行额外的检查(判断颜色)。在大量修改对象引用的代码中(如密集的 List 操作、复杂数据结构更新),这会带来明显的 CPU 开销,可能降低整体帧率(FPS)。
- 总回收时间可能更长 (Longer Total Collection Time): 因为标记被分散执行,并且有写屏障的协调开销,完成一次完整的垃圾回收周期所消耗的总 CPU 时间通常比非增量(STW)模式要长。
- 内存占用可能略高 (Slightly Higher Memory Footprint): 增量标记需要维护额外的状态(对象颜色标记位、灰色集合),并且在回收完成前,一些“即将死亡”的垃圾对象会存活更久。
- 复杂性 (Complexity): 实现增量 GC(尤其是正确的写屏障)比简单的 STW GC 复杂得多。
- 保守式的固有限制: 内存碎片问题依然存在;潜在的误判导致少量内存泄漏。
Unity 中的使用与演进
- 启用: 在 Unity 编辑器设置或项目脚本运行时设置中,可以启用 Boehm GC 的增量模式 (
Incremental GC
选项)。 - 调优: Unity 提供了参数(如
GC.incrementalTimeSliceNanoseconds
)让开发者控制每次增量步骤允许的最大时间(微秒级),以平衡卡顿和总开销。 - 演进:
- 虽然 Boehm GC(带增量)曾是 Unity 的主力,但它固有的缺点(保守式、非移动式、碎片化、写屏障开销)在大型复杂项目中日益凸显。
- Unity 已经转向 .NET CoreCLR / Unity Burst GC: 现代 Unity 版本(尤其面向较新 .NET 版本)强烈推荐并逐渐将 CoreCLR 的 GC 作为默认选项。CoreCLR GC 是一个精确式(Precise)、分代式(Generational)、压缩式(Compacting) 的 GC。
- 精确式: 准确知道哪些是指针,回收更彻底,无保守式泄漏。
- 分代式: 基于对象生存时间假设(年轻对象死得快),专注于回收高频产生的年轻代垃圾,大幅减少需要扫描的范围。
- 压缩式: 移动存活对象消除碎片,提高内存利用率和大对象分配成功率。
- 它也支持后台并发 GC,能在不暂停用户线程(或极短暂停)的情况下完成大部分回收工作,性能特性通常优于 Boehm 增量模式,尤其在高分配率场景下。Burst 编译器进一步优化了托管-本地互操作。
- Boehm 的现状: 主要用于一些遗留项目、特定目标平台(如某些 WebGL 老后端)或需要保守式特性的特殊场景。对于新项目,强烈建议使用 CoreCLR/Burst GC。
总结
Unity 的 Boehm GC 增量模式通过将标记阶段分解为增量步骤并利用写屏障(主要是 Dijkstra 屏障)来保障正确性,有效分散了传统 STW 回收造成的卡顿,提升了游戏流畅度。其核心是增量标记 + 写屏障。然而,它带来了写屏障的运行时开销,并且无法解决 Boehm GC 固有的内存碎片和保守式潜在泄漏问题。随着 Unity 向更先进的 CoreCLR/Burst GC(精确、分代、压缩、并发)迁移,Boehm GC 增量模式正逐渐成为历史选择。理解其原理对于优化旧项目或深入理解 GC 工作机制仍有价值。对于新开发,拥抱 CoreCLR GC 是更优解。
更多教学视频