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


關於這個話題,網絡上討論的很多,我也收集了一些資料,都不是很齊全,所以自己親自測試,這里把結果分享給大家。

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 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 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 這種泛型格式。當遍歷它們時,我們不要使用foreach,而應該改用GetEnumerator。
4、 盡可能避免使用ArrayList,對它的遍歷操作均會產生新的GC Alloc。

版權聲明:本文為博主原創文章,歡迎轉載。請保留博主鏈接:http://blog.csdn.net/andrewfan


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM