【Unity】垃圾回收优化

在游戏开发过程中,经常遇到大帧问题,可以通过 Profiler 工具检测,是否存在 GC 过高的问题。在 Unity 官方网站上找到了优化垃圾回收的进阶教程,于是翻译出来方便阅读。

1.介绍

当我们游戏运行时候,使用的是内存去存储数据。当数据不再需要了,存储数据的内存就会被释放以便重用。垃圾(Garbage)是存储不再使用的数据的内存的术语。垃圾回收(Garbage collection)是使内存可再次使用的进程的名字。

Unity 使用垃圾回收来管理内存。如果垃圾回收频繁发生或者垃圾回收要做很多工作,我们的游戏性能就会变差。垃圾回收是造成性能问题的常见原因。

本文中,我们将学到垃圾回收是如何工作的和怎样高效使用内存来最小化垃圾回收在游戏中的影响。

 

  2.用垃圾回收分析器诊断问题

垃圾回收造成的性能问题可以表现为帧率低、卡顿甚至卡死。然而,其他问题也能引起类似的症状。如果我们游戏有类似的问题,首先我们应该使用 Unity 的 Profiler 工具来确定这个问题是否是垃圾回收引起的。

学习如何使用 Profiler 工具分析性能问题,请看这篇文章

 

3.Unity 内存管理简介

为了理解垃圾回收是如何发生的,我们必须明白 Unity 的内存工作原理。首先,我们必须明白引擎代码和我们自己编写的代码的内存管理是不同的。

核心引擎代码运行的时候的内存管理方式叫做手动内存管理( manual memory management )。这意味着核心引擎代码必须明确地说明如何使用内存。手动内存管理不会使用垃圾回收,因此本文不做进一步说明。

我们代码运行时候的内存管理方式叫做自动内存管理(automatic memory management)。这意味着我们的代码不需要明确地告诉Unity如何管理内存。Unity 已经为我们考虑好了。

在基本层面上讲,Unity 自动内存管理像这样:

  • Unity 可以访问两个内存池:栈(stack)和堆(heap)(also known as the managed heap)。栈用于短期存储的小数据,堆用于较长时间存储的较大的数据块。

  • 当创建变量的时候,Unity 会从栈或堆中请求一个内存块。

  • 只要变量值作用域内(in scope(仍可被代码访问) ),分配给它的内存仍然会保留。我们说的内存被分配( allocated )了。我们可以这样描述,保存在栈内存的叫做栈对象(object on the stack),保存在堆内存的叫做堆对象(object on the heap)。

  • 当变量不在作用域范围内了,这块内存就不再需要了,可以被创建它的那个内存池回收。当内存被回收到池,我们说内存被重新分配(deallocated)了。只要变量的引用超出了作用域,栈内存就会被重新分配,而堆的内存不同,这种情况下内存不会重新分配即使变量的引用超出了作用域。

  • 垃圾回收器标识和回收没有使用的堆内存。垃圾回收器定期清理堆内存。

现在我们了解了事件的流程,让我们详细了解栈和堆分配和释放内存的不同之处。

 

栈分配和释放内存的时候会发生什么?

栈分配和释放内存是非常快速和简单的。这是因为栈只用于存储短时间的小数据。分配和释放内存总是可以预测顺序和大小。

栈工作类似栈数据类型:这种情况下的内存更像一个简单的集合,元素按严格的顺序添加和移除。这种简单性和严格性使它如此快速:当变量存储在栈时,它的内存只从栈顶分配。当一个栈变量离开作用域,存储变量的内存会立即被栈重用。

 

堆分配内存会发生什么?

堆分配内存比栈分配内存要复杂得多。这是因为,堆可以用来存储长期数据和短期数据和不同类型和大小的数据。分配和释放不总是按可预见的顺序,并且可能需要非常不同大小的内存块。当创建一个堆变量的时候,会按以下的步骤:

  • 首先,Unity 会检查堆是否有足够的剩余内存。如果堆有足够的剩余内存,就会为变量分配内存。

  • 如果堆内有足够的剩余内存,Unity 触发垃圾回收尝试释放不使用的堆内存。这可能是一个很慢的操作。如果这时堆有足够的剩余内存,则会为变量分配内存。

  • 如果垃圾回收后,堆剩余内存仍然不够,Unity 会在堆中增加内存。这可能是一个很慢的操作。然后为变量分配内存。

