GC优化策略-下篇

Posted 何文西

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了GC优化策略-下篇相关的知识,希望对你有一定的参考价值。

原文:http://www.cnblogs.com/zblade/

英文:英文地址

 

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

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

  字符串  

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

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

  我们可以采用以下的一些方法来最小化字符串的影响:

  1)减少不必要的字符串的创建,如果一个字符串被多次利用,我们可以创建并缓存该字符串。

  2)减少不必要的字符串操作,例如如果在Text组件中,有一部分字符串需要经常改变,但是其他部分不会,则我们可以将其分为两个部分的组件,对于不变的部分就设置为类似常量字符串即可,见下面的例子。

  3)如果我们需要实时的创建字符串,我们可以采用StringBuilderClass来代替,StringBuilder专为不需要进行内存分配而设计,从而减少字符串产生的内存垃圾。

  4)移除游戏中的Debug.Log()函数的代码,尽管该函数可能输出为空,对该函数的调用依然会执行,该函数会创建至少一个字符(空字符)的字符串。如果游戏中有大量的该函数的调用,这会造成内存垃圾的增加。

  在下面的代码中,在Update函数中会进行一个string的操作,这样的操作就会造成不必要的内存垃圾:

1
2
3
4
5
6
7
public Text timerText;
private float timer;
void Update()
{
    timer += Time.deltaTime;
    timerText.text = "Time:"+ timer.ToString();
}

  通过将字符串进行分隔,我们可以剔除字符串的加操作,从而减少不必要的内存垃圾:

1
2
3
4
5
6
7
8
9
10
11
12
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];
    }
}

  对于这样的问题,我们可以缓存一个数组的引用,这样只需要分配一个数组就可以实现相同的功能,从而减少内存垃圾的产生:

1
2
3
4
5
6
7
8
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就会产生内存垃圾:

1
2
3
4
5
private string playerTag="Player";
void OnTriggerEnter(Collider other)
{
    bool isPlayer = other.gameObject.tag == playerTag;
}

  采用GameObject.CompareTag()可以避免内存垃圾的产生:

1
2
3
4
5
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类型数据,就会触发装箱操作。如下面代码所示:

1
2
3
4
5
void ExampleFunction()
{
    int cost = 5;
    string displayString = String.Format("Price:{0} gold",cost);
}

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

  协程

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

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

1
yield return 0;

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

1
yield return null;

  另外一种对协程的错误使用是每次返回的时候都new同一个变量,例如:

1
2
3
4
while(!isComplete)
{
    yield return new WaitForSeconds(1f);
}

  我们可以采用缓存来避免这样的内存垃圾产生:

1
2
3
4
5
WaitForSeconds delay = new WaiForSeconds(1f);
while(!isComplete)
{
    yield return delay;
}

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

  foreach 循环

  在unity5.5以前的版本中,在foreach的迭代中都会生成内存垃圾,主要来自于其后的装箱操作。每次在foreach迭代的时候,都会在堆内存上生产一个System.Object用来实现迭代循环操作。在unity5.5中解决了这个问题,比如,在unity5.5以前的版本中,用foreach实现循环:

1
2
3
4
5
6
7
void ExampleFunction(List listOfInts)
{
    foreach(int currentInt in listOfInts)
    {
        DoSomething(currentInt);
    }
}

  如果游戏工程不能升级到5.5以上,则可以用for或者while循环来解决这个问题,所以可以改为:

1
2
3
4
5
6
7
8
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中被检查:

1
2
3
4
5
6
7
public struct ItemData
{
    public string name;
    public int cost;
    public Vector3 position;
}
private ItemData[] itemData;

  我们可以将该struct拆分为多个数组的形式,从而减小GC的工作量:

1
2
3
private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;

  另外一种在代码中增加GC工作量的方式是保存不必要的Object引用,在进行GC操作的时候会对堆内存上的object引用进行检查,越少的引用就意味着越少的检查工作量。在下面的例子中,当前的对话框中包含一个对下一个对话框引用,这就使得GC的时候会去检查下一个对象框:

1
2
3
4
5
6
7
8
9
public class DialogData
{
     private DialogData nextDialog;
     public DialogData GetNextDialog()
     {
           return nextDialog;
                     
     }
}

  通过重构代码,我们可以返回下一个对话框实体的标记,而不是对话框实体本身,这样就没有多余的object引用,从而减少GC的工作量:

1
2
3
4
5
6
7
8
public class DialogData
{
    private int nextDialogID;
    public int GetNextDialogID()
    {
       return nextDialogID;
    }
}

  当然这个例子本身并不重要,但是如果我们的游戏中包含大量的含有对其他Object引用的object,我们可以考虑通过重构代码来减少GC的工作量。 

定时执行GC操作

  主动调用GC操作

   如果我们知道堆内存在被分配后并没有被使用,我们希望可以主动地调用GC操作,或者在GC操作并不影响游戏体验的时候(例如场景切换的时候),我们可以主动的调用GC操作:

1
System.GC.Collect()

  通过主动的调用,我们可以主动驱使GC操作来回收堆内存。

总结

  通过本文对于unity中的GC有了一定的了解,对于GC对于游戏性能的影响以及如何解决都有一定的了解。通过定位造成GC问题的代码以及代码重构我们可以更有效的管理游戏的内存。

  接着我会继续写一些Unity相关的文章。翻译的工作,在后面有机会继续进行。

以上是关于GC优化策略-下篇的主要内容,如果未能解决你的问题,请参考以下文章

Unity2019 增量式GC(使用时间片段执行GC,减少卡顿)

Unity2019 增量式GC(使用时间片段执行GC,减少卡顿)

从golang的垃圾回收说起(下篇)

45.JVM调优策略常见问题:内存泄漏(年老代堆空间被占满持久代被占满堆栈溢出线程堆栈满系统内存被占满)优化方法:优化目标优化GC步骤优化总结;案例分析(公司系统参数网上给的配置参数)

面向面试编程代码片段之GC

HBase最佳实践-CMS GC调优(从gc本身参数调优)