学习ILRuntime时反复看到GC Alloc消耗的强调,对GC Alloc属于⼀直知道但没有概念,就延伸学习了下,以下内容转载⾄:
https://www.cnblogs.com/zblade/p/6445578.html
。
介绍
在游戏运⾏的时候,数据主要存储在内存中,当游戏的数据在不需要的时候,存储当前数据的内存就可以被回收以再次使⽤。内存垃圾是指当前废弃数据所占⽤的内存,垃圾回收(GC)是指将废弃的内存重新回收再次使⽤的过程。
Unity中将垃圾回收当作内存管理的⼀部分,如果游戏中废弃数据占⽤内存较⼤,则游戏的性能会受到极⼤影响,此时垃圾回收会成为游戏性能的⼀⼤障碍点。
本⽂我们主要学习垃圾回收的机制,垃圾回收如何被触发以及如何提升GC效率来提⾼游戏的性能。
Unity内存管理机制简介
要想了解垃圾回收如何⼯作以及何时被触发,我们⾸先需要了解Unity的内存管理机制。Unity
主要采⽤⾃动内存管理的机制,开发时在代码中不需要详细地告诉Unity如何进⾏内存管理,Unity
内部⾃⾝会进⾏内存管理。这和使⽤C++开发需要随时管理内存相⽐,有⼀定的优势,当然带来的劣势就是需要随时关注内存的增⻓,不要让游戏在⼿机上跑“⻜”了。
Unity的⾃动内存管理可以理解为以下⼏个部分:
-
Unity内部有两个内存管理池:堆内存和堆栈内存。堆栈内存(stack)主要⽤来存储较⼩的和短暂的数据,堆内存(heap)主要⽤来存储较⼤的和存储时间较⻓的数据。
-
Unity中的变量只会在堆栈或者堆内存上进⾏内存分配,变量要么存储在堆栈内存上,要么处于堆内存上。
-
只要变量处于激活状态,则其占⽤的内存会被标记为使⽤状态,则该部分的内存处于被分配的状态。
-
⼀旦变量不再激活,则其所占⽤的内存不再需要,该部分内存可以被回收到内存池中被再次使⽤,这样的操作就是内存回收。处于堆栈上的内存回收及其快速,处于堆上的内存并不是及时回收的,此时其对应的内存依然会被标记为使⽤状态。
-
垃圾回收主要是指堆上的内存分配和回收,Unity中会定时对堆内存进⾏GC操作。
在了解了GC的过程后,下⾯详细了解堆内存和堆栈内存的分配和回收机制的差别。
堆栈内存分配和回收机制
堆栈上的内存分配和回收⼗分快捷简单,因为堆栈上只会存储短暂的或者较⼩的变量。内存分配和回收都会以⼀种顺序和⼤⼩可控制的形式进⾏。
堆栈的运⾏⽅式就像stack: 其本质只是⼀个数据的集合,数据的进出都以⼀种固定的⽅式运⾏。正是这种简洁性和固定性使得堆栈的操作⼗分快捷。当数据被存储在堆栈上的时候,只需要简单地在其后进⾏扩展。当数据失效的时候,只需要将其从堆栈上移除。
堆内存分配和回收机制
堆内存上的内存分配和存储相对⽽⾔更加复杂,主要是堆内存上可以存储短期较⼩的数据,也可以存储各种类型和⼤⼩的数据。其上的内存分配和回收顺序并不可控,可能会要求分配不同⼤⼩的内存单元来存储数据。
堆上的变量在存储的时候,主要分为以下⼏步:
-
⾸先,Unity检测是否有⾜够的闲置内存单元⽤来存储数据,如果有,则分配对应⼤⼩的内存单元;
-
如果没有⾜够的存储单元,Unity会触发垃圾回收来释放不再被使⽤的堆内存。这步操作是⼀步缓慢的操作,如果垃圾回收后有⾜够⼤⼩的内存单元,则进⾏内存分配。
-
如果垃圾回收后并没有⾜够的内存单元,则Unity会扩展堆内存的⼤⼩,这步操作会很缓慢,然后分配对应⼤⼩的内存单元给变量。
堆内存的分配有可能会变得⼗分缓慢,特别是在需要垃圾回收和堆内存需要扩展的情况下,通常需要减少这样的操作次数。
垃圾回收时的操作
当堆内存上⼀个变量不再处于激活状态的时候,其所占⽤的内存并不会⽴刻被回收,不再使⽤的内存只会在GC的时候才会被回收。
每次运⾏GC的时候,主要进⾏下⾯的操作:
-
GC会检查堆内存上的每个存储变量;
-
对每个变量会检测其引⽤是否处于激活状态;
-
如果变量的引⽤不再处于激活状态,则会被标记为可回收;
-
被标记的变量会被移除,其所占有的内存会被回收到堆内存上。
GC操作是⼀个极其耗费的操作,堆内存上的变量或者引⽤越多,则其运⾏的操作会更多,耗费的时间越⻓。
何时会触发垃圾回收
主要有三个操作会触发垃圾回收:
-
在堆内存上进⾏内存分配操作⽽内存不够的时候都会触发垃圾回收来利⽤闲置的内存;
-
GC会⾃动的触发,不同平台运⾏频率不⼀样;
-
GC可以被强制执⾏。
特别是在堆内存上进⾏内存分配时内存单元不⾜够的时候,GC会被频繁触发,这就意味着频繁在堆内存上进⾏内存分配和回收会触发频繁的GC操作。
GC操作带来的问题
在了解GC在Unity内存管理中的作⽤后,我们需要考虑其带来的问题。最明显的问题是GC操作会需要⼤量的时间来运⾏,如果堆内存上有⼤量的变量或者引⽤需要检查,则检查的操作会⼗分缓慢,这就会使得游戏运⾏缓慢。其次GC可能会在关键时候运⾏,例如在CPU处于游戏的性能运⾏关键时刻,此时任何⼀个额外的操作都可能会带来极⼤的影响,使得游戏帧率下降。
另外⼀个GC带来的问题是堆内存的碎⽚化。当⼀个内存单元从堆内存上分配出来,其⼤⼩取决于其存储的变量的⼤⼩。当该内存被回收到堆内存上的时候,有可能使得堆内存被分割成碎⽚化的单元。也就是说堆内存总体可以使⽤的内存单元较⼤,但是单独的内存单元较⼩,在下次内存分配的时候不能找到合适⼤⼩的存储单元,这也会触发GC操作或者堆内存扩展操作。
堆内存碎⽚会造成两个结果,⼀个是游戏占⽤的内存会越来越⼤,⼀个是GC会更加频繁地被触发。
分析GC带来的问题
GC操作带来的问题主要表现为帧率运⾏低,性能间歇中断或者降低。如果游戏有这样的表现, 则⾸先需要打开Unity中的Profiler Window来确定是否是GC造成。
了解如何运⽤Profiler Window,可以参考
此处
,如果游戏确实是由GC造成的,可以继续阅读下⾯的内容。
分析堆内存的分配
如果GC造成游戏的性能问题,我们需要知道游戏中的哪部分代码会造成GC,内存垃圾在变量不再激活的时候产⽣,所以⾸先我们需要知道堆内存上分配的是什么变量。
堆内存和堆栈内存分配的变量类型
在Unity中,值类型变量都在堆栈上进⾏内存分配,其他类型的变量都在堆内存上分配。如果你不知道值类型和引⽤类型的差别,可以查看
此处
。
下⾯的代码可以⽤来理解值类型的分配和释放,其对应的变量在函数调⽤完后会⽴即回收:
void ExampleFunciton()
{
int localInt = 5;
}
对应的引⽤类型的参考代码如下,其对应的变量在GC的时候才回收:
void ExampleFunction()
{
List localList = new List();
}
利⽤Profiler Window 来检测堆内存分配:
我们可以在Profier Window中检查堆内存的分配操作:在CPU Usage分析窗⼝中,我们可以检测任何⼀帧Cpu的内存分配情况。其中⼀个选项是GC Alloc,通过分析其来定位是什么函数造成⼤量的堆内存分配操作。⼀旦定位该函数,我们就可以分析解决其造成问题的原因从⽽减少内存垃圾的产⽣。现在Unity5.5的版本,还提供了Deep Profiler的⽅式深度分析GC垃圾的产⽣。
降低GC的影响的⽅法
⼤体上来说,我们可以通过三种⽅法来降低GC的影响:
-
减少GC的运⾏次数;
-
减少单次GC的运⾏时间;
-
将GC的运⾏时间延迟,避免在关键时候触发,⽐如可以在场景加载的时候调⽤GC;
似乎看起来很简单,基于此,我们可以采⽤三种策略:
-
对游戏进⾏重构,减少堆内存的分配和引⽤的分配。更少的变量和引⽤会减少GC操作中的检测个数从⽽提⾼GC的运⾏效率。
-
降低堆内存分配和回收的频率,尤其是在关键时刻。也就是说更少的事件触发GC操作,同时也降低堆内存的碎⽚化。
-
我们可以试着测量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。对象池技术可以通过重复使⽤对象来降低堆内存的分配和回收频率。对象池在游戏中⼴泛的使⽤,特别是在游戏中需要频繁的创建和销毁相同的游戏对象的时候,例如枪的⼦弹这种会频繁⽣成和销毁的对象。
要详细的讲解对象池已经超出本⽂的范围,但是该技术值得我们深⼊的研究
https://unity3d.com/cn/learn/tutorials/topics/scripting/object-pooling
对于对象池有详细深⼊的讲解。
附:对象池技术属于游戏中⽐较通⽤的技术,如果有闲余时间,⼤家可以学习⼀下这⽅⾯的知识。
造成不必要的堆内存分配的因素
我们已经知道值类型变量在堆栈上分配,其他的变量在堆内存上分配,但是仍然有⼀些情况下的堆内存分配会让我们感到吃惊。下⾯让我们分析⼀些常⻅的不必要的堆内存分配⾏为并对其进⾏优化。
字符串
在C#中,字符串是引⽤类型变量⽽不是值类型变量,即使看起来它是存储字符串的值的。这就意味着字符串会造成⼀定的内存垃圾,由于代码中经常使⽤字符串,所以我们需要对其格外⼩⼼。
C#中的字符串是不可变更的,也就是说其内部的值在创建后是不可被变更的。每次在对字符串进⾏操作的时候(例如运⽤字符串的“加”操作),Unity会新建⼀个字符串⽤来存储新的字符串,
使得旧的字符串被废弃,这样就会造成内存垃圾。
我们可以采⽤以下的⼀些⽅法来最⼩化字符串的影响:
- 减少不必要的字符串的创建,如果⼀个字符串被多次利⽤,我们可以创建并缓存该字符串。
- 减少不必要的字符串操作,例如如果在Text组件中,有⼀部分字符串需要经常改变,但是其他部分不会,则我们可以将其分为两个部分的组件,对于不变的部分就设置为类似常量字符串即可,⻅下⾯的例⼦。
- 如果我们需要实时的创建字符串,我们可以采⽤StringBuilderClass来代替,StringBuilder专为不需要进⾏内存分配⽽设计,从⽽减少字符串产⽣的内存垃圾。
- 移除游戏中的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中常⻅的造成堆内存分配的函数调⽤。
在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;
另外⼀种对协程的错误使⽤是每次返回的时候都new同⼀个变量,例如:
while(!isComplete)
{
yield return new WaitForSeconds(1f);
}
我们可以采⽤缓存来避免这样的内存垃圾产⽣:
WaitForSeconds delay = new WaiForSeconds(1f);
while(!isComplete)
{
yield return delay;
}
如果游戏中的协程产⽣了内存垃圾,我们可以考虑⽤其他的⽅式来替代协程。重构代码对于游戏⽽⾔⼗分复杂,但是对于协程⽽⾔我们也可以注意⼀些常⻅的操作,⽐如如果⽤协程来管理时间,最好在Update函数中保持对时间的记录。如果⽤协程来控制游戏中事件的发⽣顺序,最好对于不同事件之间有⼀定的信息通信的⽅式。对于协程⽽⾔没有适合各种情况的⽅法,只有根据具体的代码来选择最好的解决办法。
foreach 循环
在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和常量表达式
由于LINQ和常量表达式以装箱的⽅式实现,所以在使⽤的时候最好进⾏性能测试。
重构代码来减⼩GC的影响
即使我们减⼩了代码在堆内存上的分配操作,代码也会增加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操作,或者在GC操作并不影响游戏体验的时候(例如场景切换的时候),我们可以主动的调⽤GC操作:
System.GC.Collect();
通过主动的调⽤,我们可以主动驱使GC操作来回收堆内存。
总结
通过本⽂对于Unity中的GC有了⼀定的了解,对于GC对于游戏性能的影响以及如何解决都有⼀定的了解。通过定位造成GC问题的代码以及代码重构我们可以更有效的管理游戏的内存。