英文原文:https://hackernoon.com/memory-mastery-comparing-unity-and-net-garbage-collection
大家好,我叫 Dmitrii Ivashchenko,是 MY.GAMES 的软件工程师。在本文中,我们将讨论 Unity 和 .NET 中垃圾收集之间的差异。
C# 编程语言的主要优点之一是自动内存管理。它消除了开发人员手动释放未使用对象的内存的需要,并显着缩短了开发时间。然而,这也可能导致大量与内存泄漏和意外应用程序崩溃相关的问题。
除了避免 C# 中垃圾收集问题的一般建议之外,强调 Unity 中内存管理实现的细节也很重要,这会导致额外的限制。
首先,我们来看看内存管理在 .NET 中是如何工作的。
.NET 中的内存管理
Common Language Runtime 公共语言运行时 (CLR) 是 .NET 中托管代码的运行时环境。用 C#、F#、Visual Basic 等语言编写的任何高级 .NET 代码都会编译为中间语言(IL 代码),然后在 CLR 中执行。除了执行 IL 代码之外,CLR 还提供其他一些必要的服务,例如类型安全、安全边界和自动内存管理。
Managed heap 托管堆
当一个新的 CLR 进程初始化时,它会为该进程保留一个连续的地址空间区域 - 该空间称为托管堆。托管堆存储一个指向堆中将分配下一个对象的地址的指针。最初,该指针被设置为托管堆的基地址。所有引用类型(通常也是值类型)都放置在托管堆中。
当应用程序创建第一个引用类型时,会在托管堆的基地址处为该类型分配内存。创建下一个对象时,垃圾收集器会在紧随第一个对象之后的地址空间中为其分配内存。只要地址空间可用,垃圾收集器就会继续以这种方式为新对象分配空间。
从托管堆分配内存比分配非托管内存更快。例如,如果您想在 C++ 中分配内存,则需要对操作系统进行系统调用来执行此操作。使用 CLR 时,当应用程序启动时,内存已经从操作系统中保留。
由于运行时通过向指针添加值来为对象分配内存,因此它几乎与从栈分配内存一样快。此外,由于按顺序分配的新对象也按顺序存储在托管堆中,因此应用程序可以非常快速地访问对象。
CLR GC 算法
CLR 中的垃圾收集 (GC) 算法基于以下几个考虑因素:
- 新对象的寿命较短,而旧对象的寿命较长。
- 压缩一小部分托管堆的内存比一次性压缩整个托管堆的内存要快。
- 新对象通常彼此相关,并且大约在同一时间可供应用程序使用。
基于这些经过时间检验的假设,CLR 垃圾收集器算法的构建如下。对象分为三代:
- 第 0 代:所有新对象都进入这一代。
- 第 1 代:来自第 0 代且在一次垃圾回收中幸存下来的对象将移至这一代。
- 第 2 代:来自第 1 代且在第二次垃圾回收中幸存下来的对象将移至这一代。
每一代在内存中都有自己的地址空间,并且独立于其他一代进行处理。当应用程序启动时,垃圾收集器将所有创建的对象放置在第 0 代空间中。一旦不再有足够的空间容纳另一个对象,就会触发第 0 代的垃圾回收。
那么,我们如何确定不再使用的对象呢?为此,垃圾收集器使用应用程序的根列表。该列表通常包括:
- 类的静态字段。
- 局部变量和方法参数。
- 对存储在线程栈中的对象的引用。
- 对存储在处理器寄存器中的对象的引用。
- 等待最终确定的对象。
- 与事件处理程序相关联的对象也可以包括在根列表中。
垃圾收集器分析根列表并从根对象开始构建可访问对象的图。无法从根对象到达的对象被认为是死的,垃圾收集器会释放这些对象占用的内存。
处理完第 0 代中的任何死对象后,垃圾收集器会将剩余的活动对象移动到第 1 代的地址空间。一旦成为第 1 代的一部分,垃圾收集器就很少将对象视为要删除的候选者。
随着时间的推移,如果清理第 0 代不能为创建新对象提供足够的空间,则会执行第 1 代的垃圾收集。再次构建图,再次删除失效对象,并将第 1 代中幸存的对象移至第 2 代。
如果不断发生内存泄漏,垃圾收集器将消耗所有三代的可用地址空间,并决定向操作系统请求额外的空间。如果内存泄漏持续存在,操作系统将继续向进程分配额外的内存,直至达到一定限制,但当其可用内存耗尽时,它将被迫停止该进程。
为了便于理解,本文有意省略了一些细节,但这仍然让我们看到了.NET中内存管理组织的复杂性和周到性。现在,了解了这些知识后,我们来看看 Unity 中是如何组织的。
Unity 中的内存管理
由于 Unity 游戏引擎是用 C++ 编写的,它在运行时显然会使用一些用户无法访问的内存(称为本地内存)。同样重要的是要强调一种特殊类型的内存,它被称为 C# 非托管内存,在使用 NativeArray<T> 或 NativeList<T> 等 Unity 集合结构时会用到。所有其他使用的内存空间都是托管内存,使用垃圾回收器分配和释放内存。
由于缺少 CLR,Unity 应用程序中的内存管理由脚本运行时(Mono 或 IL2CPP)处理。但是,应该注意的是,这些环境的内存管理效率不如 .NET。最令人沮丧的后果之一就是碎片化。
内存碎片
Unity中的内存碎片是将可用内存空间划分为分散的块的过程。当应用程序不断分配和释放内存时,就会发生碎片,导致内存中的可用空间划分为许多不同大小的连续块。内存碎片可以有两种类型:
- 外部碎片:当内存中的可用空间被分成分布在整个内存中的多个不同大小的非连续块时,就会发生这种情况。因此,可能有足够的总体可用内存来容纳新对象,但可能没有合适的连续可用空间块来容纳它。
- 内部碎片:当分配的内存块的空间多于存储对象所需的空间时,就会发生这种情况。这会在分配的块内留下未使用的内存空间,这也会导致资源利用效率低下。
由于使用不支持内存压缩的保守垃圾收集器,Unity 中的内存碎片尤其重要。如果不进行压缩,释放的内存块仍然分散,这可能会导致性能问题,尤其是从长远来看。
Boehm-Demers-Weiser 垃圾收集器
Unity 使用保守的 Boehm-Demers-Weiser (BDW) 垃圾收集器,它会停止程序的执行,并仅在完成其工作后恢复正常执行。 BDW的工作算法可以描述如下:
- Stop the world:垃圾收集器暂停程序执行以进行垃圾收集。
- Root scanning:它扫描根指针,确定所有可从程序代码直接访问的实时对象。
- Object tracing:它跟踪根对象的引用来确定所有可用对象,从而创建可访问对象的图。
- Reference counting:它计算每个对象的引用次数。
- Memory reclamation:垃圾收集器释放没有引用的对象(死对象)占用的内存。
- World resumption:垃圾回收完成后,程序继续执行。
这是一个保守的垃圾收集器,这意味着它不需要有关内存中所有对象指针位置的精确信息。相反,它假设内存中任何可以作为指向对象的指针的值都是有效的指针。这使得 BDW 垃圾收集器能够使用不提供精确指针信息的编程语言。
增量垃圾收集
从 Unity 2019.1 开始,BDW 默认以增量模式使用。这意味着垃圾收集器将其工作负载分布在多个帧上,而不是停止主 CPU 线程(停止世界垃圾收集)来处理托管堆中的所有对象。
因此,Unity 在执行应用程序时会采取较短的休息时间,而不是进行一次长时间的中断,从而允许垃圾收集器处理托管堆中的对象。增量模式整体上不会加速垃圾收集,但由于它将工作负载分布在多个帧上,因此减少了与 GC 相关的性能峰值。
增量垃圾收集可能会给您的应用程序带来问题。在这种模式下,垃圾收集器划分其工作,包括标记阶段。标记阶段是垃圾收集器扫描所有托管对象,以确定哪些对象仍在使用以及哪些可以清除。
当对象之间的大多数引用在工作片段之间不会改变时,划分标记阶段效果很好。但是,太多的更改可能会使增量垃圾收集器过载,并造成标记阶段永远无法完成的情况。在这种情况下,垃圾收集器将切换为执行完整的非增量收集。为了通知垃圾收集器每个引用的更改,Unity 使用写屏障;这会在更改引用时增加一些开销,从而影响托管代码的性能。
我们来比较一下:
Unity(Boehm-Demers-Weiser) | .NET CG | |
算法 | 保守的垃圾收集器 | 分代垃圾收集器 |
运行环境 | Mono or IL2CPP | .NET Core, .NET Framework, .NET 5+ |
根扫描 | 根部扫描精度较低 | 精准根部扫描 |
对象追踪 | Yes | Yes |
引用计数 | Yes | No |
内存压缩 | No | Yes(除大型对象外) |
世代 | No | Yes(0,1,2) |
速度 | 由于开销和缺乏紧凑性而速度较慢 | 由于分代方法和精确的根扫描,速度更快 |
停止世界 | Yes | 是(但由于增量垃圾收集对性能的影响较小) |
碎片处理 | 容易产生碎片(由于缺乏对象压缩) | 减少碎片(由于对象压缩) |
因此,由于 Unity 中没有 CLR GC,我们得到了一种容易产生堆碎片的机制,并且在未划分代的所有对象的空间中进行缓慢、繁琐的垃圾收集。让我们考虑一下在 Unity 中开发游戏时应考虑到的后果。
避免 Unity GC 限制陷阱
由于Unity增量GC的限制,可以确定以下痛点:
- GC 调用比 .NET 更昂贵(必须处理整个对象图,而不仅仅是其子集)。
- 频繁的对象创建和删除导致内存碎片;对象之间的空间只能用相同或更小尺寸的新对象填充。
- 对象关系的频繁变化使得增量 GC 模式难以使用(GC 周期需要更多帧并降低 FPS)。
- 对象关系(每一帧)变化过于频繁,导致GC切换到非增量模式;我们没有将 GC 启动分散到多个帧上,而是在垃圾回收完成之前进行一次大的世界停止。
为了避免这些痛点,请特别小心堆中不必要的内存分配,这可能会导致垃圾收集峰值:
- 装箱:避免传递值类型变量而不是引用类型变量。这会创建一个临时对象和与其隐式关联的潜在垃圾,将值类型转换为对象类型。一个简单的例子:
看起来,由于日、月和年都是值类型并在函数内部定义,因此不会有分配或垃圾。但是如果我们查看 string.Format() 的实现,我们会看到参数是对象类型,这意味着将发生值类型装箱并将它们放置在堆中:
- 字符串:在C#语言中,字符串是引用类型,而不是值类型。尽量减少字符串的创建和操作。使用 StringBuilder 类在运行时处理字符串。
- 协程:虽然yield运算符本身不会产生垃圾,但创建一个新的WaitForSeconds对象会:
- 闭包和匿名方法。一般来说,如果可能的话,尽量避免在 C# 中使用闭包。尽量减少在与性能相关的代码中使用匿名方法和方法引用,尤其是当代码在每个帧上运行时。 C# 中的方法引用是引用类型,它们位于堆上。这意味着当您传递方法引用作为参数时,可能会发生临时内存分配。无论是否传递匿名方法或已定义方法,都会发生这种情况。而且,当你将匿名方法变成闭包时,需要传递给闭包方法的内存量会大大增加。
- LINQ 和正则表达式:这两种工具都会由于隐藏装箱而产生垃圾。如果性能至关重要,请避免使用 LINQ 和正则表达式。
- Unity函数:请注意,有些函数在堆上分配内存。将数组的引用保留在缓存中,而不是在循环内分配它们。另外,使用不会产生垃圾的函数。例如,更喜欢使用 GameObject.CompareTag,而不是将字符串与 GameObject.tag 进行比较(因为返回新字符串会产生垃圾)。使用 Unity API 方法的非分配替代方法,例如Physics.RaycastNonAlloc。
如果您知道在游戏中的某个时刻垃圾收集过程不会影响游戏体验(例如,在加载屏幕上),您可以通过调用 System.GC.Collect 来启动垃圾收集。然而,争取的最佳实践是零分配。这意味着您可以在游戏开始时或加载特定游戏场景时保留所需的所有内存,然后在整个游戏周期中重复使用该内存。结合用于重用的对象池以及使用结构而不是类,这些技术解决了游戏中的大多数内存管理问题。