C#征服Unity3dUnity技术分享

05. 理解托管堆【下】

2017-09-02  本文已影响142人  Wenchao

这是摘自Unity官方文档有关优化的部分,原文链接:https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity.html
总共分为如下系列:

  1. 采样分析
  2. 内存部分
  3. 协程
  4. Asset审查
  5. 理解托管堆 【推荐阅读】
    5.1 上篇:原理,临时分配内存,集合和数组
    5.2 下篇:闭包,装箱,数组
  6. 字符串和文本
  7. 资源目录
  8. 通用的优化方案
  9. 一些特殊的优化方案

理解托管堆下篇,上篇请参见

闭包和匿名方法

当使用闭包和匿名方法的时候需要注意两点:

首先,C#中所有方法都是引用类型,都会在堆中进行分配。当把方法引用作为参数进行传递的时候,就会产生临时分配的内存。不管是在匿名方法或者定义好的方法,只要传递方法类型的参数,就会分配内存。

其次,如果将匿名方法转换成闭包,将闭包传递给接收的方法,这样会消耗更多的内存。

考虑如下代码:

List<float> listOfNumbers = createListOfRandomNumbers();

listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/2)) 

);

其次,如果将匿名方法转换成闭包,将闭包传递给接收的方法,这样会消耗更多的内存。

List<float> listOfNumbers = createListOfRandomNumbers();

int desiredDivisor = getDesiredDivisor();

listOfNumbers.Sort( (x, y) =>

(int)x.CompareTo((int)(y/desiredDivisor))

);

这样改动之后,匿名方法现在就要能够获取方法域之外的变量状态,这样就成了闭包。变量desiredDivisor需要传入到闭包之内才能被闭包中的代码使用。

为了达到这个目的,C#实现了一个匿名类,这样才能持有外部域的变量。当闭包传递给Sort方法的时候,这个匿名类的副本就会被创建,并且使用传入的desiredDivisor进行初始化操作。

因为执行闭包需要生成匿名类的副本,而且C#中的所有类都是引用类型,所以执行闭包需要在托管堆中分配额外的对象空间。

通常情况下,最好避免在C#中使用闭包。在对性能很敏感的代码里,匿名方法和方法引用应该最小化,尤其是需要每帧执行的基础性代码。

IL2CPP下的匿名方法

目前情况下,检查IL2CPP生成的代码可以发现对System.Function类型的声明和赋值会分配新对象。不论变量是显式声明(在方法中或者类中声明)或者隐式声明(作为参数传递给其他的方法)。

如此,IL2CPP脚本后端下使用匿名方法都会在托管堆中分配内存。在Mono下面则不会出现这种情况。

更进一步来讲,对于方法参数定义的类型不同,IL2CPP也会有不同等级的托管内存分配方案。比如闭包每次分配的内存非常耗费。

很不直观的是,即使方法提前定义好,在IL2CPP中作为参数传递的时候,分配的内存和闭包差不多。匿名方法会在堆上产生暂时的垃圾,按照大小排列。

因此,如果工程使用了IL2CPP,推荐如下三点:

装箱

Unity工程中最常见的临时内存分配在于装箱操作。当值类型对象需要作为引用类型对象被使用的的时候,装箱就不可避免,例如当把值类型变量(如int和float)作为参数传递给使用引用类型的方法的时候。

最简单的例子如下,当整型x传递给object.Equals方法的时候就需要被装箱,因为object的Equals方法要求传入的参数是object。

int x = 1;

object y = new object();

y.Equals(x);

C#的IDE和编译器通常不会对装箱操作发出警告,虽然装箱操作会产生没必要的内存分配。这是因为C#语言的开发者认为,对于分代式的垃圾回收器和对分配大小非常敏感的内存池而言,很小的临时分配没什么问题。

如何识别装箱

在CPU的日志中,装箱是对某些函数的调用,具体的函数则取决于使用的脚本后台。不过通常都是如下的形式,<some class>是某些类或者结构的名称,...是一些变量名称。

也可以通过反编译代码或者IL查看工具定位到。ReSharper内置的IL查看器和dotPeek反编译工具都可以查看。IL指令是“box”。

字典和枚举

引起装箱操作一个很常见的原因是将枚举类型作为字典的key。声明枚举会创建值类型对象,其实就是创建了会在编译过程中会确保类型安全的整型数据。

默认情况下,调用Dictionary.add(key, value)会导致对Object.getHashCode(object)的调用,后者是为字典中的key创建合适的哈希值,以便在Dictionary.tryGetValue和Dictionary.remove这些方法中使用。

Object.getHashCode的参数是引用类型,而枚举变量则是值类型数据。所以如果将枚举变量作为字典的key值的话,调用的每次方法都会至少产生一次装箱操作。

下面的代码片段展示了装箱问题的一个简单例子:

enum MyEnum { a, b, c };

var myDictionary = new Dictionary<MyEnum, object>();

myDictionary.Add(MyEnum.a, new object());

如果想要解决这个问题,很有必要写一个类实现IEqualityComparer接口的方法,并且将这个类的实例作为字典的比较方法。
【注意。这个对象通常没有主权,所以可以在多个字典实例中被反复使用来节省内存。】

下面的代码片段是针对上面的例子实现IEqualityComparer的改进版本:

public class MyEnumComparer : IEqualityComparer<MyEnum> {

    public bool Equals(MyEnum x, MyEnum y) {

        return x == y;

    }

    public int GetHashCode(MyEnum x) {

        return (int)x;

    }

}

