Unity GC优化学习(二):Unity内存管理

发表于2018-06-07
评论0 2.5k浏览

想免费获取内部独家PPT资料库?观看行业大牛直播?点击加入腾讯游戏学院游戏程序行业精英群

711501594
Unity内存管理机制简介:

Unity主要采用自动内存管理的机制,开发时在代码中不需要详细地告诉unity如何进行内存管理,unity内部自身会进行内存管理,其自动管理主要可以理解为以下几个部分:
(1)unity内部会有两个内存管理池:堆内存和堆栈内存。堆栈内存(stack)主要用来存储较小的和短暂的数据,堆内存(heap)主要用来存储较大的和存储时间较长的数据。
(2)unity中的变量只会在堆栈或者堆内存上进行内存分配
(3)只要变量处于激活状态,则其占用的内存会被标记为使用状态,则该部分的内存处于被分配的状态
(4)一旦变量不再激活,则其所占用的内存不再需要,该部分内存可以被回收到内存池中再次被使用,这样的操作就是内存回收。处于堆栈上的内存回收速度极其快速,处于堆上的内存并不是及时回收的,因此其对应的内存依然会被标记为使用状态。
(5)垃圾回收主要是指堆上的内存分配和回收,unity中会定时堆堆内存进行GC操作

堆栈内存分配和回收机制
堆栈上的内存分配和回收十分快捷简单,因为堆栈上只会存储短暂的或者较小的变量。内存分配和回收都会一种顺序和大小都可控制的形式进行。
堆栈的运行方式就像数据结构的stack一样,本着先进后出的原则

堆内存的分配和回收机制
(1)首先,unity检测是否有足够的闲置内存单元用来存储数据,如果有,则分配对应大小的内存单元;
(2)如果没有足够的内存单元,unity会触发来及回收来释放不再被使用的堆内存。这步操作是一步缓慢的操作,如果垃圾回收后有足够大小的内存单元则进行内存分配;
(3)如果垃圾回收后并没有足够的内存单元,则unity会扩展堆内存的大小,这种操作会很缓慢,然后分配对应大小的内存单元给变量;
堆内存的分配有可能变得十分缓慢,特别是在需要垃圾回收和堆内存需要扩展的情况下,通常需要减少这样的操作次数

垃圾回收时的操作

当堆内存上一个变量不再处于激活状态的时候,其所占用的内存并不会立刻被回收,不再使用的内存之后在GC的时候才会被回收。
每次运行GC的时候,主要进行下面的操作:
(1)GC会检查堆内存上的每个存储变量;
(2)对每个变量会检测其引用是否处于激活状态;
(3)如果变量的引用不再处于激活状态,则会被标记为可回收;
(4)被标记的变量会被移除,其所占有的内存会被回收给堆内存。

何时会触发垃圾回收
主要有三个操作会触发垃圾回收:
(1)在堆内存上进行内存分配操作而内存不够的时候;
(2)GC会自动的触发,不同的平台运行频率不一样;
(3)GC可以被强制执行。
特别是在堆内存上进行内存分配时内存单元不足的时候,GC会被频繁触发,这就意味着频繁在堆内存上进行内存分配和回收会触发频繁的GC操作。

GC操作带来的问题

在了解GC在unity内存管理中的作用后,我们需要考虑其带来的问题。最明显的问题是GC操作会需要大量的时间来进行,如果堆内存上有大量的变量或者引用需要检查,则检查的操作会十分缓慢,这就会使得游戏运行缓慢。其次GC可能会在关键的时候运行,例如在CPU处于游戏的性能运行关键时刻,此时任何一个额外的操作都可能带来极大的影响,使得游戏帧率下降。

另外一个GC带来的问题就是堆内存的碎片化。当一个内存单元从堆内存上分配出来,其大小取决于其存储的变量的大小。该内存被回收到堆内存上的时候,有可能使得堆内存被分割成碎片化的单元。也就是说堆内存的总体可以使用的内存单元较大,但是单独的内存单元较小,在下次内存分配的时候就不能找到合适大小的存储单元,这样也会触发GC操作或者堆内存扩展操作。

【就好比一个没有秩序的空地,大家的车子随意停放,后来的车子无处停放,但是如果按照停车场一样按照车位顺序规则停放,其实还可以继续停放很多车子,甚至是很大的车子。】

堆内存碎片会造成两个结果,一个是游戏占用的内存会越来越大,一个是GC会更加地频繁地触发。

分析GC带来的问题

GC操作带来的问题主要表现为帧率运行较低,性能间歇终端或者降低。如果游戏有这样的表现,则首先需要打开unity中的profiler window来确定是否是GC造成的

分析堆内存分配

