05. 理解托管堆【下】

这是摘自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。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,670评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,928评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,926评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,238评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,112评论 4 356
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,138评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,545评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,232评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,496评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,596评论 2 310
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,369评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,226评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,600评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,906评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,185评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,516评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,721评论 2 335

推荐阅读更多精彩内容