堆分配可能会很缓慢,尤其是需要运行垃圾回收器或扩展堆内存的时候。

 

垃圾回收的时候会发生什么?

当一个堆变量离开作用域,存储该变量的内存不会被立即释放。不使用的堆内存只有在垃圾回收器运行时候才会释放。

每次垃圾回收器运行,会发生以下的事情:

  • 垃圾回收器检查堆中每个对象

  • 垃圾回收器搜索所有当前对象的引用以确定堆对象是否在作用域。

  • 任何不在作用域的对象都打一个删除的标记。

  • 分配给打了删除标记的对象的内存会被堆回收。

 

垃圾回收什么时候发生?

三件事可以造成垃圾回收的发生:

  • 请求堆内存分配的时候,剩余内存不能满足时,垃圾回收器会运行。

  • 垃圾回收器不时的自动运行(不同的平台频率不同)。

  • 垃圾回收可以手动运行。

垃圾回收可能是一个频繁的操作。请求对内存分配,内存不足时候会触发垃圾回收器,这意味频繁的堆内存申请和释放可能导致频繁的垃圾回收。

 

4.垃圾回收问题

现在我们知道了垃圾回收在 Unity 内存管理中的作用,我们可以考虑可能出现的问题类型。

 

最显而易见的问题是,垃圾回收器会花费很长的时间来运行。如果垃圾回收有大量的堆对象或有大量的引用要检查,检查所有对象的进度可能会很慢。这可能会造成我们游戏卡顿或运行缓慢。

另一个问题是,垃圾回收器可能在一个不恰当的时间运行。如果 CPU 在努力运行我们游戏的性能关键部分,即使垃圾回收会增加很小的消耗,也可能导致我们游戏掉帧和性能的显著变化。

另一个没有那么明显的问题是堆碎片(heap fragmentation)。从堆的空闲内存块申请的内存的大小依赖必须存储的数据的大小。当这些内存块被堆回收时,堆可能是一个被申请的内存块分割成的许多很小的空闲内存块。这意味着,尽管空闲内存总量很高,但如果我们没有使用垃圾回收器或扩展堆内存的话,我们是不能申请大内存块的,因为现存的内存块中没有那么大的内存块。

堆碎片会造成两个后果。第一个是我们游戏使用的内存会高于它所需要的内存。第二是垃圾回收器会频繁使用。关于堆碎片的详情和讨论,请看这篇文章。

 

查找堆分配

如果我们知道我们的游戏有垃圾回收问题,我们需要知道是我们代码的哪个部分产生了垃圾。当堆变量离开作用域的时候就会产生垃圾,所以我们首先要知道在堆上分配变量的原因。

 

栈和堆分配的是什么?

在 Unity 中,值类型的本地变量是在栈上分配的,除此之外是在堆上分配的。如果你不确定是哪种数据类型,请看这篇教程。

下面是栈分配内存的示例代码,localInt即是局部变量又是值类型。这个变量申请的内存会在函数执行之后被栈立即回收。

gcscript1.png

 

下面是堆分配内存的示例代码,localList变量是局部变量又是引用类型。这个变量申请的内存会在垃圾回收器运行的时候回收。

gcscript2.png

 

使用分析器查找堆分配

我们可以使用分析器查看我们代码哪里产生了堆分配。

 

选中 CPU 分析器,我们可以选择任何一帧查看 CPU 该帧的数据。其中一列数据叫GC alloc。这列数据显示该帧堆分配信息。如果我们选择列头,我们可以按统计的数据进行排序,使得更容易看出我们游戏我们游戏哪个函数造成的堆分配最多。一旦我们知道了哪个函数造成对分配,我们就可以测试该函数。

一旦我们知道了函数中产生垃圾的代码,我们就可以决定如何去解决这个问题和最小化垃圾的产生。

 

5.减少垃圾回收的影响

