05. 理解托管堆【下】

作者: Wenchao | 来源:发表于2017-09-02 16:16 被阅读142次

    这是摘自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,推荐如下三点:

    • 尽量选择不使用将方法作为参数进行传递的代码风格
    • 当不可避免的时候,尽量选择匿名方法而不是定义好的方法
    • 避免闭包,不管是否使用了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>是某些类或者结构的名称,...是一些变量名称。

    • <some class>::Box(...)
    • Box(...)
    • <some class>_Box(...)

    也可以通过反编译代码或者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。

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

    相关文章

      网友评论

        本文标题:05. 理解托管堆【下】

        本文链接:https://www.haomeiwen.com/subject/zrlrjxtx.html