關於這個話題,網絡上討論的很多,我也收集了一些資料,都不是很齊全,所以自己親自測試,這里把結果分享給大家。
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的情況,如下圖:
這里確實是在產生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())
{
}
}
原本以為,這個結果與上面的方式應該相同。不過結果出乎意料。
它並沒產生任何的新內存。於是,我准備使用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)
{
}
}
結果是沒有產生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_intArray)
{
}
}
結果是這里也在產生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())
{
}
}
結果是這里也在產生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
|
4、 | 盡可能避免使用ArrayList,對它的遍歷操作均會產生新的GC Alloc。 |
版權聲明:本文為博主原創文章,歡迎轉載。請保留博主鏈接:http://blog.csdn.net/andrewfan