通常来说,我们可以通过下面三个方法来减小垃圾回收的影响:

  • 我们可以减少垃圾回收器运行的时间。

  • 我们可以少垃圾回收器运行的频率。

  • 我们可以故意触发垃圾回收器,使它在非性能关键时刻运行,比如加载场景的时候。

 

   考虑到这一点,这里有三种策略可以帮助我们:

  • 我们可以重构我们的游戏,使我们游戏更少的堆分配和更少的对象引用。更少的堆对象和更少的引用检测意味着当垃圾回收触发的时候会花费更少的时间运行。

  • 我们可以减少堆内存分配和释放的频率,尤其是在性能关键的时候。更少的内存申请和释放意味着更少的机会触发垃圾回收。这同样会减少堆碎片的风险。

  • 我们可以尝试定时垃圾回收和堆扩展以确保它们可以预测和在适当的时候运行。这是一个比较难比较不可靠的方法,但作为一个综合的内存管理策略,这可以减少垃圾回收的影响。

 

减少垃圾产生的数量

让我们通过代码测试几个能帮助我们减少垃圾产生的方法。

 

缓存(Caching)

如果我们频繁调用会申请堆内存,并且用完之后丢弃的函数,这会造成不必要的垃圾。我们应该保存这些对象的引用来重用它们。这个方法叫做缓存。

下面的示例,代码每次调用会造成堆内存分配。这是因为产生了新的数组。

gcscript3.png

 

下面的代码中只有一次堆内存申请,因为数组创建后缓存起来了。这个缓存的数组可以被重用,不会再产生更多的垃圾。

gcscript4.png

 

不要在频繁调用的函数中申请内存

如果我们必须在 MonoBehaviour 中申请堆内存,最坏的情况是在频繁的函数中调用。比如 Update() 和 LateUpdate(),这些函数每帧都会调用。如果我们产生垃圾的代码在这些地方调用,那么垃圾很快就会累加上来。我们应该考虑尽可能在 Start() 或 Awake() 缓存对象引用,或确保当需要的时候只申请一次内存。

让我们来看一个非常简单的移动代码,只有状态发生变化的时候才会移动。下面这段代码在每次 Update() 的时候调用,频繁创建垃圾。

gcscript5.png

做一点简单的改变,我们确保只有 transform.position.x 发生改变的时候才调用函数。我们使得这个函数在必要的时候申请对内存,而不是每帧。

gcscript6.png

另一个减少在 Update() 中生成垃圾的技巧是使用定时器。这种方法适用于频繁调用的但不需每帧调用的代码。

下面的代码每帧产生垃圾:

gcscript7.png

下面的代码,我们使用定时器,确保产生垃圾的代码每秒运行一次。

gcscript8.png

像这样的小改动,可以减少很多垃圾产生的数量。

清除集合

创建集合会在堆上申请内存。如果我们在代码中创建多次集合,我们应该缓存集合的引用,使用 Clear() 清空集合内容而不是反复创建新的。

下面例子中,每帧创建新的集合。

gcscript9.png 

下面代码中,集合只有在进入场景后创建的时候或重置大小的时候才会申请堆内存。这大大减少了垃圾数量的产生。

gcscript10.png

 

对象池(object pooling)

即使我们的代码减少了内存申请,如果我们在运行时候创建销毁大量的对象,我们仍然会有垃圾回收问题。对象池是一种通过重用对象而不是重复创建和销毁对象来减少内存分配的技术。对象池在游戏中广泛使用,尤其适用于频繁创建和销毁相似对象的游戏,比如枪弹射击类型的游戏。

全面介绍对象池超出了本文的范围,但这是一个非常有用和值得学习的技术。这篇文章教你如何在 Unity 中实现对象池技术。

 

造成不必要堆内存申请的常见的原因

我们知道本地的,值类型变量是在栈上申请内存的的,除此之外都是在堆上申请的。然而很多情况下,堆分配会让我们吃惊。让我们看下几种常见的造成不必要堆内存的原因和如何尽可能地减少这些操作。

 

字符串(Strings)

在 C# 中,strings 是引用类型不是值类型,即使它们看起来持有字符串的值。这意味着创建或丢弃 strings 会产生垃圾。因为 strings 在很多地方都很常用,所以垃圾会越来越多。

Strings 在 C# 中是不可更改的,这意味着它们首次创建之后就不能更改值。每次我们改变 string , Unity 会创建一个更新值后字符串并且丢弃旧的字符串。这会产生垃圾。

 

