美文网首页Unity游戏开发入门UnityUnity标准编程导引
【Unity优化】Unity中究竟能不能使用foreach?

【Unity优化】Unity中究竟能不能使用foreach?

作者: AndrewFan | 来源:发表于2017-03-04 13:37 被阅读558次

    关于这个话题,网络上讨论的很多,我也收集了一些资料,都不是很齐全,所以自己亲自测试,这里把结果分享给大家。

    foreach究竟怎么了?

    研究过这个问题的人都应该知道,就是它会引起频繁的GC Alloc。也就是说,使用它之后,尤其在Update方法中频繁调用时,会快速产生小块垃圾内存,造成垃圾回收操作的提前到来,造成游戏间歇性的卡顿。
    问题大家都知道,也都给出了建议,就是尽可能不要用。在start方法里倒无所谓,因为毕竟它只执行一次。Update方法一秒钟执行大概50-60次,这里就不要使用了。这个观点整体上是正确的,因为这样做毕竟避开了问题。
    不过有一点点不是很方便的就是,foreach确实带来了很多便捷性的编码。尤其是结合了var之后,那么我们究竟还能不能使用它,能使用的话,应该注意哪些问题?带着这些问题,我做了以下的测试。

    重现GC Alloc问题

    首先,我写了一个简单的脚本来重现这个问题。
    这个类中包括一个int数组,一个泛型参数为int的List。
    代码如下:

    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的情况,如下图:

    IntList foreach

    这里确实是在产生GC Alloc,每帧产生39.1KB的新内存。我使用的Unity版本是64位的5.4.3f1,可能不同的版本产生的内存大小有些差别,但是产生新内存是不可避免的。

    应用于IntList的GetEnumerator

    接下来,我又做了另外一种尝试,就是用对等的方式写出同样的代码。将测试代码部分改成如下:

            for (int i = 0; i < 1000; i++)
            {
                var iNum = m_intList.GetEnumerator();
                while (iNum.MoveNext())
                {
                }
            }
    

    原本以为,这个结果与上面的方式应该相同。不过结果出乎意料。

    IntList GetEnumerator

    它并没产生任何的新内存。于是,我准备使用IL反编译器来了解它的GCAlloc是如何产生的。
    我们知道,List是动态数组,是可以随时增长、删减的,而int[]这种形式,在C#里面被编译成Array的子类去执行。为了有更多的对比,我将foreach和GetEmulator也写一份同样的代码,应用于Int数组和ArrayList,先查看运行的结果,然后一起查看他们的IL代码。

    应用于IntArray的foreach

            for (int i = 0; i < 1000; i++)
            {
                foreach (var iNum in m_intArray)
                {
                }
            }
    
    IntArray Foreach

    结果是没有产生GC Alloc。

    应用于IntArray的GetEnumerator

            for (int i = 0; i < 1000; i++)
            {
                var iNum = m_intArray.GetEnumerator();
                while (iNum.MoveNext())
                {
                }
            }
    
    IntArray GetEnumerator

    结果是这里也在产生GC Alloc,每帧产生31.3KB的新内存。

    应用于ArrayList的foreach

            for (int i = 0; i < 1000; i++)
            {
                foreach (var iNum in m_intArray)
                {
                }
            }
    
    ArrayList ForeachArrayList Foreach

    结果是这里也在产生GC Alloc,每帧产生23.4KB的新内存(在32位版Unity5.3.4f1测试)。

    应用于ArrayList的GetEnumerator

            for (int i = 0; i < 1000; i++)
            {
                var iNum = m_intArray.GetEnumerator();
                while (iNum.MoveNext())
                {
                }
            }
    
    ArrayList GetEnumeratorArrayList GetEnumerator

    结果是这里也在产生GC Alloc,每帧产生23.4KB的新内存(在32位版Unity5.3.4f1测试)。

    GC Alloc产生情况小结

    小结 int[] (Array) List< int > ArrayList
    foreach 不产生 产生 产生
    GetEnumerator 产生 不产生 产生

    探索原因

    我们知道GC Alloc就是产生了新的堆内存,C#中也就意味着产生了新的对象。因此,在上面的表中,应该是意味着,只有对Array应用foreach的情况,和对泛型List应用GetEnumerator的情况下,过程中不会产生新GC Alloc,其它情况均有产生新的GC Alloc。

    接下来,我找来ILSpy,将工程目录下的:

    Library\ScriptAssemblies\Assembly-CSharp.dll
    

    文件拖入其中,并且找到Unity安装目录下的:

    Unity\Editor\Data\Mono\lib\mono\2.0\mscorlib.dll
    

    也将其拖入ILSpy。(如果你使用不同的.net版本打包,则可以选择相匹配的库来看)

    testIntArrayForeach

    .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 指令,如果出现newobj 指令,如果跟随值类型,说明它在栈上新建对象,它不会产生GCAlloc;如果后面参数跟随对象类型,则说明它在堆上新建对象,会产生GC Alloc
    • callvirt 指令,它表示函数调用,后方会跟随某个类的某个函数,被调用的函数中也可能会产生GC Alloc
    • box指令,装箱,将值类型封装成指定的对象类型,流程是,弹出计算堆栈上的值类型参数,并使用新建立的一个引用类型对象进行并包装,将包装结果返回计算堆栈。本过程产生GC Alloc。

    更具体的指令解释可以参见我的另外一篇博客《我所理解的IL指令》

    在上面常常的代码中,没有出现这三个指令,那么也就是说,这方法没有产生新的内存,符合之前的UnityProfiler中的结果。

    testIntArrayGetEmulator

    .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
    
    

    虽然这个代码里面也没有newobj 字段,但是含有调用其它函数的字段callvirt instance class [mscorlib]System.Collections.IEnumerator [mscorlib]System.Array::GetEnumerator(),我们翻查这个函数调用,代码如下:

    .method public final hidebysig newslot virtual 
        instance class System.Collections.IEnumerator GetEnumerator () cil managed 
    {
        // Method begins at RVA 0xffd8
        // Code size 7 (0x7)
        .maxstack 8
    
        IL_0000: ldarg.0
        IL_0001: newobj instance void System.Array/SimpleEnumerator::.ctor(class System.Array)
        IL_0006: ret
    } // end of method Array::GetEnumerator
    
    
    

    果然是出现了newobj 字段,且跟随对象类型System.Array/SimpleEnumerator,新的GC Alloc由此产生。

    testIntListForeach

    .method private hidebysig 
        instance void testIntListForeach () cil managed 
    {
        // Method begins at RVA 0x2dfc
        // Code size 77 (0x4d)
        .maxstack 11
        .locals init (
            [0] int32,
            [1] int32,
            [2] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
        )
    
        IL_0000: ldc.i4.0
        IL_0001: stloc.0
        IL_0002: br IL_0041
        // loop start (head: IL_0041)
            IL_0007: ldarg.0
            IL_0008: ldfld class [mscorlib]System.Collections.Generic.List`1<int32> ForeachTest::m_intList
            IL_000d: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0> class [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()
            IL_0012: stloc.2
            .try
            {
                IL_0013: br IL_0020
                // loop start (head: IL_0020)
                    IL_0018: ldloca.s 2
                    IL_001a: call instance !0 valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::get_Current()
                    IL_001f: stloc.1
    
                    IL_0020: ldloca.s 2
                    IL_0022: call instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()
                    IL_0027: brtrue IL_0018
                // end loop
    
                IL_002c: leave IL_003d
            } // end .try
            finally
            {
                IL_0031: ldloc.2
                IL_0032: box valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
                IL_0037: callvirt instance void [mscorlib]System.IDisposable::Dispose()
                IL_003c: endfinally
            } // end handler
    
            IL_003d: ldloc.0
            IL_003e: ldc.i4.1
            IL_003f: add
            IL_0040: stloc.0
    
            IL_0041: ldloc.0
            IL_0042: ldc.i4 1000
            IL_0047: blt IL_0007
        // end loop
    
        IL_004c: ret
    } // end of method ForeachTest::testIntListForeach
    
    
    

    同样的,这里虽然没有出现newobj 字段,却出现了:

    callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0> class [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()
    

    也就是调用了List的GetEnumerator()方法。我们翻查此方法如下:

    .method public hidebysig instance valuetype System.Collections.Generic.List`1/Enumerator<!T> GetEnumerator () cil managed 
    {
        // 方法起始 RVA 地址 0xe4928
        // 方法起始地址(相对于文件绝对值:0xe2b28)
        // 代码长度 7 (0x7)
        .maxstack 8
    
        // 0xE2B29: 02
        IL_0000: ldarg.0
        // 0xE2B2A: 73 4B 01 00 0A
        IL_0001: newobj instance void valuetype System.Collections.Generic.List`1/Enumerator<!T>::.ctor(class System.Collections.Generic.List`1<!0>)
        // 0xE2B2F: 2A
        IL_0006: ret
    } // 方法 List`1::GetEnumerator 结束
    
    

    这里同样也出现了newobj指令,但是应用于值类型:

    System.Collections.Generic.List`1/Enumerator
    

    所以,这这函数调用指令也不会产生GCAlloc。
    那么GCAlloc是在哪里产生的,回过头去,我们再检查上面的代码,发现在finally代码块中,有:

    IL_0032: box valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
    

    这样一句话。它调用了box指令,尽管它box的是值类型,但此时值类型对象依然会被放至堆上,GC Alloc在由此产生。

    testIntListGetEmulator

    .method private hidebysig 
        instance void testIntListGetEmulator () cil managed 
    {
        // 方法起始 RVA 地址 0x28e0
        // 方法起始地址(相对于文件绝对值:0x0ae0)
        // 代码长度 52 (0x34)
        .maxstack 7
        .locals init (
            [0] int32,
            [1] valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>
        )
    
        // 0x0AEC: 16
        IL_0000: ldc.i4.0
        // 0x0AED: 0A
        IL_0001: stloc.0
        // 0x0AEE: 38 21 00 00 00
        IL_0002: br IL_0028
        // 循环开始 (head: IL_0028)
            // 0x0AF3: 02
            IL_0007: ldarg.0
            // 0x0AF4: 7B 1A 00 00 04
            IL_0008: ldfld class [mscorlib]System.Collections.Generic.List`1<int32> ForeachTest::m_intList
            // 0x0AF9: 6F 53 00 00 0A
            IL_000d: callvirt instance valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<!0> class [mscorlib]System.Collections.Generic.List`1<int32>::GetEnumerator()
            // 0x0AFE: 0B
            IL_0012: stloc.1
            // 0x0AFF: 38 00 00 00 00
            IL_0013: br IL_0018
            // 循环开始 (head: IL_0018)
                // 0x0B04: 12 01
                IL_0018: ldloca.s 1
                // 0x0B06: 28 55 00 00 0A
                IL_001a: call instance bool valuetype [mscorlib]System.Collections.Generic.List`1/Enumerator<int32>::MoveNext()
                // 0x0B0B: 3A F4 FF FF FF
                IL_001f: brtrue IL_0018
            // 循环结束
    
            // 0x0B10: 06
            IL_0024: ldloc.0
            // 0x0B11: 17
            IL_0025: ldc.i4.1
            // 0x0B12: 58
            IL_0026: add
            // 0x0B13: 0A
            IL_0027: stloc.0
    
            // 0x0B14: 06
            IL_0028: ldloc.0
            // 0x0B15: 20 E8 03 00 00
            IL_0029: ldc.i4 1000
            // 0x0B1A: 3F D4 FF FF FF
            IL_002e: blt IL_0007
        // 循环结束
    
        // 0x0B1F: 2A
        IL_0033: ret
    } // 方法 ForeachTest::testIntListGetEmulator 结束
    
    

    这里没有newobj和box指令,而callvirt 调用的函数如前所述,是含有一个newobj指令,但是应用于值类型:

    System.Collections.Generic.List`1/Enumerator
    

    所以,这这函数调用指令也不会产生GCAlloc。所以整个函数没有GCAlloc,符合预期结果。

    foreach和GetEnumerator 使用总结

    我们再回过头看一下这个表格:

    小结 int[] (Array) List< int > ArrayList
    foreach 不产生 产生 产生
    GetEnumerator 产生 不产生 产生

    现在我们已经知道:

    • Array中的Enumerator是对象类型,这是intArray调用GetEnumerator产生GCAlloc的原因。
    • 泛型List中的Enumerator是值类型,所以它不会产生GCAlloc。而foreach应用于List时,由于增加了一个box装箱操作,所以产生了GCAlloc。
    • 那么我们就得出最终的如下结论:
    1、 如果能使用数组,就直接使用数组,对它直接使用foreach不产生GC Alloc。
    2、 尽可能不要使用数组的GetEnumerator 方法,会产生新GC Alloc。
    3、 当我们需要动态数组时,最好使用List<T>这种泛型格式。当遍历它们时,我们不要使用foreach,而应该改用GetEnumerator。
    4、 尽可能避免使用ArrayList,对它的遍历操作均会产生新的GC Alloc。

    相关文章

      网友评论

      • 8e0151695779:当List<T>中的泛型为引用类型时,GetEnumerator仍是会产生GC Alloc的。
      • young路在脚下:写的很好呀,让我们知其然也知所以然,探究钻研精神值得我学习
        AndrewFan:@young路在脚下 谢谢

      本文标题:【Unity优化】Unity中究竟能不能使用foreach?

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