如果GC造成游戏性能问题,我们需要知道游戏中那部分代码造成了GC,内存垃圾在变量不再激活的时候产生,所以首先我们需要知道堆内存上分配的是什么变量

下面的代码可以用来理解值类型的分配和释放,其对应的变量在函数调用玩后立即回收:
void ExampleFunc()
{
    int localInt =5;
}

对应的引用类型的参考代码如下,其对应的变量在GC的时候才会被回收:
void ExampleFunc()
{
    List localList = new List();
}

降低GC的影响的方法
(1)减少GC运行的次数;
(2)减少单次GC的运行时间;
(3)将GC的运行时间延迟,避免在关键时刻触发,如在加载场景是调用GC

似乎看起来很简单,基于此,我们可以采用三种策略
(1)对游戏进行重构,减少堆内存的分配和引用的分配。
    更少的变量和引用会减少GC操作中的检测个数
    从而提高GC运行的效率
(2)降低堆内存分配和回收的频率,尤其是在关键时刻。
    也就是说更少的事件触发GC操作,同时也降低堆内存的碎片化
(3)我们可以试着测量GC和堆内存扩展的时间,使其按照可预测的顺序执行。
    当然这样操作的难度极大,但是这会大大降低GC的影响

减少内存垃圾的数量

1.通过缓存来减少内存垃圾

如果在代码中反复调用某些造成堆内存分配的函数但是其返回结果并没有被使用,这样就会造成不必要的内存垃圾,我们可以缓存这些变量来重复利用

例如下面的代码每次调用的时候就会造成堆内存的分配,主要是每次都会分配一个新的数组:
void OnTriggerEnter(Collider other)
{
    Renderer[] allRenderers = FindObjectsOfType<Renderer>();
    ExampleFunction(allRenderers);
}

比如下面的代码,只会产生一个数组来缓存数据,实现反复利用而不需要造成更多的内存垃圾:
private Renderer[] allRenderers;
void Start()
{
    allRenderers = FindObjectsOfType<Renderer>();
}
void OnTriggerEnter(Collider other)
{
    ExampleFunction(allRenderers);
}

2.不要在频繁调用的函数中反复进行堆内存分配

在MonoBehavior中,如果我们在Update()和 LateUpdate()这类每帧都需要调用的函数中进行堆内存分配,就会造成大量的内存垃圾。我们可以考虑在Start()或Awake()函数中进行内存分配,这样就可以减少内存垃圾。
另一种方法是在update中采用计时器
private float timeSinceLastCalled;
private float interval;
void Update()
{
    timeSinceLastCalled +=Time.deltaTime;
    if(timeSinceLastCalled>interval)
    {
        ExampleGarbageGenerationFunction();
        timeSinceLastCalled = 0f;
    }
}
通过这样细小的改变,我们就可以使得代码运行的更快同时减少内存垃圾的产生。

【注意:不要忽略这个方法,很多地方都可以使用这个手段进行性能优化,比如AI的执行,可以让复杂的AI判断每隔0.5-1.0秒执行一次,而不是每帧都要执行一次!】

3.清除链表

在堆内存上进行链表分配的时候,如果该链表需要多次反复的分配,我们可以采用链表的clear方法来清空链表,从而代替反复多次的创建分配链表。
void Update()
{
    List myList = new List();
    PopulateList(myList);
}

通过改进,我们可以将该链表只在第一次创建或者该链表必须重新设置的时候才进行内存分配,从而大大减少内存垃圾的产生
private List myList = new List();
void Update()
{
    myList.Clear();
    PopulateList(myList);
}

4.借助对象池技术手段

造成不必要的堆内存分配的因素

我们已经知道值类型变量在堆栈上分配,其它变量在堆内存上分配,但是仍然有一些情况下的堆内存分配会让我们感到吃惊!下面让我们分析一些常见的不必要的堆内存分配行为并对其进行优化。

1.字符串

在C#中,字符串是引用类型变量而不是值类型变量,即使看起来它是存储字符串的值得。这就意味着字符串会造成一定的内存垃圾,由于代码中经常使用字符串,所以我们需要对其格外小心。

在C#中字符串是不可变更的,也就是说其内部的值在创建后是不可变更的。每次在对字符串进行操作的时候(例如运用字符串的“加”操作),unity会创建一个新的字符串用来存储新的字符串,使得旧的字符串被废弃,这样就会造成内存垃圾。

我们可以采用以下的一些方法来最小化字符串的影响:
(1)极少不必要的字符串创建,如果一个字符串被多次利用,可以考虑缓存该字符串。
(2)减少不必要的字符串操作,例如在Text组件中,有一部分字符串需要经常改变,而其他部分不会,则我们可以将其分为两个部分的组件,对于不变的部分就设置为类似常量字符串即可。
(3)如果我们需要实时的创建字符串,我们可以采用StringBuilder类来代替。
(4)移除游戏中的Debug.Log()函数的代码,尽管该函数可能输出为空,但该函数的代码仍然会被执行,该函数会创建至少一个字符(空字符)的字符串。

