zleisure 发表于 2020-11-26 15:21

理解Unity中的优化(五):托管堆

托管堆:
在开发过程中,我们总会遇到托管堆内存意外的增长的情况。在Unity中,托管堆的增长速度总是大于它收缩的速度。因此,Unity的GC(Garbage Collection)回收策略更趋向于内存片段的回收。


托管堆的工作原理:
“托管堆”是由项目的脚本运行时(Scripting Runtime)——Mono或者IL2CPP内存管理器管理的 一个内存片段。所有托管代码中被创建的对象必须被分配到托管堆上(提示:严格意义上说,所有不为空的引用类型对象和所有被封装的值类型对象必须被分配到托管堆上)。






上图中,白色部分是一部分的已经分配的托管堆,有颜色的部分代表内存空间上存储的数据。当创建新的对象时,堆上就会被分配更多的空间。
Unity的GC会周期性的执行(执行的周期与平台有关)。它会遍历堆上的所有对象,并且标记那些没有被引用的对象,然后删除它们,释放内存。
Unity的GC机制,使用了Boehm GC算法(可以参考:https://en.wikipedia.org/wiki/Boehm_garbage_collector),是非分代(non-generational)和非压缩(non-compacting)的。"非分代"是指GC执行清理操作时,必须遍历整个内存,并且随着内存的增长,它的性能就会降低。“非压缩”意味着内存中的对象不会被重新定位,去缩小对象之间的内存空隙。






上图中展示了一个内存分配的例子。当内存被释放时,内存是空的。然而,这部分未被分配的内存并没有与其他未分配的内存合并,它的两边的内存可能仍然在使用。因此,这部分未被分配的内存空间就成了内存片段中的“间隙(Gap)”(图中红色的圆圈表示了这个间隙)。因此,只有当被存储对象的大小小于或者等于被释放内存大小时,才能被存储。
当给对象分配内存时,记住对象总是占据了一块连续的内存。
这就导致了内存片段的核心问题:尽管堆中可用的空间总量可能是巨大的,但有可能很多或者所有的空间都位于已经分配对象之间的小“间隙”中。在这种情况下,尽管总共有足够大的空间来分配,但托管堆找不到足够大的连续空间来分配内存。






上图中,一个大的对象正在被分配,但是没有足够大的连续内存空间,这时,Unity内存管理器就会执行两个步骤:
第一步,启动垃圾收集器,释放足够大的空间来满足分配需要。
第二部,如果GC启动了,但任然没有足够的空间,托管堆就会扩张。堆扩张的大小是由平台决定的,但是Unity上的大多数平台都会让托管堆增长一倍。


托管堆的核心问题:
托管堆增长带来的主要问题:
当托管堆扩张的时候,为了尽量避免不被再次扩张,Unity没有经常释放堆上的内存页。大多数平台上,Unity最终会将托管堆的空部分返回给操作系统。发生这个的时间间隔是不确定的,所以我们不能依赖这种情况。被托管堆使用的地址空间永远不会返回给操作系统。对于32位程序来说,托管堆增长和缩小多次,会导致地址空间不够用。如果一个程序的可用地址空间用完了,那么操作系统将会结束这个程序。但对于64位程序来说,有足够大的地址空间,不会出现地址空间用完的情况。


临时分配:
许多Unity的项目每一帧都会操作堆上分配的几十或者几百kb的临时数据。这非常影响性能。参考一下以下数据:
如果一个程序每一帧分配1kb的临时内存,每秒运行60帧,那么它每秒会分配60kb的临时内存。一分钟内,就会在内存中增加3.6mb的垃圾。在低端设备上每分钟分配3.6mb很可能出现问题。而且,频繁的调用GC将会出现性能问题。
然后,来说loading操作。如果Asset-loading操作产生了大量的临时对象。并且在loading结束前,这些对象都有被引用。那么GC不能释放这些临时的对象,堆就会增长。






追踪内存分配的情况比较简单。在UnityCPU Profiler中,查看“GC Alloc”这一列。这一列展示了在这一帧中,托管堆的分配状况(提示:这一列的大小显示不是临时分配的内存大小。Profiler显示了这一帧中分配的字节大小,即使在下一帧有些被分配的内存会被再利用)。开启“Deep Profiling”选项,它能追踪是那些方法分配了内存。
不是在主线程上分配内存时,Unity Profiler是追踪不到的。因此用户自己创建的线程分配内存时,不会出现在"GC Alloc"这一列上。
当在Unity Editor下运行时,有一些脚本方法会需要分配内存。但是在build完之后,就不会有这个问题。比如GetComponent就是一个例子。
通常来说,在游戏中,在一个与玩家交互性比较强的时候, 我们需要最小化分配堆内存。在交互性比较弱的时候,就可以分配。比如说在加载场景的时候。
Unity推荐了一个能在代码中定位内存分配的插件:Jetbrains Resharper Plugin,笔者没用过这个插件,这里不展开。地址:
可以使用Unity的Deep Profile来定位造成内存分配的一些特殊情况。在Deep Profile模式下,所有方法的调用都会被记录,可以清晰的看到方法调用过程中的内存分配情况。Deep Profile模式不仅在Editor模式下能使用,同时也能在Android环境下用-deepprofiling命令也是能够使用的。


内存保护:
有一些比较简单的技术可以用来减少托管堆分配。
数组和集合的重复使用:
当使用c#集合类或者数组时,需要考虑尽可能的重用或者池化集合或者数组。c#集合类有暴露一个Clear方法,这个方法会清除集合里面的值,但不会释放集合分配的内存。






给复杂的运算分配临时的“Helper”集合,是非常有用的。下面是一个比较简单的例子。
在例子中,nearestNeighbors列表每帧都会被分配一次。简单的做法就是把nearestNeighbors从Update方法中提取出来,避免每一帧都分配内存。






这里,m_NearestNeighbors列表就不会每帧被创建。只有当它需要扩张时,才会分配更多的内存。


闭包和匿名方法:
当使用闭包和匿名方法时,有两点需要注意一下:
首先,在C#中所有方法的引用都是引用类型,都会被分配到堆中。把一个方法作为参数传递时,都会产生临时的内存分配,不管传递的是匿名方法还是已经定义的方法。
其次,将匿名方法转换为闭包会显著地增加传递闭包所需的内存。
例如以下代码:






上面的一段代码使用了一个匿名方法来控制列表的排序。然后,如果开发者想要重用这段代码,就需要把常量“2”替换成变量,比如:






这个匿名方法现在需要访问方法以外的变量,因此,已经成为了一个闭包。desiredDivisor变量被传递到了闭包中,以便在闭包中被使用。
为了做到这点,C#会生成一个匿名类来保存闭包中用到的外部变量。当闭包传递给Sort方法时,这个匿名类会被实例化一个副本,当desiredDivisor变量被赋值时,实例化的副本类也就被初始化。
因为执行闭包需要实例化它生成的类的副本,并且C#中所有的类都是引用类型,所以,执行闭包的时候就会在托管堆上给对象分配内存。
通常,在C#中最好避免出现闭包。在一些对性能要求高的功能中,应最小化的使用匿名方法和方法引用,尤其是在一些每一帧都需要执行的代码里。


IL2CPP中的匿名方法:
目前,在查看用IL2CPP生成的代码的时候,发现在给System.Function类型的变量声明和赋值的时候,都会分配新的对象。不管变量是显式的(在类或方法里声明)还是隐式的(在另一个方法中作为参数声明)。
同样,如果在IL2CPP编译的代码中,使用任何匿名方法都会分配到托管内存(managed memory)中。
此外,IL2CPP会根据方法参数的声明方式的不同,来分配不同级别的托管内存。如果有闭包,就会分配最多的内存。
在IL2CPP下,当一个已经声明的方法作为参数传递时,给它分配的内存跟闭包是一样多的。在堆上给匿名方法分配的临时内存是最少的。
因此,如果我们需要用IL2CPP来编译时,请记住以下三点:
编写代码时,尽量不要把方法作为参数传递。当不可避免时,匿名方法比定义方法来的更好。尽量避免闭包。


装箱:
在Unity项目中,装箱是最常见的一种意外地分配临时内存的情况。当一个值类型被用作引用类型时,它就会发生。常见的就是,当我们把一个值类型(int或者float)赋值给object类型的时候。
下面是装箱的一个例子,Equals方法需要传递一个object参数,我们把整型变量x传递过去,就会发生装箱。






C# IDE和编译器通常不会提醒装箱,尽管有时它会导致意想不到的内存问题。这是因为C#语言是在临时分配较小的内存会被分代垃圾收集器和分配大小的内存池有效处理的前提下,被开发出来的。
虽然Unity的分配器确实使用了不同的内存池来进行不同大小的分配。但是Unity的垃圾收集器不是分代的,因此不能有效的清理由装箱产生的一些小的,频繁的临时内存垃圾。
总之,在我们写C#代码的时候,最好避免装箱操作。


识别装箱:
在CPU性能追踪中,装箱操作就是一个或一些方法的调用,这取决于脚本的编译方式(mono或者IL2CPP)。通常包含以下结构:
<some class>::Box(…)Box(…)<some class>_Box(…)
<some class>是类或者结构体的名称,...表示参数。这个也能在反编译器或者IL viewer中能被找到。


字典和枚举:
还有一个会引起装箱操作的,就是把枚举当做key值赋给字典。枚举时值类型,我们使用一个枚举时,其实跟使用一个int类型差不多。但是,在编译的时候会遵守类型安全规则。
我们在调用Dictionary.add(key, value)方法时,默认会调用Object.getHashCode(Object)。这个方法被用来获取哈希码当做字典的key值,并在所有与key值相关的方法中都会用到。比如Dictionary.tryGetValue, Dictionary.remove等。
Object.getHashCode方法是引用类型,但是枚举值是值类型。所以如果用枚举来当字典的key值,调用上述的每一个方法都会产生至少一次的装箱。
下面是装箱问题的一个示例:






为了解决这个问题,我们最好写一个实现IEqualityComparer 接口的类,来实现字典的比较操作。比如:






Foreach循环:
在Unity中的Mono C#编译器中,使用foreach循环会在循环结束的时候都会发生装箱操作(整个循环结束时,才发生一次装箱操作,而不是循环中的每一次迭代。所以循环执行2次和执行200次装箱消耗的内存是一样的)。这是因为Unity的C#编译器生成的中间语言构造了一个通用的值类型Enumerator来遍历字典。
这个Enumerator实现了IDisposable接口,这个接口在循环结束的时候就会被调用。然后,值类型对象(比如结构体和Enumerator)调用这个接口就需要被装箱。
比如以下代码:






上述代码,通过C# 编译器,会生成以下中间语言:










注意最下面的finally{...}部分。在callvirt方法执行之前,Enumerator会被封装,然后callvirt指令再调用IDisposable.Dispose。
通常来说,foreach循环在Unity中应该尽量避免。不仅因为装箱,还有就是通过Enumerator遍历集合通常要比for和while要慢的多。
在Unity5.5版本及以后,Unity又花了C#编译器生成IL的过程,foreach循环中不会再出现装箱。这消除了foreach的内存开销。但是由于方法调用的开销,foreach的CPU性能任然要低于基于下标遍历的代码。


Unity API中的数组返回值:
另一个需要我们引起注意的是重复访问Unity api,造成重复分配数组。Unity中所有的api如果返回一个数组,那么在被使用时,都会拷贝一个新的数组。所以我们在开发过程中,在没必要的情况下,尽量不要访问返回数组的API。
举一个例子,下面的代码会在每次循环中创建四份vertices数组。






为了避免出现这种情况,我们需要在循环外保存vertices数组:






获取一次vertices数组对CPU的影响不会很大。但是在循环中重复获取,就可以产生性能问题。这么做也很好的避免了托管堆的扩张。
在移动平台上,这是一个非常普遍的问题,因为Input.touches API跟上面的例子一样。在很多项目中,一下代码是非常常见的:






同样,我们需要在循环外访问touches属性:






然而,现在也有许多Unity API不会造成内存分配,我们就可以放心的来用它们。比如:






注意,上面的例子中,我们仍然需要把Input.touchCount放在循环外面,以减少访问touchCount属性的次数。


重复使用空数组:
当我们在一个需要返回数组的方法中, 我们喜欢返回一个空数组,而不是null。这种做法在C#和JAVA中是比较常见的。
通常来说,当我们返回一个数组的时候,返回预先定义的零长度的数组要比重新创建一个空数组要高效的多。




本文内容来自Unity官方文档:


作者水平有限,如有错误请多加指正。
页: [1]
查看完整版本: 理解Unity中的优化(五):托管堆