【Unity优化】Unity中究竟能不能使用foreach?
网络上关于“Unity中能否使用foreach”这一话题讨论众多,但相关资料并不齐全。因此,我亲自进行了测试,并将结果分享给大家。
一、foreach引发的问题
研究过此问题的人都清楚,foreach会引发频繁的GC Alloc(垃圾回收分配)。当在代码里使用foreach,尤其是在Update方法中频繁调用时,会快速产生小块垃圾内存,进而导致垃圾回收操作提前触发,造成游戏间歇性卡顿。
大家普遍知晓这个问题,并建议尽可能避免使用foreach。在Start方法中使用倒也无妨,因为该方法仅执行一次;而Update方法每秒大约执行50 - 60次,不建议在此使用。总体而言,这种观点是正确的,因为这样能避开问题。
不过,foreach确实为编码带来了诸多便利,特别是与var结合使用时。那么,我们究竟能否使用它呢?若可以,又该注意哪些问题?带着这些疑问,我开展了以下测试。
二、重现GC Alloc问题
首先,我编写了一个简单的脚本来重现该问题。这个类包含一个int数组、一个泛型参数为int的List以及一个ArrayList。代码如下:
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class ForeachTest : MonoBehaviour {
int[] m_intArray;
List<int> m_intList;
ArrayList m_arryList;
public void Start () {
m_intArray = new int[2];
m_intList = new List<int>();
m_arryList = new ArrayList();
for (int i = 0; i < m_intArray.Length; i++) {
m_intArray[i] = i;
m_intList.Add(i);
m_arryList.Add(i);
}
}
void Update () {
testIntListForeach();
}
void testIntListForeach() {
for (int i = 0; i < 1000; i++) {
foreach (var iNum in m_intList) {
}
}
}
}
(一)应用于IntList的foreach
首先来看应用于泛型List的情况。测试结果显示,这里确实产生了GC Alloc,每帧产生39.1KB的新内存。我使用的是64位的Unity 5.4.3f1版本,不同版本产生的内存大小可能有所差异,但产生新内存是不可避免的。
(二)应用于IntList的GetEnumerator
接着,我尝试用对等方式编写同样的代码,将测试代码部分修改如下:
for (int i = 0; i < 1000; i++) {
var iNum = m_intList.GetEnumerator();
while (iNum.MoveNext()) {
}
}
原本以为结果会与使用foreach的方式相同,但结果出乎意料,这种方式并未产生任何新内存。于是,我打算使用IL反编译器来探究其GC Alloc的产生机制。
我们知道,List是动态数组,可以随时增长、删减;而int[]在C#中会被编译成Array的子类执行。为了进行更多对比,我将foreach和GetEnumerator的代码分别应用于int数组和ArrayList,先查看运行结果,再一同查看它们的IL代码。
(三)应用于IntArray的foreach
for (int i = 0; i < 1000; i++) {
foreach (var iNum in m_intArray) {
}
}
结果显示,此情况未产生GC Alloc。
(四)应用于IntArray的GetEnumerator
for (int i = 0; i < 1000; i++) {
var iNum = m_intArray.GetEnumerator();
while (iNum.MoveNext()) {
}
}
结果表明,这里产生了GC Alloc,每帧产生31.3KB的新内存。
(五)应用于ArrayList的foreach
for (int i = 0; i < 1000; i++) {
foreach (var iNum in m_arryList) {
}
}
结果显示,这里产生了GC Alloc,每帧产生23.4KB的新内存(在32位版Unity 5.3.4f1中测试)。
(六)应用于ArrayList的GetEnumerator
for (int i = 0; i < 1000; i++) {
var iNum = m_arryList.GetEnumerator();
while (iNum.MoveNext()) {
}
}
结果显示,这里同样产生了GC Alloc,每帧产生23.4KB的新内存(在32位版Unity 5.3.4f1中测试)。
(七)GC Alloc产生情况小结
| 数据类型 | foreach | GetEnumerator |
|---|---|---|
| int[] (Array) | 不产生 | 产生 |
| List< int > | 产生 | 不产生 |
| ArrayList | 产生 | 产生 |
三、探索原因
我们知道,GC Alloc意味着产生了新的堆内存,在C#中即产生了新的对象。从上述表格可以看出,只有对Array应用foreach,以及对泛型List应用GetEnumerator时,过程中不会产生新的GC Alloc,其他情况均会产生。
接下来,我使用ILSpy工具进行分析。将工程目录下的Library\\ScriptAssemblies\\Assembly-CSharp.dll文件拖入ILSpy,同时将Unity安装目录下的Unity\\Editor\\Data\\Mono\\lib\\mono\\2.0\\mscorlib.dll文件也拖入(若使用不同的.net版本打包,可选择相匹配的库)。
(一)testIntArrayForeach的IL代码分析
.method private hidebysig
instance void testIntArrayForeach () cil managed
{
// Method begins at RVA 0x2eb4
// Code size 54 (0x36)
.maxstack 3
.locals init (
[0] int32,
[1] int32,
[2] int32[],
[3] int32
)
IL_0000: ldc.i4.0
IL_0001: stloc.0
IL_0002: br IL_002a
// loop start (head: IL_002a)
IL_0007: ldarg.0
IL_0008: ldfld int32[] ForeachTest::m_intArray
IL_000d: stloc.2
IL_000e: ldc.i4.0
IL_000f: stloc.3
IL_0010: br IL_001d
// loop start (head: IL_001d)
IL_0015: ldloc.2
IL_0016: ldloc.3
IL_0017: ldelem.i4
IL_0018: stloc.1
IL_0019: ldloc.3
IL_001a: ldc.i4.1
IL_001b: add
IL_001c: stloc.3
IL_001d: ldloc.3
IL_001e: ldloc.2
IL_001f: ldlen
IL_0020: conv.i4
IL_0021: blt IL_0015
// end loop
IL_0026: ldloc.0
IL_0027: ldc.i4.1
IL_0028: add
IL_0029: stloc.0
IL_002a: ldloc.0
IL_002b: ldc.i4 1000
IL_0030: blt IL_0007
// end loop
IL_0035: ret
} // end of method ForeachTest::testIntArrayForeach
对于不熟悉IL的同学,无需完整理解这些代码,只需了解几个重要的IL字段即可:
newobj指令:若跟随值类型,表明在栈上新建对象,不会产生GC Alloc;若跟随对象类型,则表示在堆上新建对象,会产生GC Alloc。callvirt指令:表示函数调用,后方会跟随某个类的某个函数,被调用的函数中可能会产生GC Alloc。box指令:装箱操作,将值类型封装成指定的对象类型,此过程会产生GC Alloc。更详细的指令解释可参考我的另一篇博客《我所理解的IL指令》。
在上述代码中,未出现这三个指令,这意味着该方法未产生新的内存,与之前Unity Profiler中的结果相符。
(二)testIntArrayGetEmulator的IL代码分析
.method private hidebysig
instance void testIntArrayGetEmulator () cil managed
{
// Method begins at RVA 0x2ef8
// Code size 51 (0x33)
.maxstack 7
.locals init (
[0] int32,
[1] class [mscorlib]System.Collections.IEnumerator
)
IL_0000: ldc.i4.0
IL_0001: stloc.0
IL_0002: br IL_0027
// loop start (head: IL_0027)
IL_0007: ldarg.0
IL_0008: ldfld int32[] ForeachTest::m_intArray
IL_000d: callvirt instance class [mscorlib]System.Collections.IEnumerator [mscorlib]System.Array::GetEnumerator()
IL_0012: stloc.1
IL_0013: br IL_0018
// loop start (head: IL_0018)
IL_0018: ldloc.1
IL_0019: callvirt instance bool [mscorlib]System.Collections.IEnumerator::MoveNext()
IL_001e: brtrue IL_0018
// end loop
IL_0023: ldloc.0
IL_0024: ldc.i4.1
IL_0025: add
IL_0026: stloc.0
IL_0027: ldloc.0
IL_0028: ldc.i4 1000
IL_002d: blt IL_0007
// end loop
IL_0032: ret
} // end of method ForeachTest::testIntArrayGetEmulator