我们遵循一些简单的规则,使字符串垃圾最小化。让我们看下这些规则,并学习如何使用它们。

  • 我们应该减少不必要的字符串创建。如果我们多次使用相同值的字符串,我们应该创建字符串后缓存起来。

  • 我们应该减少不必要字符串操作。比如,我们有一个频繁刷新的文本并且包含一个连接字符串,这个时候我们应该考虑把它分成两个文本。

  • 如果我们在运行时创建字符串,我们应该使用 StringBuilder 这个类。这个类是用于创建没有分配的字符串的。当我们拼接字符串的时候,这个类可以减少大量垃圾的产生。

  • 当我们不在需要 Debug.Log() 的时候,我们应该尽快删除掉它。调用 Debug.Log() 也会有影响,即使没有打印任何内容。调用一次 Debug.Log() 至少创建和销毁一条字符串。所以我们游戏包调用次数多的话,垃圾就会越来越多。

 

下面代码中,我们创建一个用于显示的 score 字符串,在 Update() 中拼接显示计时器的值。这会创建不必要的垃圾。

gcscript11.png

 

下面代码中,我们改良一下。我们把 "TIME:" 取出来作为一个独立的文本,在 Start() 的时候赋值。也就是说,在 Updqte() 中,我们不需要再拼接字符串了。这大大减少了垃圾的产生。

gcscript12.png

 

Unity 函数调用

有一点要注意,我们不管调用 Unity 自身代码还是第三方插件代码,都有可能产生垃圾。一些 Unity 函数也会申请堆内存,所以我们应该小心使用,避免产生不必要的垃圾。

 

并没有一份列表表明我们应该要避免哪些函数。每个函数,可能在一些场景适用,在另一些场景又不适用了。所以最好是仔细检查我们的游戏,查出哪里产生了垃圾,并且仔细思考要怎么处理它。有时,缓存函数的返回值是一个英明的决定。有时减少函数调用的频繁度也是个不错的选择。有时重构我们的代码也是个好办法。说了这么多,让我们看看这几个 Unity 函数造成的堆内存申请,思考下如何解决它们。

 

每次我们访问 Unity 函数返回的数组的时候,Unity 都会创建一个新的数组传给我们。这种行为并不是很明显,尤其函数是一个访问器的时候。(比如 Mesh.normals)

 

下面代码中,每次迭代都会创建新的数组。

gcscript13.png

这种情况很容易减少内存申请:我们可以简单的缓存的该数组。我们这样做的时候,只会创建一个数组,因此大大减少垃圾的产生。

 

接下来我们在循环之前先缓存数组的引用,这样就只会创建一个数组。

gcscript14.png

 

另一个意想不到的函数是 GameObject.name 或 GameObject.tag,这两个访问器都是返回新的字符串,也就是说调用它们会产生垃圾。缓存字符串的值也许有用,但是我们可以使用 Unity 相关的函数替代它。为了减少 GameObject.tag 产生的垃圾,我们可以使用 GameObject.CompareTag() 替代。

 

下面演示的是 GameObject.tag 产生的垃圾:

gcscript15.png

 

如果我们使用 GameObject.CompareTag() 就不会再产生垃圾:

gcscript16.png

 

不仅仅 GameOject.CompareTag 是这样,许多 Unity 函数都提供不会产生堆内存的替代方法。比如,我们可以使用 Input.GetTouch() 和 Input.touchCount 代替 Input.touches 或使用 Physics.PhereCastNonAlloc() 代替 Physics.SphereCastAll()。

 

装箱(Boxing)

当一个值类型变量替代引用变量时候会发生装箱。装箱经常发生在我们传值类型(比如整型或浮点型)到一个以 object 为参数的函数上,比如Object.Equals()。

 

举个例子,String.Format() 有两个参数,string 和 object。当我们传一个 string 和一个 int 的时候就会装箱。下面的例子发生了装箱操作。

gcscript17.png

 

装箱会产生垃圾,是因为,当一个子类型被装箱的时候, Unity 在堆上创建了一个临时的 System.Object 用于封装值类型变量。System.Object 是引用类型,所以操作临时变量的时候会产生垃圾。

 