上面类的某个实例可以作为比较器传入到字典的构造方法中。

foreach循环

在Unity中Mono C#的编译器,在处理foreach循环的时候在每次循环结束的时候,都会强制Unity对一个值类型对象进行装箱操作。【注意,只是在循环结束执行的时候才会执行装箱操作,而不是每次循环都会产生装箱操作。所以无论循环执行2次或者200次,消耗内存都是一样的】。这是因为Unity的C#编译器构建了一个值类型的枚举器用来对值集合进行迭代。

枚举器实现了IDisposable接口,当结束循环的时候一定会被调用。然而对于值类型的对象调用这个接口方法一定要求先对值类型(如结构或者枚举变量)进行装箱才行。

考虑如下的代码:

int accum = 0;

foreach(int x in myList) {

    accum += x;

}

上面的代码在Unity的C#编译器编译之后的IL代码如下:

.method private hidebysig instance void 
    ILForeach() cil managed 
  {
    .maxstack 8
    .locals init (
      [0] int32 num,
      [1] int32 current,
      [2] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32> V_2
    )
    // [67 5 - 67 16]
    IL_0000: ldc.i4.0     
    IL_0001: stloc.0      // num
    // [68 5 - 68 74]
    IL_0002: ldarg.0      // this
    IL_0003: ldfld        class [mscorlib]System.Collections.Generic.List`1<int32> test::myList
    IL_0008: callvirt     instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0/*int32*/> class [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()
    IL_000d: stloc.2      // V_2
    .try
    {
      IL_000e: br           IL_001f
    // [72 9 - 72 41]
      IL_0013: ldloca.s     V_2
      IL_0015: call         instance !0/*int32*/ valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()
      IL_001a: stloc.1      // current
    // [73 9 - 73 23]
      IL_001b: ldloc.0      // num
      IL_001c: ldloc.1      // current
      IL_001d: add          
      IL_001e: stloc.0      // num
    // [70 7 - 70 36]
      IL_001f: ldloca.s     V_2
      IL_0021: call         instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()
      IL_0026: brtrue       IL_0013
      IL_002b: leave        IL_003c
    } // end of .try
    finally
    {
      IL_0030: ldloc.2      // V_2
      IL_0031: box          valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
      IL_0036: callvirt     instance void [mscorlib]System.IDisposable::Dispose()
      IL_003b: endfinally   
    } // end of finally
    IL_003c: ret          
  } // end of method test::ILForeach
} // end of class test

关键部分的代码在于finally {...} 部分。callvirt指令找到IDisposable.Dispose方法的内存位置,在调用这个方法之前,进行了一次装箱box操作。

通常来讲,foreach方法应该尽可能避免在Unity中使用。不仅是因为装箱操作,还有通过枚举器对集合类进行迭代相比for或者while方法更慢。

注意Unity5.5版本之后的C#编译器做了绝大的提升,对IL代码的生成有了很大的优化。额外的装箱操作已经被移除了,减少了foreach循环的内存开销。但是CPU的方法调用消耗并没有得到改善。

基于数组(Array)的Unity API

另外一个对性能有害但是很少被发现的问题是反复调用返回数组的Unity API导致的内存分配。每次当这些API被调用的时候,都会创建新的数组。在不必要的时候,尽量减少对返回数组类型的Unity API的调用。

下面的代码在每次迭代的时候都会创建四个vertices的副本。当.vertices的属性被访问的时候,都会发生内存分配。

for(int i = 0; i < mesh.vertices.Length; i++)

{

    float x, y, z;

    x = mesh.vertices[i].x;

    y = mesh.vertices[i].y;

    z = mesh.vertices[i].z;

    // ...

    DoSomething(x, y, z);   

}

将对Mesh中的vertices属性的访问移动到循环之外进行访问,就可以减少内存分配:

var vertices = mesh.vertices;

for(int i = 0; i < vertices.Length; i++)

{

    float x, y, z;

    x = vertices[i].x;

    y = vertices[i].y;

    z = vertices[i].z;

    // ...

    DoSomething(x, y, z);   

}

尽管单次属性访问不会消耗很多CPU开销,但是循环中重复访问属性也会产生性能问题。而且,重复访问也会造成堆内存没必要的开销。

这个问题在移动设备上更常见,因为Input.touches API就是返回数组。在工程代码中经常会看到如下的代码片段,在循环内部每次都去获取.touches属性。

for ( int i = 0; i < Input.touches.Length; i++ )
{
   Touch touch = Input.touches[i];
    // …
}

同样道理,将对.touches的访问移动到循环体之外,性能能够得到改善。

Touch[] touches = Input.touches;
for ( int i = 0; i < touches.Length; i++ )
{
   Touch touch = touches[i];
   // …
}

现在更新的Unity提供了不会产生内存分配的API。

int touchCount = Input.touchCount;
for ( int i = 0; i < touchCount; i++ )
{
   Touch touch = Input.GetTouch(i);
   // …
}

上面的API的转化很容易完成:

上面的例子将.touchCount的访问也移动到了循环体之外是为了减少调用get方法引起的CPU消耗。

空数组重用

有一些开发组喜欢用空数组代替null值,当需要返回一个空对象的时候。这种代码风格在许多托管语言很常见,尤其是C#和Java。

通常来讲,当方法需要返回一个空数组的时候,可以考虑提前定义好一个空数组的单个实例,这样就可以避免重复创建空数组。【如果这个数组被返回,不能进行改变,如果被改变,应该抛出异常】

上一篇 下一篇

猜你喜欢

热点阅读