在游戏运行的时候,数据主要存储在内存中,当游戏的数据在不须要的时候,存储当前数据的内存就能够被回收以再次使用。内存垃圾是指当前废弃数据所占用的内存,垃圾回收(GC)是指将废弃的内存从新回收再次使用的过程。编程
Unity中将垃圾回收看成内存管理的一部分,若是游戏中废弃数据占用内存较大,则游戏的性能会受到极大影响,此时垃圾回收会成为游戏性能的一大障碍点。c#
本文咱们主要学习垃圾回收的机制,垃圾回收如何被触发以及如何提升GC效率来提升游戏的性能。数组
要想了解垃圾回收如何工做以及什么时候被触发,咱们首先须要了解Unity的内存管理机制。Unity主要采用自动内存管理的机制,开发时在代码中不须要详细地告诉Unity如何进行内存管理,Unity内部自身会进行内存管理。这和使用C++开发须要随时管理内存相比,有必定的优点,固然带来的劣势就是须要随时关注内存的增加,不要让游戏在手机上跑“飞”了。缓存
Unity的自动内存管理能够理解为如下几个部分:性能优化
1.Unity内部有两个内存管理池:堆内存和堆栈内存。堆栈内存(stack)主要用来存储较小的和短暂的数据,堆内存(heap)主要用来存储较大的和存储时间较长的数据。ide
2.Unity中的变量只会在堆栈或者堆内存上进行内存分配,变量要么存储在堆栈内存上,要么处于堆内存上。函数
3.只要变量处于激活状态,则其占用的内存会被标记为使用状态,则该部分的内存处于被分配的状态。性能
4.一旦变量再也不激活,则其所占用的内存再也不须要,该部份内存能够被回收到内存池中被再次使用,这样的操做就是内存回收。处于堆栈上的内存回收及其快速,处于堆上的内存并非及时回收的,此时其对应的内存依然会被标记为使用状态。学习
5.垃圾回收主要是指堆上的内存分配和回收,Unity中会定时对堆内存进行GC操做。测试
在了解了GC的过程后,下面详细了解堆内存和堆栈内存的分配和回收机制的差异。
堆栈上的内存分配和回收十分快捷简单,由于堆栈上只会存储短暂的或者较小的变量。内存分配和回收都会以一种顺序和大小可控制的形式进行。
堆栈的运行方式就像stack:其本质只是一个数据的集合,数据的进出都以一种固定的方式运行。正是这种简洁性和固定性使得堆栈的操做十分快捷。当数据被存储在堆栈上的时候,只须要简单地在其后进行扩展。当数据失效的时候,只须要将其从堆栈上移除。
堆内存上的内存分配和存储相对而言更加复杂,主要是堆内存上能够存储短时间较小的数据,也能够存储各类类型和大小的数据。其上的内存分配和回收顺序并不可控,可能会要求分配不一样大小的内存单元来存储数据。
堆上的变量在存储的时候,主要分为如下几步:
1.首先,Unity检测是否有足够的闲置内存单元用来存储数据,若是有,则分配对应大小的内存单元;
2.若是没有足够的存储单元,Unity会触发垃圾回收来释放再也不被使用的堆内存。这步操做是一步缓慢的操做,若是垃圾回收后有足够大小的内存单元,则进行内存分配。
3.若是垃圾回收后并无足够的内存单元,则Unity会扩展堆内存的大小,这步操做会很缓慢,而后分配对应大小的内存单元给变量。
堆内存的分配有可能会变得十分缓慢,特别是在须要垃圾回收和堆内存须要扩展的状况下,一般须要减小这样的操做次数。
当堆内存上一个变量再也不处于激活状态的时候,其所占用的内存并不会马上被回收,再也不使用的内存只会在GC的时候才会被回收。
每次运行GC的时候,主要进行下面的操做:
1.GC会检查堆内存上的每一个存储变量;
2.对每一个变量会检测其引用是否处于激活状态;
3.若是变量的引用再也不处于激活状态,则会被标记为可回收;
4.被标记的变量会被移除,其所占有的内存会被回收到堆内存上。
GC操做是一个极其耗费的操做,堆内存上的变量或者引用越多则其运行的操做会更多,耗费的时间越长。
主要有三个操做会触发垃圾回收:
1.在堆内存上进行内存分配操做而内存不够的时候都会触发垃圾回收来利用闲置的内存
2.GC会自动的触发,不一样平台运行频率不同
3.GC能够被强制执行
特别是在堆内存上进行内存分配时内存单元不足够的时候,GC会被频繁触发,这就意味着频繁在堆内存上进行内存分配和回收会触发频繁的GC操做。
在了解GC在Unity内存管理中的做用后,咱们须要考虑其带来的问题。最明显的问题是GC操做会须要大量的时间来运行,若是堆内存上有大量的变量或者引用须要检查,则检查的操做会十分缓慢,这就会使得游戏运行缓慢。其次GC可能会在关键时候运行,例如在CPU处于游戏的性能运行关键时刻,此时任何一个额外的操做均可能会带来极大的影响,使得游戏帧率降低。
另一个GC带来的问题是堆内存的碎片划。当一个内存单元从堆内存上分配出来,其大小取决于其存储的变量的大小。当该内存被回收到堆内存上的时候,有可能使得堆内存被分割成碎片化的单元。也就是说堆内存整体可使用的内存单元较大,可是单独的内存单元较小,在下次内存分配的时候不能找到合适大小的存储单元,这也会触发GC操做或者堆内存扩展操做。
堆内存碎片会形成两个结果,一个是游戏占用的内存会愈来愈大,一个是GC会更加频繁地被触发。
GC操做带来的问题主要表现为帧率运行低,性能间歇中断或者下降。若是游戏有这样的表现,则首先须要打开Unity中的profiler window来肯定是不是GC形成。
若是GC形成游戏的性能问题,咱们须要知道游戏中的哪部分代码会形成GC,内存垃圾在变量再也不激活的时候产生,因此首先咱们须要知道堆内存上分配的是什么变量。
堆内存和堆栈内存分配的变量类型
在Unity中,值类型变量都在堆栈上进行内存分配,其余类型的变量都在堆内存上分配。
下面的代码能够用来理解值类型的分配和释放,其对应的变量在函数调用完后会当即回收:
void ExampleFunciton() { int localInt = 5; }
对应的引用类型的参考代码以下,其对应的变量在GC的时候才回收:
void ExampleFunction() { List localList = new List(); }
咱们能够在profier window中检查堆内存的分配操做:在CPU usage分析窗口中,咱们能够检测任何一帧Cpu的内存分配状况。其中一个选项是GC Alloc,经过分析其来定位是什么函数形成大量的堆内存分配操做。一旦定位该函数,咱们就能够分析解决其形成问题的缘由从而减小内存垃圾的产生。如今Unity5.5的版本,还提供了deep profiler的方式深度分析GC垃圾的产生。
大致上来讲,咱们能够经过三种方法来下降GC的影响:
1.减小GC的运行次数;
2.减小单次GC的运行时间;
3.将GC的运行时间延迟,避免在关键时候触发,好比能够在场景加载的时候调用GC
彷佛看起来很简单,基于此,咱们能够采用三种策略:
1.对游戏进行重构,减小堆内存的分配和引用的分配。更少的变量和引用会减小GC操做中的检测个数从而提升GC的运行效率
2.下降堆内存分配和回收的频率,尤为是在关键时刻。也就是说更少的事件触发GC操做,同时也下降堆内存的碎片化
3.咱们能够试着测量GC和堆内存扩展的时间,使其按照可预测的顺序执行。固然这样操做的难度极大,可是这会大大下降GC的影响
减小内存垃圾主要能够经过一些方法来减小:
若是在代码中反复调用某些形成堆内存分配的函数可是其返回结果并无使用,这就会形成没必要要的内存垃圾,咱们能够缓存这些变量来重复利用,这就是缓存。
例以下面的代码每次调用的时候就会形成堆内存分配,主要是每次都会分配一个新的数组:
void OnTriggerEnter(Collider other) { Renderer[] allRenderers = FindObjectsOfType<Renderer>(); ExampleFunction(allRenderers); }
对比下面的代码,只会生产一个数组用来缓存数据,实现反复利用而不须要形成更多的内存垃圾:
private Renderer[] allRenderers; void Start() { allRenderers = FindObjectsOfType<Renderer>(); } void OnTriggerEnter(Collider other) { ExampleFunction(allRenderers); }
在MonoBehaviour
中,若是咱们须要进行堆内存分配,最坏的状况就是在其反复调用的函数中进行堆内存分配,例如Update()
和LateUpdate()
函数这种每帧都调用的函数,这会形成大量的内存垃圾。咱们能够考虑在Start()
或者Awake()
函数中进行内存分配,这样能够减小内存垃圾。
下面的例子中,Update函数会屡次触发内存垃圾的产生:
void Update() { ExampleGarbageGenerationFunction(transform.position.x); }
经过一个简单的改变,咱们能够确保每次在x改变的时候才触发函数调用,这样避免每帧都进行堆内存分配:
private float previousTransformPositionX; void Update() { float transformPositionX = transform.position.x; if(transfromPositionX != previousTransformPositionX) { ExampleGarbageGenerationFunction(transformPositionX); previousTransformPositionX = trasnformPositionX; } }
另外的一种方法是在Update中采用计时器,特别是在运行有规律可是不须要每帧都运行的代码中,例如:
void Update() { ExampleGarbageGeneratiingFunction() }
经过添加一个计时器,咱们能够确保每隔1s才触发该函数一次:
private float timeSinceLastCalled; private float delay = 1f; void Update() { timSinceLastCalled += Time.deltaTime; if(timeSinceLastCalled > delay) { ExampleGarbageGenerationFunction(); timeSinceLastCalled = 0f; } }
经过这样细小的改变,咱们可使得代码运行的更快同时减小内存垃圾的产生。
附: 不要忽略这一个方法,在最近的项目性能优化中,我常常采用这样的方法来优化游戏的性能,不少对于固定时间的事件回调函数中,若是每次都分配新的缓存,可是在操做完后并不释放,这样就会形成大量的内存垃圾,对于这样的缓存,最好的办法就是当前周期回调后执行清除或者标志为废弃。
在堆内存上进行链表的分配的时候,若是该链表须要屡次反复的分配,咱们能够采用链表的clear函数来清空链表从而替代反复屡次的建立分配链表。
void Update() { List myList = new List(); PopulateList(myList); }
经过改进,咱们能够将该链表只在第一次建立或者该链表必须从新设置的时候才进行堆内存分配,从而大大减小内存垃圾的产生:
private List myList = new List(); void Update() { myList.Clear(); PopulateList(myList); }
即使咱们在代码中尽量地减小堆内存的分配行为,可是若是游戏有大量的对象须要产生和销毁依然会形成GC。对象池技术能够经过重复使用对象来下降堆内存的分配和回收频率。对象池在游戏中普遍的使用,特别是在游戏中须要频繁的建立和销毁相同的游戏对象的时候,例如枪的子弹这种会频繁生成和销毁的对象。
咱们已经知道值类型变量在堆栈上分配,其余的变量在堆内存上分配,可是任然有一些状况下的堆内存分配会让咱们感到吃惊。下面让咱们分析一些常见的没必要要的堆内存分配行为并对其进行优化。
在c#中,字符串是引用类型变量而不是值类型变量,即便看起来它是存储字符串的值的。这就意味着字符串会形成必定的内存垃圾,因为代码中常用字符串,因此咱们须要对其格外当心。
c#中的字符串是不可变动的,也就是说其内部的值在建立后是不可被变动的。每次在对字符串进行操做的时候(例如运用字符串的“+”操做),Unity会新建一个字符串用来存储新的字符串,使得旧的字符串被废弃,这样就会形成内存垃圾。
咱们能够采用如下的一些方法来最小化字符串的影响:
1.减小没必要要的字符串的建立,若是一个字符串被屡次利用,咱们能够建立并缓存该字符串。
2.减小没必要要的字符串操做,例如若是在Text组件中,有一部分字符串须要常常改变,可是其余部分不会,则咱们能够将其分为两个部分的组件,对于不变的部分就设置为相似常量字符串便可,见下面的例子。
3.若是咱们须要实时的建立字符串,咱们能够采用StringBuilder
来代替,StringBuilder
专为不须要进行内存分配而设计,从而减小字符串产生的内存垃圾。
4.移除游戏中的Debug.Log()
函数的代码,尽管该函数可能输出为空,对该函数的调用依然会执行,该函数会建立至少一个字符(空字符)的字符串。若是游戏中有大量的该函数的调用,这会形成内存垃圾的增长。
在下面的代码中,在Update
函数中会进行一个string
的操做,这样的操做就会形成没必要要的内存垃圾:
public Text timerText; private float timer; void Update() { timer += Time.deltaTime; timerText.text = "Time:"\+ timer.ToString(); }
经过将字符串进行分隔,咱们能够剔除字符串的加操做,从而减小没必要要的内存垃圾:
public Text timerHeaderText; public Text timerValueText; private float timer; void Start() { timerHeaderText.text = "TIME:"; } void Update() { timerValueText.text = timer.ToString(); }
在代码编程中,当咱们调用不是咱们本身编写的代码,不管是Unity自带的仍是插件中的,咱们均可能会产生内存垃圾。Unity的某些函数调用会产生内存垃圾,咱们在使用的时候须要注意它的使用。
这儿没有明确的列表指出哪些函数须要注意,每一个函数在不一样的状况下有不一样的使用,因此最好仔细地分析游戏,定位内存垃圾的产生缘由以及如何解决问题。有时候缓存是一种有效的办法,有时候尽可能下降函数的调用频率是一种办法,有时候用其余函数来重构代码是一种办法。如今来分析Unity中常见的形成堆内存分配的函数调用。
在Unity中若是函数须要返回一个数组,则一个新的数组会被分配出来用做结果返回,这不容易被注意到,特别是若是该函数含有迭代器,下面的代码中对于每一个迭代器都会产生一个新的数组:
void ExampleFunction() { for(int i=0; i < myMesh.normals.Length;i++) { Vector3 normal = myMesh.normals[i]; } }
对于这样的问题,咱们能够缓存一个数组的引用,这样只须要分配一个数组就能够实现相同的功能,从而减小内存垃圾的产生:
void ExampleFunction() { Vector3[] meshNormals = myMesh.normals; for(int i=0; i < meshNormals.Length;i++) { Vector3 normal = meshNormals[i]; } }
此外另外的一个函数调用GameObject.name 或者 GameObject.tag也会形成预想不到的堆内存分配,这两个函数都会将结果存为新的字符串返回,这就会形成没必要要的内存垃圾,对结果进行缓存是一种有效的办法,可是在Unity中都对应的有相关的函数来替代。对于比较gameObject的tag,能够采用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
,Unity中许多其余的函数也能够避免内存垃圾的生成。好比咱们能够用Input.GetTouch()
和Input.touchCount
来代替Input.touches
,或者用Physics.SphereCastNonAlloc()
来代替Physics.SphereCastAll()
。
装箱操做是指一个值类型变量被用做引用类型变量时候的内部变换过程,若是咱们向带有对象类型参数的函数传入值类型,这就会触发装箱操做。好比String.Format()函数须要传入字符串和对象类型参数,若是传入字符串和int类型数据,就会触发装箱操做。以下面代码所示:
void ExampleFunction() { int cost = 5; string displayString = String.Format("Price:{0} gold",cost); }
在Unity的装箱操做中,对于值类型会在堆内存上分配一个System.Object类型的引用来封装该值类型变量,其对应的缓存就会产生内存垃圾。装箱操做是很是广泛的一种产生内存垃圾的行为,即便代码中没有直接的对变量进行装箱操做,在插件或者其余的函数中也有可能会产生。最好的解决办法是尽量的避免或者移除形成装箱操做的代码。
调用 StartCoroutine()会产生少许的内存垃圾,由于Unity会生成实体来管理协程。因此在游戏的关键时刻应该限制该函数的调用。基于此,任何在游戏关键时刻调用的协程都须要特别的注意,特别是包含延迟回调的协程。
yield在协程中不会产生堆内存分配,可是若是yield带有参数返回,则会形成没必要要的内存垃圾,例如:
yield return 0;
因为须要返回0,引起了装箱操做,因此会产生内存垃圾。这种状况下,为了不内存垃圾,咱们能够这样返回:
yield return null;
另一种对协程的错误使用是每次返回的时候都new同一个变量,例如:
while(!isComplete) { yield return new WaitForSeconds(1f); }
咱们能够采用缓存来避免这样的内存垃圾产生:
WaitForSeconds delay = new WaiForSeconds(1f); while(!isComplete) { yield return delay; }
若是游戏中的协程产生了内存垃圾,咱们能够考虑用其余的方式来替代协程。重构代码对于游戏而言十分复杂,可是对于协程而言咱们也能够注意一些常见的操做,好比若是用协程来管理时间,最好在update函数中保持对时间的记录。若是用协程来控制游戏中事件的发生顺序,最好对于不一样事件之间有必定的信息通讯的方式。对于协程而言没有适合各类状况的方法,只有根据具体的代码来选择最好的解决办法。
在Unity5.5之前的版本中,在foreach的迭代中都会生成内存垃圾,主要来自于其后的装箱操做。每次在foreach迭代的时候,都会在堆内存上生产一个System.Object用来实现迭代循环操做。在Unity5.5中解决了这个问题,好比,在Unity5.5之前的版本中,用foreach实现循环:
void ExampleFunction(List listOfInts) { foreach(int currentInt in listOfInts) { DoSomething(currentInt); } }
若是游戏工程不能升级到5.5以上,则能够用for或者while循环来解决这个问题,因此能够改成:
void ExampleFunction(List listOfInts) { for(int i=0; i < listOfInts.Count; i++) { int currentInt = listOfInts\[i\]; DoSomething(currentInt); } }
函数的引用,不管是指向匿名函数仍是显式函数,在Unity中都是引用类型变量,这都会在堆内存上进行分配。匿名函数的调用完成后都会增长内存的使用和堆内存的分配。具体函数的引用和终止都取决于操做平台和编译器设置,可是若是想减小GC最好减小函数的引用。
因为LINQ和常量表达式以装箱的方式实现,因此在使用的时候最好进行性能测试。
即便咱们减少了代码在堆内存上的分配操做,代码也会增长GC的工做量。最多见的增长GC工做量的方式是让其检查它没必要检查的对象。struct是值类型的变量,可是若是struct中包含有引用类型的变量,那么GC就必须检测整个struct。若是这样的操做不少,那么GC的工做量就大大增长。在下面的例子中struct包含一个string,那么整个struct都必须在GC中被检查:
public struct ItemData { public string name; public int cost; public Vector3 position; } private ItemData[] itemData;
咱们能够将该struct拆分为多个数组的形式,从而减少GC的工做量:
private string[] itemNames; private int[] itemCosts; private Vector3[] itemPositions;
另一种在代码中增长GC工做量的方式是保存没必要要的Object引用,在进行GC操做的时候会对堆内存上的object引用进行检查,越少的引用就意味着越少的检查工做量。在下面的例子中,当前的对话框中包含一个对下一个对话框引用,这就使得GC的时候会去检查下一个对象框:
public class DialogData { private DialogData nextDialog; public DialogData GetNextDialog() { return nextDialog; } }
经过重构代码,咱们能够返回下一个对话框实体的标记,而不是对话框实体自己,这样就没有多余的object引用,从而减小GC的工做量:
public class DialogData { private int nextDialogID; public int GetNextDialogID() { return nextDialogID; } }
固然这个例子自己并不重要,可是若是咱们的游戏中包含大量的含有对其余Object引用的object,咱们能够考虑经过重构代码来减小GC的工做量。
若是咱们知道堆内存在被分配后并无被使用,咱们但愿能够主动地调用GC操做,或者在GC操做并不影响游戏体验的时候(例如场景切换的时候),咱们能够主动的调用GC操做:
System.GC.Collect();
经过主动的调用,咱们能够主动驱使GC操做来回收堆内存。
经过本文对于Unity中的GC有了必定的了解,对于GC对于游戏性能的影响以及如何解决都有必定的了解。经过定位形成GC问题的代码以及代码重构咱们能够更有效的管理游戏的内存。