装箱是一个很常见的,又会产生不必要堆分配的现象。即使我们在自己的代码不直接引用装箱,我们可能也会使用有装箱操作的插件。最好的做法是尽可能避免装箱操作,并且移除任何导致装箱操作的函数。

 

协同程序(Coroutines)

调用 StartCoroutine() 会产生少量的垃圾,因为 Unity 会创建管理该协程的类。如果我们的游戏是交互的,并且性能上令人担忧,那么使用 StartCoroutine() 的时候就要限制下了。为了减少这个函数带来的垃圾,任何在性能关键时候运行的协程函数都应该提前运行,并且我们要特别小心要是调用嵌套调用的 StartCoroutine()。

 

协程中 yield 语句本省并不会申请堆内存,但是 yield 语句传递值可能会创建不必要的堆内存。比如下面的代码:

gcscript18.png

 

这行代码创建了垃圾,是因为 0 被装箱了。这种情况下,如果我们只是简单地等待一帧而不造成堆申请,最好的方法是:

gcscript19.png

 

另一个协程中比较常见错误是多次 yielding 的时候使用了 new 。比如下面的代码每次循环的时候创建然后销毁一个 WaitForSeconds 对象

gcscript20.png

 

如果我们缓存 WaitForSeconds 对象就能减少垃圾的创建,像这样:

gcscript21.png

 

如果我们的协程创建很多垃圾,我们可以考虑重构我们的代码使用其他方式代替协程。代码重构是一个复杂的主题,并且每个项目都不太一样。但这里有一些可供替代协程的选择,我们要牢记。比如,如果我们使用协程来管理时间,我们可以在 Update() 处理来代替。如果我们使用协程来控制事情发生的顺序,我们可以创建消息系统通信来控制。没有一种放之四海而皆准的方法,要记住,在代码中实现相同的事情有很多种方法。

 

foreach 循环

在 Unity 5.5版本之前,foreach 循环遍历数组以外的内容都会产生垃圾。这是装箱导致的。System.Object 在循环开始时创建,在循环结束后丢弃。这个问题在 Unity 5.5 修复了。

 

比如,下面的代码在 Unity 5.5版本之前会产生垃圾:

gcscript22.png

 

如果我们不能升级我们的 Unity 版本,有个简单的解决方案。for 和 while 循环不会装箱,所以不会产生任何垃圾。下面的代码不会产生垃圾:

gcscript23.png

 

 

函数引用(Function references)

对函数的引用,不管是匿名函数还是命名函数,在 Unity 中都是引用类型变量。它们会产生堆分配。

 

函数引用和闭包如何分配内存取决于平台和编译器设置,但如果需要考虑垃圾回收问题,那么最后在游戏过程中尽量减少函数引用和闭包的使用。这篇文章有更详细的介绍。

 

 

LINQ 和正则表达式

LINQ 和正则表达式都会产生垃圾,因为有装箱的操作。最好的做法是避免在性能关键时刻使用它们。这篇文章针对这一主题有更详细的描述。

 

重构我们的代码使垃圾回收影响最小

重构我们的代码可以减小垃圾回收的影响。即使我们的代码不会创建对内存,但也会增加垃圾回收器的负担。

 

定时垃圾回收

手动调用 gc 接口

我们可以手动触发垃圾回收。如果我们知道了有不再使用的堆内存(比如,加载资源产生的垃圾),并且我们知道该垃圾回收不会使玩家感觉卡顿(比如,在加载场景的时候),我们可以使用以下接口进行垃圾回收:

gcscript24.png

 

在我们方便的时候强制进行垃圾回收释放未使用的内存。

 

6.总结

我们学习了 Unity 的垃圾回收是如何工作的,为什么会造成性能问题以及如何减少它对游戏的影响。使用这些知识和 profiling 工具,我们可以修复垃圾回收相关问题。

 

本文尽量保持跟原文排版一致,翻译能力有限,以下是官方源地址:https://unity3d.com/cn/learn/tutorials/topics/performance-optimization/optimizing-garbage-collection-unity-games?playlist=44069

 

希望本文对你有帮助,欢迎关注公众号:hellokazhang,一个不给自己设限的终身学习者。

 

推荐阅读:

【Unity】优化代码

【Unity】使用Profiler进行性能分析

 

 

最好的投资是提升自己的能力。

  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值