Unity开发者的C#内存管理(中篇)
第一篇介紹了在?.NET/Mono?和Unity里內(nèi)存管理的基礎(chǔ),并且提供了一些避免不必要的堆分配的建議。第三篇會(huì)深入到對(duì)象池。所有的都主要是面向中級(jí)的C#開(kāi)發(fā)者。
我們現(xiàn)在來(lái)看看兩種發(fā)現(xiàn)項(xiàng)目中不想要的堆分配的方法。第一種-Unity?profiler-實(shí)在是太簡(jiǎn)單了,但是卻相當(dāng)費(fèi)錢,得買’pro‘版的。第二種是講你的.NET/Mono程序集反匯編成中間語(yǔ)言(CIL)然后再檢查。如果你從沒(méi)見(jiàn)過(guò)反匯編的.NET代碼,繼續(xù)看下去,不難,而且免費(fèi)還很有啟發(fā)意義。
容易的方法:使用Unity?profiler
Unity優(yōu)秀的分析器主要被用來(lái)分析游戲中各種資源需要的性能和資源:著色器,紋理,音頻,游戲?qū)ο蟮鹊?。然而分析器在發(fā)掘內(nèi)存上也一樣有用-跟你的C#代碼的行為有關(guān)-甚至是外部的?沒(méi)引用UnityEngine.dll的.NET/Mono程序集!在當(dāng)前Unity版本中(4.3),這個(gè)功能不是來(lái)自內(nèi)存分析器,而是CPU分析器。到C#代碼的時(shí)候,內(nèi)存分析器只是展示Mono堆的總大小和已使用的量。
這樣讓你看你的C#代碼是否有嫩村泄露實(shí)在太粗糙了。即使不適用任何腳本,已使用的堆大小也會(huì)持續(xù)增長(zhǎng)和縮減。只要你使用腳本,你需要一個(gè)看哪里分配了內(nèi)存的途徑,然后CPU分析器剛好給你提供這個(gè)。
讓我們來(lái)看看一些實(shí)例代碼。假設(shè)下面的腳本綁定到了一個(gè)GameObject上。
?
using UnityEngine;using System.Collections.Generic; public class MemoryAllocatingScript : MonoBehaviour{ void Update() { List<int> iList = new List<int>(new int[] { 072, 101, 108, 108, 111, 032, 119, 111, 114, 108, 100, 033 }); string result = ""; foreach (int i in iList.ToArray()) result += ((char)i).ToString(); Debug.Log(result); }}?
它所做的就是通過(guò)一組整數(shù)用一種繞的方法創(chuàng)建了一個(gè)字符串("Hello?world!"),一路上造成了不必要的內(nèi)存分配。多少呢?很高興你問(wèn)了,但是我很懶,就讓我們看看CPU分析器吧。選中窗口頂部的”Deep?Profiler“,可以跟蹤到每幀的調(diào)用樹(shù)。
正如你所見(jiàn),堆內(nèi)存在Update()函數(shù)過(guò)程中的5個(gè)不同位置被分配。這個(gè)列表的初始化,foreach循環(huán)里到數(shù)組的轉(zhuǎn)換是多余的,每一個(gè)數(shù)字到字符的轉(zhuǎn)換以及連接都需要分配內(nèi)存。有趣的是,僅僅是調(diào)用Debug.Log()也會(huì)分配一大塊內(nèi)存-這點(diǎn)值得記下來(lái),即使在生產(chǎn)環(huán)境中這段代碼會(huì)被剔除。
如果你沒(méi)有Unity?Pro,但是恰巧有Microsoft?Visual?Studio,那就有替代Unity?Profiler的方法來(lái)發(fā)掘調(diào)用堆棧。Telerik?告訴我他們的?JustTrace?Memory?profiler?有相似的功能?(see?here).?然而,?我不知道它模仿Unity每幀記錄調(diào)用樹(shù)到了什么程度。更進(jìn)一步,盡管對(duì)Unity項(xiàng)目的遠(yuǎn)程調(diào)試(通過(guò)UnityVS)?是可以的,我還是沒(méi)有成功的把JustTrace用來(lái)分析被Unity調(diào)用的程序集。
只是稍微難一點(diǎn)點(diǎn)的方法:反匯編你的代碼
CIL的背景知識(shí)
如果你已經(jīng)有了一個(gè).NET/Mono的反匯編器,開(kāi)始用吧,不然我推薦ILSpy.?這個(gè)工具不僅是免費(fèi)的,它還非常干凈簡(jiǎn)單,但是剛好包含下面我們會(huì)用到的一個(gè)特殊功能。
你也許知道C#編譯器不會(huì)將你的代碼編譯成機(jī)器語(yǔ)言,而是公共中間語(yǔ)言。這種語(yǔ)言是被原.NET團(tuán)隊(duì)作為一種包含兩種來(lái)自高級(jí)語(yǔ)言特性的低級(jí)語(yǔ)言開(kāi)發(fā)出來(lái)的。一方面,它與硬件無(wú)關(guān),另一方面,它包含最適合被稱為’面向?qū)ο蟆奶匦?比如可以引用其他模塊或者類的能力。
沒(méi)有經(jīng)過(guò)代碼模糊處理(?code?obfuscator?)的CIL代碼是異常容易反向工程的。?許多情況下,結(jié)果幾乎和原始的C#(VB)代碼一樣。ILSpy?可以替你做這件事,但是我們僅僅反匯編代碼就可以了(ILSpy通過(guò)調(diào)用ildasm.exe來(lái)實(shí)現(xiàn),.它是NET/Mono的一部分)。讓我們從一個(gè)加兩個(gè)整數(shù)的函數(shù)開(kāi)始。
int AddTwoInts(int first, int second) { int result = first + second; return result;}如果你愿意,你可以將這段代碼粘貼到MemoryAllocatingScript.cs文件里。然后確保Unity編譯了它,再用ILSpy打開(kāi)編譯了的庫(kù)Assembly-Csharp.dll。如果你選擇AddTwoInts()?方法,你會(huì)看到下面的:
除了藍(lán)色的關(guān)鍵字?hidebysig,我們可以忽略掉,方法簽名應(yīng)該看起來(lái)差不多。要了解到方法里主要發(fā)生了什么,你需要知道CIL把CPU看成一個(gè)堆棧式機(jī)器stack?machine?而不是寄存器機(jī)器register?machine。CIL假設(shè)CPU可以處理非?;A(chǔ),非常算法的指令,例如”將兩個(gè)整數(shù)相加“,而且它可以處理任何內(nèi)存地址的隨機(jī)訪問(wèn)。CIL還假設(shè)CPU不直接在RAM上進(jìn)行算術(shù)操作,而是首先需要將數(shù)據(jù)裝載進(jìn)概念上的計(jì)算堆棧。(注意計(jì)算堆棧和你你知道的C#堆棧沒(méi)有任何關(guān)系。CIL計(jì)算堆棧只是一個(gè)抽象的,并且預(yù)設(shè)很小。)在行IL_0000到IL_0005發(fā)生了:
- 兩個(gè)整型參數(shù)被推進(jìn)堆棧。
- 加法被調(diào)用然后從堆棧里彈出開(kāi)始位置的兩個(gè)對(duì)象,自動(dòng)將記過(guò)壓進(jìn)堆棧。
- 第3和4行可以忽略,因?yàn)樵诎l(fā)行版本里會(huì)被優(yōu)化掉。
- 這個(gè)方法返回堆棧的第一個(gè)值。
找到CIL里面的內(nèi)存分配
CIL代碼美在它不會(huì)隱藏任何堆分配。而且,堆分配會(huì)嚴(yán)格按照以下三個(gè)順序分配,在你的反匯編代碼里能看到。
- newobj?<constructor>:這創(chuàng)建了一個(gè)由constructor指定類型的未初始化的對(duì)象。如果這個(gè)對(duì)象是值類型,它就在堆棧上被創(chuàng)建。如果它是一個(gè)引用類型,就在堆上。你總是能從CIL代碼知道類型,所以你可以容易的知道內(nèi)存分配產(chǎn)生的地方。
- newarr?<element?type>:這條指令在堆上創(chuàng)建了一個(gè)新的數(shù)組。Element的類型由參數(shù)指定。
- box?<value?type?token>:這條特殊的指令執(zhí)行裝箱操作,我們已經(jīng)在第一篇帖子里說(shuō)過(guò)。
Let's?look?at?a?rather?contrived?method?that?performs?all?three?types?of?allocations.
然我們來(lái)看一個(gè)人為的執(zhí)行這三種內(nèi)存分配的方法。
?
void SomeMethod() { object[] myArray = new object[1]; myArray[0] = 5; Dictionary<int, int> myDict = new Dictionary<int, int>();myDict[4] = 6; foreach (int key in myDict.Keys) Console.WriteLine(key);}?
有這幾行代碼產(chǎn)生的CIL代碼很多,所以這里我們只看關(guān)鍵部分:
IL_0001:?newarr?[mscorlib]System.Object...IL_000a:?box?[mscorlib]System.Int32...IL_0010:?newobj?instance?void?class?[mscorlib]System.????Collections.Generic.Dictionary'2<int32,?int32>::.ctor()...IL_001f:?callvirt?instance?class?[mscorlib]System.????Collections.Generic.Dictionary`2/KeyCollection<!0,?!1>????class?[mscorlib]System.Collections.Generic.Dictionary`2<int32,????int32>::get_Keys()
正如我們懷疑過(guò)的,對(duì)象的數(shù)組(SomeMethod()里的第一行)導(dǎo)致newarr指令。整數(shù)5被賦給數(shù)組的第一個(gè)元素需要裝箱。Dictionary<int,?int>是被newobj指令分配的。
但是還有第四個(gè)堆分配!正如我在第一篇帖子里提到的,Dictionary<K,?V>.?KeyCollection被聲明為一個(gè)類,不是結(jié)構(gòu)。這個(gè)類的一個(gè)實(shí)例會(huì)被創(chuàng)建,這樣foreach蓄奴換才有迭代的對(duì)象。不幸的是,分配發(fā)生在Keys屬性的getter方法里。正如你在CIL代碼里看到,這個(gè)方法的名字是get_Keys(),而且它的返回值是一個(gè)類。
作為一個(gè)查找內(nèi)存泄露的通用方法,你可以生成一個(gè)對(duì)你的整個(gè)程序集反匯編的CIL文件,只要在ILSpy按下Ctrl+S。然后用你喜歡的文本編輯器打開(kāi)這個(gè)文件,搜索上面提到的三種指令。查出其他程序集里的內(nèi)存泄露是有難度。我唯一知道的辦法就是仔細(xì)檢查你的C#代碼,確認(rèn)所有的外部方法調(diào)用,并且一個(gè)個(gè)地查看它們的CIL代碼。你怎么知道什么時(shí)候就完成了?很簡(jiǎn)單:你的游戲可以流暢的運(yùn)行好幾個(gè)小時(shí),不因?yàn)槔占斐扇魏蔚男阅芷款i。
PS:在之前的帖子里,我答應(yīng)要向你們展示如何確認(rèn)你們系統(tǒng)上的Mono版本。只要裝了ILSpy,沒(méi)有比這更簡(jiǎn)單的了。在ILSpy里,點(diǎn)擊打開(kāi)然后找到Unity根目錄。找到Data/Mono/lib/mono/2.0然后打開(kāi)mscorlib.dll。在層級(jí)視圖里,找到mscorlib/-/Consts,然后那兒你能找到MonoVersion作為一個(gè)字符串常量。
總結(jié)
以上是生活随笔為你收集整理的Unity开发者的C#内存管理(中篇)的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: 标志设计教程
- 下一篇: Unity开发者的C#内存管理