在下面的代码中,在Update函数中会进行一个string的操作,这样的操作机会造成不必要的内存垃圾:
public Text timerTxt;
private float timer;
void Update()
{
    timer +=Time.deltaTime;
    timerTxt.text = "Time:" + timer.ToString();
}

通过将字符串进行分隔,我们可以剔除字符串加操作,从而减少不必要的内存垃圾:
public Text timerHeaderTxt;
public Text timerValueTxt;
private float timer;
void Start()
{
    timerHeaderText.text = "TIME:";
}
void Update()
{
    timerValueTxt.text = timer.ToString();
}

2.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.Compare(playerTag);
}
不只是GameObject.CompareTag,unity中许多其他函数也可以避免内存垃圾的生成,比如我们可以用Input.GetTouch() 和 Input.touchCount()来代替Input.touches。

3.装箱操作

装箱操作是指一个值类型变量被用作引用类型变量时候的内部变换过程,如果我们向带有对象类型参数的函数传入值类型,这就会触发装箱操作。
比如Sring.Format()函数需要传入字符串和对象类型参数,如果传入int类型数据,机会触发装箱操作。如下面代码所示:
void ExampleFunction()
{
    int cost = 5;
    string displayString = Srting.Format("Price:{0} glod",cost);
}

在Unity的装箱操作中,对于值类型会在堆内存上分配一个System.Object类型的引用来封装该值类型变量,其对应的缓存就会产生内存垃圾。装箱操作是非常普遍的一种产生内存垃圾的行为,即使代码中没有直接的对变量进行装箱操作,在插件或者其他的函数中也有可能会产生。最好的解决办法是尽可能的避免或者移除造成装箱操作的代码。

4.协程

调用StartCoroutine()会产生少量的内存垃圾,因为unity会生成实体来管理协程。所以在游戏的关键时刻应该闲置该函数的调用。基于此,任何在游戏关键时刻调用的协程都需要特别的注意,特别是包含延迟回调的协程。

yield在协程中不产生内存分配,但是如果yield带有参数返回,则会造成不必要的内存垃圾,例如:
yield return 0;

由于需要返回0,引发了装箱操作,所以会产生内存垃圾。这种情况下,为了避免内存垃圾,我们可以这样返回:
yield return null;

另外一种对协程的错误使用是每次返回的时候都new同一变量,例如:
while(!complete)
{
    yield return new WaitForSeconds(1f);
}

我们可以采用缓存来避免这样的内存垃圾产生:
WaitForSeconds delay = new WaitForSeconds(1f);
while(!insComplete)
{
    yield return delay;
}

如果游戏中的协程产生了内存垃圾,我们可以考虑其它方式来代替协程。重构代码对于游戏而言十分复杂,但是对于协程而言我们也可以注意一些常见的操作,比如如果用协程来管理实践,最好在update函数中保持对时间的记录。如果用协程来控制游戏中事件的发生顺序,最好对不同时间之间有一定的信息通信方式。对于携程而言没有合适各种请款单个方法,只有根据具体的代码来选择最好的解决办法。

5.foreach循环

在unity5.5以前的版本中,在foreach的迭代中都会生成内存垃圾,主要来自其后的装箱操作。每次在foreach迭代的时候,都会在堆内存上产生一个System.Object用来实现循环操作。
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 currentIn = listOfInts[i];
        DoSometing(currentInt);
    }
}

6.函数引用

函数的引用,无论是指向匿名函数还是显式函数,在unity中都是引用类型变量,这都会在堆内存上进行分配。

【匿名函数的调用完成后都会增加内存的使用和堆内存的分配。】
具体函数的引用和中指都取决于操作平台和编译器设置,但是如果想减少GC最好减少函数的引用。

7.LINQ和常量表达式

由于LINQ和常量表达式以装箱的方式实现,所以在使用的时候最好进行性能测试。

8.重构代码来减少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的工作量。

9.定时执行GC操作

主动调用GC操作
如果我们知道堆内存在被分配后并没有被使用,我们希望可以主动地调用GC操作,或者在GC操作并不影响游戏体验的时候(例如游戏场景切换的时候),我们可以主动的调用GC操作:
System.GC.Collect()
来自:https://blog.csdn.net/qq_26276097/article/details/75675818

原文链接

著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

如社区发表内容存在侵权行为,您可以点击这里查看侵权投诉指引

游戏学院公众号二维码
腾讯游戏学院
微信公众号

提供更专业的游戏知识学习平台