Unity 垃圾收集最佳实践

前言

最近在搞的一个 Unity 项目是以插件形式运行在服务端的,对内存碎片问题有比较高的要求,正好有机会深入学习了下 Unity 的 GC。这篇文章便是从 Unity 2019.4 官方文档 Garbage collection best practices 翻译、摘要而来。

正文

自动内存管理让你写起代码时又快又简单,还能减少出错。但是这种便利有可能带来性能影响。为了优化代码以提高性能,你必须避免频繁触发 GC 的情形。

情形一:临时分配

为每一帧在托管堆上分配临时数据。例如:

  • 如果每帧申请 1KB 的临时内存,每秒 60 帧,那么每秒会产生 60KB 的临时内存。每分钟就是 3.6MB 内存需要 GC。
  • 每秒调用一次 GC 就会对性能产生负面影响。但是如果每分钟仅运行一次 GC,就要一次性清除不连续的 3.6MB 内存,这会导致显著的 GC 时间。
  • 加载操作对性能有影响。如果在一个很重的资产加载操作期间生成大量临时对象,Unity 又一直引用它们直到操作完成,那么 GC 就不能及时释放这些临时对象。这意味着托管堆需要扩大——即使 Unity 会在短时间后释放大量对象。

你需要尝试尽可能地降低内存分配次数以避开此种情况:理想情况下是每帧分配 0 字节的临时对象,或者尽可能地接近于 0。

解法一:可复用的对象池

以子弹为例,没必要每次都从 prefab 实例化一个新的子弹对象。可以预先计算出屏幕上最多可显示的子弹数量,然后在首次进入场景时申请此数量的一组对象。

情形二:反复的字符串拼接

string 类在 C# 里是不可变引用类型。引用类型意味着 Unity 在托管堆上分配它们并且服从于 GC。不可变意味着 string 对象一旦创建就不能再被修改;任何视图修改字符串的行为都会产生一个新的字符串对象。所以你应该尽可能地避免创建临时字符串。

对大量的拼接应该使用 StringBuilder 类。

除非是拼接非常频繁,比如每一帧都会,一般的字符串拼接不会太多地降低性能。

情形三:返回数组的方法

解法三:复用集合和数组

复用 System.Collection 命名空间中的数组或类是很高效的。集合类暴露了一个 Clear 方法,清除集合元素但是不释放集合分配的内存。(即集合中元素的引用会被减 1,如果元素对象引用归 0 则被释放;但是集合本身的容量不变。跟 C++ 中 vector 行为一样。)

情形四:闭包和匿名方法

通常来说:在 C# 里你应该尽可能地避免闭包;你应该最小化在性能敏感代码里对匿名函数和函数引用的使用,特别是那些每一帧都会执行的代码。

方法引用是一种引用类型,在堆上分配。这意味着如果你把方法引用用作一个方法实参,很容易就创建出了临时分配。这种分配不管被传递的方法是匿名的还是具名的都会发生。

另外,当把匿名方法转换成闭包时,把闭包当做参数传递给方法所需的内存总量会增加很多。(大概意思就是闭包比匿名函数更占内存?)

为了能把值传递进闭包,C# 会生成一个持有闭包所需的外部作用域的变量的匿名类。当把闭包作为参数传递给方法时,会实例化这个匿名类。

情形五:装箱

应该避免装箱。

情形六:数组值的 Unity API

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Bad C# script example: this loop create 4 copies of the vertices array per iteration
void Update() {
for(int i = 0; i < mesh.vertices.Length; i++) { // mesh.vertices 会创建临时对象
float x, y, z;

x = mesh.vertices[i].x; // mesh.vertices[i] 会创建临时对象
y = mesh.vertices[i].y; // mesh.vertices[i] 会创建临时对象
z = mesh.vertices[i].z; // mesh.vertices[i] 会创建临时对象

// ...

DoSomething(x, y, z);
}
}

每次迭代创建 4 个临时对象。

后记

每秒调用一次 GC 就会对性能产生负面影响(Invoking the garbage collector once per second has a negative effect on performance.)。这结论对我太震撼了。

本来只想知道频繁 GC 对内存碎片化的影响大小,看来即便只考虑性能问题,该做的优化还是得做啊。

评论