前言
好的,我们来详细解析一下 Unity3D 早期版本中使用的 Boehm-Demers-Weiser 垃圾收集器(通常简称为 Boehm GC) 的原理。理解它对理解 Unity 的内存管理历史、某些遗留项目的行为以及"保守式"GC 的特点非常有帮助。
核心概念:Boehm GC 是一个"保守式、非移动、标记-清除"垃圾收集器。
让我们拆解这些术语并解释其在 Unity 上下文中的含义:
对惹,这里有一 个游戏开发交流小组 ,希望大家可以点击进来一起交流一下开发经验呀!
- 保守式 (Conservative):
-
这是 Boehm GC 最核心、最关键的特点,也是它被 Unity 早期选中的重要原因。
-
原理: GC 在判断一个内存中的值是否是指向堆上对象的指针时,采取"保守"策略。它不依赖于精确的类型信息(如 .NET 运行时提供的元数据)来确定某个内存位置存放的到底是指针还是普通数据(如整数、浮点数)。
-
工作方式:
-
-
GC 会扫描所有可能的"根"区域:线程栈(每个线程的调用栈)、CPU 寄存器、全局/静态变量区域等。
-
对于这些区域中的每一个字(word, 通常是 4 或 8 字节),GC 会检查这个字的值:
-
-
它是否落在 GC 管理的堆地址范围内?
-
它是否指向某个已知分配对象的起始地址(通常是对象头)?
-
-
-
-
- 如果满足以上条件,GC 就保守地认为这个字是一个"指针",并将它指向的对象标记为"可达"。
-
-
- 对 Unity 的意义: Unity 引擎核心是用 C++ 编写的,而用户脚本是用 C# 编写的。两者通过复杂的互操作(P/Invoke, Marshaling)交互。C++ 代码中会有指向托管 C# 对象的不透明指针(
IntPtr
),C# 代码也会通过 P/Invoke 调用 C++ 函数并传递数据。Boehm GC 的保守性使它不需要精确知道 C++ 这边哪些变量是托管对象的指针,它只要看到一个值像指针(在堆地址范围内且对齐),就认为它是。这极大地简化了 Unity 引擎与 Mono/.NET 脚本运行时之间的集成,避免了对 C++ 引擎进行大规模改造以提供精确指针信息。
- 对 Unity 的意义: Unity 引擎核心是用 C++ 编写的,而用户脚本是用 C# 编写的。两者通过复杂的互操作(P/Invoke, Marshaling)交互。C++ 代码中会有指向托管 C# 对象的不透明指针(
- 非移动 (Non-Moving / Non-Compacting):
-
原理: 在垃圾回收过程中(特别是清除阶段之后),Boehm GC 不会移动存活的对象来压缩堆空间。回收的内存块("洞")就留在原地。
-
优点: 实现相对简单。避免了移动对象带来的复杂性和开销(需要更新所有指向该对象的指针)。
-
缺点: 主要问题是内存碎片化。频繁的分配和回收会导致堆中出现大量大小不一、分散的空闲内存块。虽然 GC 有算法(如空闲列表)来尝试分配合适大小的对象到这些空闲块中,但最终可能导致:
-
-
即使总空闲内存足够,也可能因为找不到足够大的连续空闲块而导致分配失败(触发 GC 或抛出
OutOfMemoryException
)。 -
降低缓存局部性,可能影响性能。
-
-
- 对 Unity 的意义: 这是 Unity 早期版本中内存问题(尤其是长时间运行的游戏出现内存缓慢增长或卡顿)的一个常见原因。开发者需要更积极地管理大对象池或注意分配模式来缓解碎片化。
- 标记-清除 (Mark-Sweep):
-
这是 Boehm GC 使用的基本回收算法,分为两个主要阶段:
-
-
标记阶段 (Mark):
-
-
从所有"根"(栈、寄存器、全局变量)开始遍历。
-
使用上面描述的保守式方法找出所有根直接引用的对象,将它们标记为"可达"(通常是在对象头中设置一个标志位)。
-
递归地遍历这些可达对象所引用的所有其他对象(同样保守地识别指针),也将它们标记为"可达"。这个过程持续到所有从根出发可达的对象都被标记。
-
-
-
-
-
清除阶段 (Sweep):
-
-
GC 线性遍历整个堆。
-
对于堆中的每一个对象:
-
-
如果被标记为"可达",则清除标记位(为下次 GC 准备),该对象存活。
-
如果没有被标记,则认为它是垃圾,将其占用的内存块回收,并加入到空闲内存列表中以供后续分配使用。
-
-
-
-
-
-
-
- 注意: 清除阶段只回收内存,不移动存活对象(非移动性)。
-
-
Boehm GC 在 Unity 中的工作流程概要:
- 分配: 当脚本代码(C#)使用
new
创建对象时,内存分配请求会发给 Boehm GC 管理的内存池。GC 尝试在堆的空闲块中找到合适大小的空间。如果找不到,则触发一次垃圾回收。 - 触发回收: 回收通常在:
-
分配请求无法满足时(如上所述)。
-
开发者显式调用
System.GC.Collect()
(通常不建议)。 -
GC 根据自身启发式策略(如分配总量、时间阈值等)决定触发。
- 暂停程序 (Stop-The-World): 传统的 Boehm GC 在执行完整的标记-清除回收时,需要暂停所有托管线程。这是为了避免在回收过程中程序修改对象图(创建新引用、移除旧引用、分配新对象),导致标记错误。这是 GC 引起卡顿的主要原因之一。
- 注意:Unity 后期对 Boehm GC 进行了定制和改进,引入了增量式垃圾回收(Incremental GC),试图将标记工作分散到多帧进行,显著减少了单次停顿时间,但核心原理不变。
- 保守式标记: 暂停后,GC 扫描所有根区域(所有线程栈、寄存器、全局变量),保守地识别可能的指针,并递归标记所有可达对象。
- 清除: 遍历堆,回收所有未被标记的对象占用的内存(加入空闲列表),清除存活对象的标记位。
- 恢复程序: 恢复所有托管线程执行。
Boehm GC 的优缺点总结 (在 Unity 上下文中):
-
优点:
-
-
实现相对简单: 核心算法比精确式、分代式、移动式 GC 简单。
-
语言/环境中立: 不需要语言运行时提供精确的类型和指针信息。这是它能无缝集成 Unity C++ 引擎和 C# 脚本的关键。
-
C/C++ 友好: 对通过 P/Invoke 暴露给 C# 的 C/C++ 代码中的指针处理比较宽容。
-
对"野指针"有一定容忍度: 如果野指针碰巧指向一个存活对象,该对象不会被错误回收(但可能掩盖真正的 bug)。
-
-
缺点:
-
-
内存碎片化: 非移动性导致的主要问题,影响长期运行的应用程序性能和稳定性。
-
保守性导致内存泄漏: 这是最大的理论缺点。如果一个整数值、浮点数或字符串数据恰好等于一个已死亡对象的地址,GC 会误认为它是有效指针,从而错误地将那个死亡对象标记为"可达",阻止其回收。虽然实践中发生的概率不是极高,且可以通过避免在指针位置存储非指针数据来缓解,但理论上存在,且无法完全避免。这被称为"保守式收集器的内存泄漏"。
-
潜在的性能开销: 扫描整个栈和全局区域可能比精确式 GC 扫描已知的指针位置开销大。标记过程需要遍历所有存活对象。
-
Stop-The-World 停顿: 完整 GC 会导致程序暂停,影响响应性(增量 GC 缓解了此问题)。
-
Unity 的演进:
Unity 早期(大致 Unity 5.x 及更早)主要使用 Mono 运行时配套的 Boehm GC。随着对性能、内存占用和卡顿优化的要求不断提高:
- 增量式 Boehm GC: Unity 对 Boehm GC 进行了深度定制,实现了增量式垃圾回收。它将耗时的标记阶段分成很多小步骤,穿插在游戏逻辑帧之间执行,大大减少了单次停顿时间(从几十毫秒降到几毫秒甚至更低),显著改善了游戏流畅度。但碎片化和保守性泄漏问题依然存在。
- 替换为 Unity 新的 GC: 为了解决 Boehm GC 的根本性缺点(主要是碎片化和保守性),Unity 投入巨资开发了全新的垃圾收集器(通常称为 Unity GC 或 Boehm 的替代者)。这个新 GC:
- 是精确式的:需要并利用了 .NET/Mono 运行时提供的精确类型信息,能准确识别指针,避免了保守性泄漏。
- 是分代式的:将对象按生命周期长短分为不同代(通常是 0代,1代,2代),优先收集最可能死亡的年轻代对象(0代),大幅提高收集效率,减少每次 GC 需要扫描的对象数量。
- 是压缩式 的(至少对部分代):在回收后会移动存活对象,压缩堆空间,彻底解决碎片化问题。这需要精确的指针信息来更新所有引用。
- 通常是并发/增量式的:进一步减少 STW 停顿。
- 这个新 GC 从 Unity 2017.x 左右开始逐步替换 Boehm GC,并在后续版本中成为默认和推荐的 GC。
总结:
Unity3D 早期使用的 Boehm GC 是一个保守式、非移动、标记-清除 垃圾收集器。它的核心价值在于其保守性 ,这使得它能够相对简单地集成 Unity 的 C++ 引擎核心和 C# 脚本运行时,无需精确追踪 C++ 中的托管对象指针。然而,这也带来了保守性内存泄漏 的风险,并且其非移动性 导致了严重的内存碎片化 问题。后期通过引入增量式回收 显著改善了卡顿问题。最终,Unity 开发了全新的精确式、分代式、压缩式垃圾收集器来克服 Boehm GC 的根本缺陷,提供了更好的性能、更低的内存占用和更少的内存碎片。理解 Boehm GC 的原理对于理解 Unity 内存管理的历史、遗留项目的优化以及不同 GC 策略的权衡至关重要。
更多教学视频