优化垃圾内存
介绍
当我们运行游戏的时候,使用内存纯粹数据。当这些数据不需要的时候,这些内存就会释放以便于我们重新使用。垃圾就是不被使用的数据占用的内存。垃圾收集就是处理这些内存仍旧可以被利用的过程的名称。
unity使用垃圾回收来管理一部分内存。如果垃圾收集发生的太频繁或者处理的工作太多就会造成性能低下,这也就是意味着垃圾收集是影响性能问题的一个普遍的原因。
在这篇文章中我们会学到当垃圾回收发生时候垃圾收集器怎么工作的和怎样有效的使用内存以便于缩小垃圾收集对我们游戏性能的影响.
垃圾收集问题诊断
垃圾收集主要带来的性能问题主要是帧率下降, (jerky performance or intermittent freezes) 大概意思是游戏开起来很卡。然而 其他因数也可能带来的这样的问题。如果我们的游戏有这样性能问题,第一件事情就是应该使用 unity的profiler 窗口来确定是不是垃圾回收带来的。
Unity内存管理简介
为了理解垃圾收集器何时发生和怎么工作,我们必须首先了解内存在unity中怎么使用。首先,我们必须了解在运行自己的核心引擎代码和运行我们在脚本中编写的代码时,Unity使用了不同的方法。
在运行自己的核心统一引擎代码时,Unity管理内存的方式称为手动内存管理。这意味着核心引擎代码必须显式地说明如何使用内存。手动内存管理不使用垃圾收集,在本文中不再赘述。
在运行我们的代码时,Unity管理内存的方式称为自动内存管理。这意味着我们的代码不需要明确地告诉Unity如何以一种详细的方式管理内存。unity已经为我们考虑好了。
在最基本的层面上,unity的自动内存管理工作如下:
1 .Unity可以访问两个内存池:堆栈和堆(也称为托管堆)。堆栈用于短期存储小块数据,堆用于长期存储和较大的数据块。2.当创建一个变量时,Unity会从堆栈或堆中请求一个内存块。
3.只要变量在范围内(我们的代码仍然可以访问),分配给它的内存仍然在使用中。我们称内存已经分配。我们将在堆栈内存 中保存的变量描述为堆栈上的对象,并将在堆内存中保存的变量作为堆中的对象。
4.当变量不可以访问的时候,内存便不再使用并且可以归还给他来自的内存池了。当内存返回内存池的是我们称内存被释 放了.一旦变量不在可以访问的时候分配的栈内存就会立即释放。然而堆内存在这种情况下不会立即释放并且会仍旧 保留一段 时间。
5.垃圾回收器识别并释放未使用的堆内存。垃圾收集器定期运行以清理堆。
既然我们已经了解了事件的流程,那么让我们更仔细地看看堆栈分配和处理位置与堆分配和处理位置的区别。站
在堆栈分配和处理过程中会发生什么?
栈的分配和回收是快速和简单的。这是因为堆栈只是用来储存使用很短的时间的小数据。分配和回收总是以可预测的顺序发生,并且具有可预测的大小。
堆栈的工作方式类似于堆栈数据类型:它是一个简单的元素集合,在这个内存块中,元素只能以严格的顺序添加和删除。这种简单性和严格性使它如此快速:当一个变量被存储在堆栈上时,它的内存只是从栈头分配。当堆栈变量超出范围时,用于存储该变量的内存将立即返回到堆栈中以便重用。
在堆分配时候发生什么?
堆分配比堆栈分配要复杂得多。这是因为堆可以用来存储长期和短期数据,以及许多不同类型和大小的数据。分配和释放并不总是以可预测的顺序发生,并且可能需要非常不同大小的内存块。
创建堆变量时,将执行以下步骤:
1. 首先,Unity必须检查堆中是否有足够的空闲内存。如果堆中有足够的空闲内存,则分配该变量的内存。
2.如果堆中没有足够的空闲内存,Unity就会触发垃圾收集器,以释放未使用的堆内存。这可能是一个缓慢的操作。如果堆 中有足够的空闲内存,则分配该变量的内存。
3.如果垃圾收集后堆中没有足够的空闲内存,Unity就会增加堆中的内存。这可能是一个缓慢的操作。然后分配该变量的内 存。
堆分配可能很慢,特别是如果垃圾收集器必须运行,堆必须被扩展。
在垃圾收集过程中发生了什么?
当堆变量超出范围时,用于存储它的内存不会立即被释放。未使用的堆内存只有在垃圾收集器运行时才会释放。
每次垃圾收集器运行时,都会发生以下步骤:
垃圾收集器检查堆上的每个对象。
垃圾收集器搜索所有当前对象引用,以确定堆上的对象是否仍然在范围内。
任何不再在范围内的对象被标记为删除。
被标记的对象被删除,分配给它们的内存被返回给堆。
垃圾收集可能是一项昂贵的操作。堆上的对象越多,它必须做的工作越多,在我们的代码中引用的对象越多,它必须做的工作就越多
垃圾收集何时发生
有三件事情会导致垃圾收集器运行:
每当请求堆分配时,当使用空闲内存从堆中无法实现时候垃圾收集器就会运行垃圾收集器会自动地运行(尽管频率因平台而异)。
垃圾收集器可以强制手动运行。
垃圾收集可以是一个频繁的操作。每当从可用堆内存中无法实现堆分配时,就会触发垃圾收集器,这意味着频繁的堆分配和分配会导致频繁的垃圾收集。
垃圾收集带来的问题
现在我们了解了垃圾收集在统一内存管理中发挥的作用,我们可以考虑可能出现的问题类型。
最明显的问题是垃圾收集器可以花相当长的时间运行。如果垃圾收集器在堆上有很多对象和或大量对象引用来检查,那么检查所有这些对象的过程可能会很慢。这可以使我们的游戏变得很卡或运行缓慢。
另一个问题是垃圾收集器可能在不方便的时候运行。如果CPU已经在我们游戏的性能关键部分中努力工作,即使是垃圾收集的少量额外开销也会导致我们的帧速率下降,性能显著改变。另一个不太明显的问题是堆碎片。当内存从堆中分配时,根据必须存储的数据的大小,从不同大小的块中提取内存。当这些内存块返回到堆时,堆就会被分割成许多小的空闲块,这些块被分配的块分隔开。这意味着,尽管空闲内存总量可能很高,但我们无法在不运行垃圾收集器和或扩展堆的情况下分配大块内存,因为现有的块都不够大。
碎片堆有两个后果。首先,我们游戏的内存使用量将会比它需要的要高,第二个是垃圾收集器将会运行得更频繁。关于堆碎片化的更详细的讨论,请参考this Unity best practice guide on performance.
发现堆分配
如果我们知道垃圾收集在我们的游戏中造成了问题,我们需要知道我们的代码中哪些部分产生了垃圾。当堆变量超出范围时产生垃圾,所以首先我们需要知道是什么原因导致在堆上分配一个变量。
什么是在栈上和堆上分配(内存)
在Unity中,值类型的局部变量被分配到堆栈上,其他的都分配在堆上。如果您不确定在Unity中值和引用类型之间的区别,请参阅 this tutorial.。
下面的代码是一个堆栈分配的例子,因为变量localInt是本地的和值类型的。分配给该变量的内存将在该函数完成运行后立即从堆栈中释放。
void ExampleFunction() { int localInt = 5; }
下面的代码是堆分配的一个示例,因为变量localList是本地的,但是是引用类型的。当垃圾收集器运行时,分配给该变量的内存将被释放。
void ExampleFunction() { List localList = new List();
}
使用“Profiler”窗口找到堆分配
通过profiler窗口我们可以看到我们的代码在堆分配情况(由于官网图片模糊不清自己截取了一个本地的凑合能看)
在选择了CPU使用profiler之后,我们可以选择任何帧来查看在profiler窗口底部的那个的CPU使用数据的fame。其中一列数据
称为GC alloc。这一列显示了在该Fram中正在进行的堆分配。如果我们选择列标头,我们可以根据这个统计数据对数据进行排序,
这样就可以很容易地看到我们的游戏中哪些函数导致了最多的堆分配。一旦我们知道了哪个函数导致堆分配,我们就可以检查这个函
数。一旦我们知道函数内的代码导致了垃圾生成,我们就可以决定如何解决这个问题,并最小化生成的垃圾数量。
减少垃圾收集的影响
一般来说,我们可以通过以下三种方式减少垃圾收集对我们游戏的影响:
我们可以减少垃圾收集器运行的时间。我们可以减少垃圾收集器运行的频率。
我们可以故意地触发垃圾收集器,这样它就会在没有性能临界的情况下运行,例如在加载屏幕上。
了解这些以后,我们可以有三种方法来帮助我们
我们可以组织我们的游戏,这样我们的堆分配就更少,对象引用也更少。堆上的对象越少,检查的引用就越少,这意味着当垃圾收集被触发时,运行的时间就更少了。
我们可以减少堆分配和分配的频率,特别是在性能关键时刻。更少的分配和分配意味着更少的触发垃圾收集的场合。这也降低了堆碎片的风险。我们可以尝试时间垃圾收集和堆扩展,以便它们在可预测和方便的时间发生。这是一种更加困难和不可靠的方法,但是当作为整体内存管理策略的一部分使用时,可以减少垃圾收集的影响。
减少创建的垃圾数量
让我们来看看几种可以减少垃圾产生的代码
缓存
如果我们的代码反复调用产生堆内存我们可以规避产生这种不必要的垃圾。我们应该保留这些对象的引用一边与重复利用。这种技术就是众所周知的缓存。
在下面一个例子中这个代码每次调用由于都会创建一个新的数组所以每次都会产生堆内存分配
void OnTriggerEnter(Collider other) { Renderer[] allRenderers = FindObjectsOfType<Renderer>(); ExampleFunction(allRenderers); }
下面的代码只导致一个堆分配,因为数组被创建和填充一次,然后缓存。缓存的数组可以一次又一次地重复使用,而不会产生更多的垃圾。
private Renderer[] allRenderers; void Start() { allRenderers = FindObjectsOfType<Renderer>(); } void OnTriggerEnter(Collider other) { ExampleFunction(allRenderers); }
不要在频繁的调用函数中进行堆内存分配
如果我们必须在monobihaviour中分配堆内存,那么我们所能做的最坏的地方就是频繁运行的函数。例如,Update()和LateUpdate()在每个帧中被调用一次,所以如果我们的代码在这里生成垃圾,那么它将会很快地累积起来。我们应该尽可能考虑在Start()或在Awake()中对象进行缓存引用,或者确保内存分配的代码只在需要时运行。
让我们看一个非常简单的移动代码示例,它只在发生变化时运行。在下面的代码中,每次调用Update()都会调用一个导致内存分配的函数,它经常创建垃圾:
void Update() { ExampleGarbageGeneratingFunction(transform.position.x); }
通过简单的更改,我们现在确保只在Transform.position.x发生变化时候分配函数调用。我们现在只在必要时进行堆分配,而不是在每一帧中.
void Update() { float transformPositionX = transform.position.x; if (transformPositionX != previousTransformPositionX) { ExampleGarbageGeneratingFunction(transformPositionX); previousTransformPositionX = transformPositionX; } }
清除集合
创建新集合会导致堆上的分配。如果我们发现我们在代码中不止一次地创建新集合,那么应该将引用缓存到集合中,并使用Clear()清空其内容,而不是重复调用新内容。
在下面的例子中每帧执行update时候一个新的堆分配都会发生
void Update() { List myList = new List(); PopulateList(myList); }
在下面的示例中,只有在创建集合或在集合必须调整大小时候才会发生内存分配。这大大减少了产生的垃圾数量。
private List myList = new List(); void Update() { myList.Clear(); PopulateList(myList); }
对象池
(看到这个你一定不陌生)
即使我们减少了脚本中的分配,如果在运行时创建和销毁大量对象,我们仍然可能存在垃圾收集问题。对象池是一种通过重用对象而不是重复地创建和销毁对象来减少分配和交换位置的技术。对象池在游戏中被广泛使用,最适合于我们频繁地生成和销毁相似对象的情况;举个例子,从枪里射出子弹。
对对象池的完整介绍超出了本文的范围,但它是一种非常有用的技术,值得学习。这篇关于统一学习网站上对象池的教程This tutorial on object pooling on the Unity Learn site
通常引起不必要的内存分配的情况
我们了解了 局部变量、值类型的变量是在堆栈上分配的,而其他所有的变量都是在堆上分配的。然而,有很多情况下堆分配可能会让我们大吃一惊。让我们来看看一些不必要的堆分配的常见原因,并考虑如何最好地减少它们。
string
在c#中,字符串是引用类型,而不是值类型,即使它们似乎持有字符串的“值”。这意味着创建和删除字符串会创建垃圾。由于字符串在很多代码中都是常用的,所以这种垃圾真的可以累加起来。
c#中的字符串也是不可变的,这意味着它们的值在创建后不能更改。每次我们操作一个字符串(例如,通过使用+运算符将两个字符串连接起来),Unity就会创建一个新的字符串,并使用更新后的值,并丢弃旧的字符串。这就产生了垃圾。
我们可以遵循一些简单的规则,将垃圾从字符串保持到最小值。让我们考虑一下这些规则,然后看看如何应用它们的示例。
我们应该减少不必要的字符串创建。如果我们使用相同的字符串值不止一次,我们应该创建一次字符串并缓存该值。
我们应该减少不必要的字符串操作。例如,如果我们有一个经常更新的文本组件,并且包含一个连接的字符串,我们可以考 虑将它分为两个文本组件。
如果我们必须在运行时构建字符串,我们应该使用StringBuilder类。StringBuilder类是为构建字符串而设计的,它不需要分 配,并且可以节省在连接复杂字符串时产生的垃圾数量.
我们应该在不需要调试的情况下立即删除对Debug.Log()的调用。对Debug.Log()的调用仍然在我们游戏的所有构建中执 行,即使它们没有输出到任何东西。对Debug.Log()的调用创建和处理至少一个字符串,因此,如果我们的游戏包含许多这 样的调用,则垃圾可以相加。
Unity 方法调用
重要的是要意识到,当我们调用代码时,不管它是在Unity中还是在插件中,我们都可以生成垃圾。一些Unity函数调用会创建堆分配,因此应该小心使用,以避免生成不必要的垃圾。没有我们应该避免的函数列表。每个函数在某些情况下都是有用的,在其他情况下用处不大。与以往一样,最好仔细地剖析我们的游戏,确定在哪里创建了垃圾,并仔细考虑如何处理它。在某些情况下,缓存函数的结果可能是明智的;在其他情况下,更少地调用函数是明智的;在其他情况下,最好重构代码以使用不同的函数。说到这一点,让我们来看看几个Unity函数的例子,它们会引起堆分配,并考虑如何最好地处理它们。
每当我们访问一个返回一个数组的unity函数时候,就会创建一个新的数组并将其作为返回值传递给我们。这种行为并不总是明显的或预期的,特别是当函数是访问器(例如,Mesh.normals)时。
下面的例子代码中,每次迭代循环都会创建一个新数组
void ExampleFunction() { for (int i = 0; i < myMesh.normals.Length; i++) { Vector3 normal = myMesh.normals[i]; } }在这样的情况下,很容易减少分配:我们可以简单地缓存对数组的引用。当我们这样做时,只创建一个数组,并相应地减少创建的垃圾数量。
下面的代码演示了这一点。在这种情况下,在循环之前我们调用Mesh.normals并缓存这个引用一次只创建一个数组
void ExampleFunction() { Vector3[] meshNormals = myMesh.normals; for (int i = 0; i < meshNormals.Length; i++) { Vector3 normal = meshNormals[i]; } }堆分配的另一个意想不到的原因是在函数的GameObject.name或GameObject.tag中。它们都是返回新字符串的访问器,这意味着调用这些函数将生成垃圾。缓存值可能是有用的,但是在这种情况下,我们可以使用一个对用的Unity函数来代替。为了在不生成垃圾的情况下检查GameObject的标签,我们可以使用GameObject. comparetag()。
在下面的示例代码中,通过调用GameObject.tag创建了垃圾。
private string playerTag = "Player"; void OnTriggerEnter(Collider other) { bool isPlayer = other.gameObject.tag == playerTag; }
如果我们使用GameObject.CompareTag()这个函数就不会产生垃圾
private string playerTag = "Player"; void OnTriggerEnter(Collider other) { bool isPlayer = other.gameObject.CompareTag(playerTag); }
GameObject.CompareTag isn’t unique; many Unity function calls have alternative versions that cause no heap allocations. For example, we could use Input.GetTouch() and Input.touchCount in place of Input.touches, or Physics.SphereCastNonAlloc() in place of Physics.SphereCastAll().
Boxing
装箱是指当一个值类型的变量被用来代替一个引用类型的变量时发生的事情。当我们将值类型的变量(例如ints或浮点数)传递给一个具有对象参数(如object . equals())的函数时,通常会发生装箱。
void ExampleFunction() { int cost = 5; string displayString = String.Format("Price: {0} gold", cost); }
因为在场景里装箱会产生垃圾。当一个值类型变量被装箱时,Unity会在堆上创建一个临时对象来包住这个值类型变量。于是当这个临时对象被处理时候就会产生垃圾。
装箱是不必要的堆分配的一个非常常见的原因。即使我们不直接在代码中直接设置变量来装箱,我们使用的插件也可能会导致装箱发生,或者场景里的其他功能会产生装箱。最好的做法是尽可能避免装箱,并移除导致装箱的任何功能调用。
Coroutines
调用StartCoroutine()会创建了少量的垃圾,因为Unity必须创建一个实例子来管理coroutine。考虑到这一点,对StartCoroutine()的调用应该是有限的,而我们的游戏是交互式的,性能是一个问题。为了减少以这种方式创建的垃圾,在性能关键时刻必须运行的任何coroutines都应该提前启动,在使用可能包含延迟调用StartCoroutine()的嵌套coroutines时,我们应该特别小心。
coroutines中的yield语句并没有在自己的使用范围内创建堆分配;但是,我们使用yield语句传递的值可能会造成不必要的堆分配。例如,下面的代码创建了垃圾:
yield return 0;
此代码创建垃圾,因为值为0的int被装箱。在这种情况下,如果我们希望简单地等待一个框架而不引起任何堆分配,那么最好的方法就是使用以下代码:
yield return null;
coroutines的另一个常见错误是可以使用相同值时候仍然去创建新值。例如,下面的代码每次循环迭代时处理WaitForSeconds对象时候都会创建新对象
while (!isComplete) { yield return new WaitForSeconds(1f); }如果我们缓存和重用WaitForSeconds对象,就会创建更少的垃圾。下面的代码展示了这个示例:
WaitForSeconds delay = new WaitForSeconds(1f); while (!isComplete) { yield return delay; }
如果我们的代码生成了大量的垃圾,我们可能会考虑重构我们的代码来使用除coroutines以外的其他东西。重构代码是一个复杂的主题,每个项目都是独一无二的,但是有一些共同的替代方案,我们可能希望记住。例如,如果我们主要使用coroutines来管理时间,我们可能希望简单地跟踪Upate()函数中的时间。如果我们使用coroutines主要是为了控制游戏中发生的事情的顺序,我们可能希望创建某种类型的消息传递系统,以允许对象进行通信。没有一种方法适合通用,但是记住,在代码中实现相同的方法常常不止一种方法。
foreach loops
unity 5.5 版本前循环遍历都会产生垃圾用while for 替换就可以,5.5版本后不会了所以不再叙述
方法引用
方法的引用,不管是匿名方法,还是命名的方法(其实也就是类似于委托),在untiy中都当引用类型来处理。将匿名方法转换为闭包(匿名方法在创建时可以访问范围内的变量)会显著增加内存使用量和堆分配的数量。
方法引用的精确细节和闭包分配内存依据不同的平台和编译设定。但是如果垃圾收集是一个问题的话,请减少方法参数引用和闭包使用。This Unity best practice guide on performance有这方面技术详细概述。
LINQ 和正则表达式
由于LINQ和正则表达式会产生装箱所以会产生垃圾。
机构化代码来减少垃圾产生
我们的代码结构化的方式可以影响垃圾收集。即使我们的代码没有创建堆分配,它也可以增加垃圾收集器的工作负载。
我们的代码不必要地添加到垃圾收集器的工作负载的一种方法是,要求它检查它不需要检查的东西。struct是值类型的变量,但是如果我们有一个包含了引用类型变量的结构,那么垃圾收集器必须检查整个结构。如果我们有大量的这些结构,那么这将为垃圾收集器创建大量额外的工作。
在这个示例中,struct包含一个字符串,它是引用类型。现在,整个结构的数组必须在运行时由垃圾收集器检查。
public struct ItemData { public string name; public int cost; public Vector3 position; }
private ItemData[] itemData;
在本例中,我们将数据存储在单独的数组中。当垃圾收集器运行时,它只需要检查字符串数组,并且可以忽略其他数组。这减少了垃圾收集器必须做的工作。
private string[] itemNames; private int[] itemCosts; private Vector3[] itemPositions;
我们的代码不必要地添加到垃圾收集器的工作负载的另一种方式是使用不必要的对象引用。当垃圾收集器在堆上搜索对对象的引用时,它必须检查我们代码中的每个当前对象引用。在我们的代码中有较少的对象引用意味着它有更少的工作要做,即使我们不减少堆上的对象总数。
在本例中,我们有一个填充对话框的类。当用户看到对话框时,会显示另一个对话框。我们的代码包含了一个关于应该显示的对话数据的下一个实例的引用,这意味着垃圾收集器必须检查此引用作为其操作的一部分:
public class DialogData { private DialogData nextDialog; public DialogData GetNextDialog() { return nextDialog; } }
这里,我们对代码进行了重构,以便它返回一个标识符,该标识符用于查找DialogData的下一个实例,而不是实例本身。这不是一个对象引用,因此它不会添加到垃圾收集器所占用的时间。
public class DialogData { private int nextDialogID; public int GetNextDialogID() { return nextDialogID; } }
就其本身而言,这个示例非常简单。但是,如果我们的游戏中包含了大量引用其他对象的对象,那么我们可以通过重构我们的代码来大大降低堆的复杂度。
垃圾收集时间
手动强制垃圾收集
最后,我们可能希望自己触发垃圾收集。如果我们知道堆内存被分配,但不再使用(例如,如果我们的代码生成的垃圾),我们直接可以手动触发。
结论
我们已经了解了垃圾收集是如何在unity中工作的,为什么它会导致性能问题,以及如何减少它对我们游戏的影响。利用这些知识和分析工具,我们可以修复与垃圾收集和结构相关的性能问题,从而有效地管理内存。
下面的链接提供了本文中涉及的主题的进一步信息。
Unity Manual: Understanding Optimization in Unity
Unity Manual: Understanding Automatic Memory Management
Gamasutra: C# Memory Management for Unity Developers by Wendelin Reich
Gamasutra: C# memory and performance tips for Unity by Robert Zubek
Gamasutra: Reducing memory allocations to avoid Garbage Collection on Unity by Grhyll JDD
Gamasutra: Unity Garbage Collection Tips and Tricks by Megan Hughes
Boxing
MSDN: Boxing and Unboxing (C# Programming Guide)
Object pooling
Unity Learn: Object Pooling Tutorial
Wikipedia: Object Pool Pattern
Strings
Best Practices for Using Strings in the .NET Framework