Unity版本:5.5
引言
游戲運(yùn)行時(shí)使用內(nèi)存來(lái)存儲(chǔ)數(shù)據(jù),當(dāng)這些數(shù)據(jù)不再被使用時(shí),存儲(chǔ)這些數(shù)據(jù)的內(nèi)存被釋放以便于之后這些內(nèi)存可以被復(fù)用。垃圾(Garbage )是存儲(chǔ)無(wú)用數(shù)據(jù)的內(nèi)存的術(shù)語(yǔ),GC(Garbage Collection 垃圾回收)是使這些內(nèi)存可以再次使用的過(guò)程。
GC是Unity管理內(nèi)存的一部分,我們的游戲可能因?yàn)镚C負(fù)擔(dān)過(guò)重而表現(xiàn)不佳,所以GC是引起性能問(wèn)題的一個(gè)常見(jiàn)原因。
在這篇文章中,我們將介紹GC如何工作,在什么情況下會(huì)觸發(fā)GC和如何高效的使用內(nèi)存以減少GC對(duì)游戲的影響
GC問(wèn)題診斷
GC引起的性能問(wèn)題可表現(xiàn)為幀率過(guò)低,幀率劇烈波動(dòng)或者間歇性卡頓。但是其他問(wèn)題也可能引起類似的癥狀。如果你的游戲有這些性能問(wèn)題,首先需要使用Unity的Profiler工具來(lái)確定這些問(wèn)題是由GC引起的。
如何使用Profiler工具來(lái)確定引起性能問(wèn)題的原因,可以查看這篇教程。
Unity內(nèi)存管理簡(jiǎn)介
在了解GC如何工作和何時(shí)觸發(fā)之前,我們需要先了解Unity的內(nèi)存使用情況。首先,我們要知道,在運(yùn)行自己的核心引擎代碼和運(yùn)行我們?cè)谀_本中編寫(xiě)的代碼時(shí),Unity使用不同的方法。
當(dāng)Unity在運(yùn)行自己的核心引擎代碼時(shí)使用<u style="box-sizing: border-box; outline: 0px; margin: 0px; padding: 0px; overflow-wrap: break-word;">手動(dòng)內(nèi)存管理</u>,這意味著核心引擎代碼必須明確地說(shuō)明如何使用內(nèi)存。手動(dòng)內(nèi)存管理不使用GC,本文不做介紹。
當(dāng)Unity運(yùn)行我們寫(xiě)的腳本代碼時(shí)使用<u style="box-sizing: border-box; outline: 0px; margin: 0px; padding: 0px; overflow-wrap: break-word;">自動(dòng)內(nèi)存管理</u>,這意味著我們寫(xiě)代碼時(shí)不用明確的告訴Unity如何管理內(nèi)存,Unity自動(dòng)幫我們完成這些工作。
基本上來(lái)說(shuō),Unity自動(dòng)內(nèi)存管理像這樣工作:
- Unity可以訪問(wèn)兩個(gè)內(nèi)存池:棧和堆(也稱為托管堆)。棧用于短期存儲(chǔ)小塊數(shù)據(jù),堆用于長(zhǎng)期存儲(chǔ)和較大數(shù)據(jù)段。
- 當(dāng)創(chuàng)建變量時(shí),Unity從棧或堆中申請(qǐng)內(nèi)存
- 只要變量在作用域內(nèi)(仍然可以通過(guò)我們的代碼訪問(wèn)),分配給它的內(nèi)存仍然在使用中, 我們稱這部分內(nèi)存已被分配。 我們將棧中的變量稱為棧對(duì)象,將堆中的變量稱為堆對(duì)象。
- 當(dāng)變量超出作用域,該內(nèi)存不再被使用并可以歸還給原來(lái)的內(nèi)存池。當(dāng)內(nèi)存被歸還給原有的內(nèi)存池里,我們稱該內(nèi)存被釋放。棧內(nèi)存在變量超出作用域時(shí)被實(shí)時(shí)釋放,而堆內(nèi)存在變量超出作用域之后并沒(méi)有被釋放并保持被分配的狀態(tài)
- 垃圾收集器(garbage collector)識(shí)別和釋放未使用的堆內(nèi)存。 垃圾收集器定期運(yùn)行以清理堆。
現(xiàn)在我們了解事件的流程,讓我們進(jìn)一步了解棧分配和釋放與堆分配和釋放之間的區(qū)別。
在棧分配和釋放時(shí)發(fā)生了什么
棧分配和釋放簡(jiǎn)單快速。這是因?yàn)闂V挥糜谠诙虝r(shí)間內(nèi)存儲(chǔ)小數(shù)據(jù)。 分配和釋放總是以可預(yù)測(cè)的順序發(fā)生,并且具有可預(yù)測(cè)的大小。
棧的工作方式類似于<u style="box-sizing: border-box; outline: 0px; margin: 0px; padding: 0px; overflow-wrap: break-word;">棧數(shù)據(jù)類型</u>:它是一個(gè)簡(jiǎn)單的元素集合,這種情況下的內(nèi)存塊,只能以嚴(yán)格的順序添加和刪除元素。 這種簡(jiǎn)單性和嚴(yán)格性使得它變得非??焖伲寒?dāng)一個(gè)變量存儲(chǔ)在棧上時(shí),它的內(nèi)存就是簡(jiǎn)單地從棧頂分配。 棧變量超出作用域時(shí),用于存儲(chǔ)該變量的內(nèi)存將立即返回棧進(jìn)行重用。
在堆分配時(shí)發(fā)生了什么
堆分配比棧分配復(fù)雜的多。因?yàn)槎芽梢杂脕?lái)存儲(chǔ)長(zhǎng)期和短期數(shù)據(jù)及各種不同類型大小的數(shù)據(jù)。分配和釋放也并不總是按可預(yù)測(cè)的順序進(jìn)行且可能需要大小差距巨大的內(nèi)存塊。
當(dāng)一個(gè)堆變量創(chuàng)建時(shí),將執(zhí)行以下步驟:
- 首先,Unity檢查堆上是否有足夠的空閑內(nèi)存,如果有,則該變量的內(nèi)存被分配。
- 如果沒(méi)有,Unity觸發(fā)GC試圖釋放未使用的堆內(nèi)存,這個(gè)操作可能很慢。如果GC之后堆內(nèi)存足夠,則該變量的內(nèi)存被分配。
- 如果GC之后堆上還是沒(méi)有足夠的空閑內(nèi)存,Unity將向操作系統(tǒng)申請(qǐng)更多內(nèi)存以擴(kuò)大堆大小。這個(gè)操作可能很慢。之后該變量的內(nèi)存被分配。
堆分配可能會(huì)很慢,特別在必須執(zhí)行GC和擴(kuò)大堆大小時(shí)。
在GC時(shí)發(fā)生了什么
當(dāng)堆變量超出作用域后,存儲(chǔ)該變量的內(nèi)存并沒(méi)有被立即釋放。無(wú)用的堆內(nèi)存只在執(zhí)行GC時(shí)被釋放。
每次執(zhí)行GC時(shí),將執(zhí)行以下步驟:
- 垃圾收集器檢索堆上的每個(gè)對(duì)象。
- 垃圾收集器搜索所有當(dāng)前對(duì)象引用以確定堆上的對(duì)象是否仍在作用域內(nèi)。
- 不在作用域內(nèi)的對(duì)象被標(biāo)記為刪除。
- 刪除被標(biāo)記的對(duì)象并將內(nèi)存返回給堆。
GC是個(gè)費(fèi)時(shí)的操作,堆上的對(duì)象越多,代碼中的引用數(shù)越多,GC就越費(fèi)時(shí)。
何時(shí)會(huì)觸發(fā)GC
三種情況下會(huì)觸發(fā)GC:
- 堆分配時(shí)堆上的可用內(nèi)存不足時(shí)觸發(fā)GC。
- GC會(huì)不時(shí)的自動(dòng)運(yùn)行(頻率因平臺(tái)而異)。
- 手動(dòng)強(qiáng)制調(diào)用GC
GC可能被頻繁觸發(fā)。每當(dāng)無(wú)法從可用堆內(nèi)存中實(shí)現(xiàn)堆分配時(shí),就會(huì)觸發(fā)GC,這意味著頻繁的堆分配和釋放可能導(dǎo)致GC頻繁。
GC的問(wèn)題
現(xiàn)在我們了解了GC在Unity內(nèi)存管理中的作用,我們可以考慮可能發(fā)生的問(wèn)題類型。
最明顯的問(wèn)題是GC可能花費(fèi)相當(dāng)長(zhǎng)的時(shí)間來(lái)運(yùn)行。 如果堆上有很多對(duì)象和大量的對(duì)象引用要檢查,則檢查所有這些對(duì)象的過(guò)程可能很慢。 這可能會(huì)導(dǎo)致我們的游戲卡頓或運(yùn)行緩慢。
另一個(gè)問(wèn)題是GC可能在不合時(shí)宜的時(shí)刻被觸發(fā)。 如果CPU在我們游戲的性能關(guān)鍵部分已經(jīng)滿負(fù)荷了,那此時(shí)即使是少量的GC額外開(kāi)銷也可能導(dǎo)致我們的幀速率下降和性能問(wèn)題。
另一個(gè)不太明顯的問(wèn)題是堆碎片。當(dāng)從堆中分配內(nèi)存時(shí),會(huì)根據(jù)必須存儲(chǔ)的數(shù)據(jù)大小從不同大小的塊中的可用空間中獲取內(nèi)存。當(dāng)這些內(nèi)存塊返回到堆時(shí),堆可能分成很多由分配塊分隔的小空閑塊。這意味著雖然可用內(nèi)存總量可能很高,但由于碎片化太過(guò)嚴(yán)重而無(wú)法分配一塊連續(xù)的大內(nèi)存塊。導(dǎo)致GC被觸發(fā)或不得不擴(kuò)大堆大小。
堆內(nèi)存碎片化有兩個(gè)后果,一是游戲內(nèi)存大小會(huì)遠(yuǎn)高于實(shí)際所需要的大小,二是GC會(huì)被更頻繁的觸發(fā)。 有關(guān)堆碎片的更詳細(xì)討論,請(qǐng)參閱<u style="box-sizing: border-box; outline: 0px; margin: 0px; padding: 0px; overflow-wrap: break-word;">這個(gè)Unity性能最佳實(shí)踐指南</u>。
查找堆分配
當(dāng)我們的游戲因?yàn)镚C而出現(xiàn)問(wèn)題時(shí),我們需要知道是由哪部分的代碼引起的。當(dāng)堆上的變量超出作用域后,這部分的內(nèi)存變?yōu)榇厥盏睦鴥?nèi)存,所以我們需要知道一個(gè)變量何時(shí)會(huì)被分配到堆上。
棧和堆上分配了什么?
Unity中值類型的局部變量分配在棧上,除此之外都分配在堆上。如果不清楚值類型和引用類型的區(qū)別,請(qǐng)看這篇教程。
下面這段代碼是個(gè)棧分配的示例,localInt變量是個(gè)局部的值類型變量。分配給該變量的內(nèi)存在該函數(shù)調(diào)用結(jié)束后立即被回收。
void ExampleFunction()
{
int localInt = 5;
}
下面這段代碼是個(gè)堆分配的示例,localList變量是局部變量但是引用類型。分配給該變量的內(nèi)存在下次GC時(shí)被回收。
void ExampleFunction()
{
List localList = new List();
}
使用Profiler工具來(lái)查找堆分配
我們可以使用Profiler工具來(lái)查看哪部分代碼產(chǎn)生了堆分配
選中CPU Usage,然后選中任意幀就可以在Profiler窗口的下部查看到該幀的CPU使用數(shù)據(jù)。其中一列叫GC alloc,這一列顯示了這幀中的堆分配信息。點(diǎn)擊列頭對(duì)該列進(jìn)行排序,這樣可以更直觀的看出當(dāng)前幀哪些函數(shù)產(chǎn)生了最多的堆分配。這樣就可以檢查這些產(chǎn)生堆分配的函數(shù)。
一旦我們知道函數(shù)內(nèi)的什么代碼導(dǎo)致生成垃圾,我們可以決定如何解決這個(gè)問(wèn)題,并最大限度地減少垃圾的生成量。
減少GC的影響
概括的說(shuō),可以通過(guò)以下三中方式來(lái)減少GC對(duì)我們游戲的影響:
- 減少GC的時(shí)間
- 減少GC的頻率
- 故意觸發(fā)GC,以避開(kāi)游戲運(yùn)行的性能關(guān)鍵點(diǎn),比如加載場(chǎng)景時(shí)
基于這些考慮,我們可以使用三種策略:
- 我們可以組織我們的游戲使其更少的堆分配和更少的對(duì)象引用。 堆上更少的對(duì)象和更少的引用 檢查意味著當(dāng)GC觸發(fā)時(shí),運(yùn)行時(shí)間更少。
- 我們可以減少堆分配和釋放的頻率,特別是在性能點(diǎn)。 更少的分配和釋放意味著更少的觸發(fā)GC。 這也降低了堆碎片的問(wèn)題。
- 我們可以嘗試手動(dòng)觸發(fā)GC和擴(kuò)展堆大小以便GC可控并在合適的時(shí)候觸發(fā)。這個(gè)方法更難且不可靠,但作為整體內(nèi)存管理策略的一部分,可以減少GC的影響。
減少垃圾的產(chǎn)生量
可以使用一些技術(shù)來(lái)幫助我們減少代碼中生成的垃圾量
緩存
如果我們的代碼重復(fù)調(diào)用產(chǎn)生堆分配的函數(shù),然后丟棄結(jié)果,這將產(chǎn)生不必要的垃圾。 對(duì)此,我們應(yīng)該存儲(chǔ)對(duì)這些對(duì)象的引用并復(fù)用它們。 這種技術(shù)被稱為緩存。
下面的函數(shù)每次調(diào)用都會(huì)引起堆分配,因?yàn)槊看握{(diào)用都會(huì)生成一個(gè)新的數(shù)組。
void OnTriggerEnter(Collider other)
{
Renderer[] allRenderers = FindObjectsOfType<Renderer>();
ExampleFunction(allRenderers);
}
下面的代碼只會(huì)有一次堆分配,因?yàn)閿?shù)組創(chuàng)建賦值后被緩存起來(lái)了。緩存的數(shù)組可以復(fù)用因而不會(huì)產(chǎn)生垃圾。
private Renderer[] allRenderers;
void Start()
{
allRenderers = FindObjectsOfType<Renderer>();
}
void OnTriggerEnter(Collider other)
{
ExampleFunction(allRenderers);
}
不要在頻繁調(diào)用的函數(shù)中分配
如果我們需要在MonoBehaviour中分配堆內(nèi)存,在頻繁調(diào)用的函數(shù)里分配是最糟糕的。比如 每幀調(diào)用的函數(shù)Update()和LateUpdate(),在這些地方分配,垃圾將非常快的累積。我們應(yīng)該盡可能在Start() 或 Awake() 里緩存這些對(duì)象的引用,或者確保分配內(nèi)存的代碼只在需要的時(shí)候被運(yùn)行。
讓我們來(lái)看個(gè)簡(jiǎn)單的例子,下面的代碼在每次 Update()調(diào)用時(shí)都會(huì)調(diào)用一個(gè)引起堆分配的函數(shù),會(huì)非??斓漠a(chǎn)生垃圾
void Update()
{
ExampleGarbageGeneratingFunction(transform.position.x);
}
簡(jiǎn)單修改后,可以確保產(chǎn)生堆分配的函數(shù)只在transform.position.x 的值改變時(shí)才被調(diào)用.這樣只在需要的時(shí)候產(chǎn)生堆分配而不會(huì)每幀都產(chǎn)生.
private float previousTransformPositionX;
void Update()
{
float transformPositionX = transform.position.x;
if (transformPositionX != previousTransformPositionX)
{
ExampleGarbageGeneratingFunction(transformPositionX);
previousTransformPositionX = transformPositionX;
}
}
另一個(gè)在 Update()函數(shù)中減少垃圾內(nèi)存產(chǎn)生量的方法是使用計(jì)時(shí)器.這適用于那些會(huì)產(chǎn)生垃圾內(nèi)存的代碼需要被頻繁調(diào)用又不需要每幀調(diào)用的地方
下面的示例代碼,產(chǎn)生垃圾內(nèi)存的函數(shù)每幀被調(diào)用
void Update()
{
ExampleGarbageGeneratingFunction();
}
下面的代碼,使用一個(gè)計(jì)時(shí)器來(lái)保證產(chǎn)生垃圾內(nèi)存的函數(shù)每秒只被調(diào)一次
private float timeSinceLastCalled;
private float delay = 1f;
void Update()
{
timeSinceLastCalled += Time.deltaTime;
if (timeSinceLastCalled > delay)
{
ExampleGarbageGeneratingFunction();
timeSinceLastCalled = 0f;
}
}
像這樣對(duì)頻繁調(diào)用函數(shù)的小改動(dòng),可以顯著的減少垃圾內(nèi)存的產(chǎn)生量
清空容器
創(chuàng)建容器類會(huì)引起堆分配,如果在代碼中發(fā)現(xiàn)多次創(chuàng)建同一個(gè)容器變量,則應(yīng)該緩存該容器引用并在重復(fù)創(chuàng)建的地方使用 Clear()操作來(lái)替代
下面的示例中每次 *new *操作都會(huì)產(chǎn)生一次堆分配
void Update()
{
List myList = new List();
PopulateList(myList);
}
下面的示例中,只在容器被創(chuàng)建或者擴(kuò)容時(shí)才會(huì)有堆分配,顯著減少了垃圾內(nèi)存的產(chǎn)生量
private List myList = new List();
void Update()
{
myList.Clear();
PopulateList(myList);
}
對(duì)象池
即使減少了腳本中的堆分配,在運(yùn)行時(shí)大量對(duì)象的創(chuàng)建和銷毀依然會(huì)引起GC問(wèn)題. 對(duì)象池是一種通過(guò)重用對(duì)象而不是重復(fù)創(chuàng)建和銷毀對(duì)象來(lái)減少分配和釋放的技術(shù).對(duì)象池在游戲中廣泛使用,最適合于頻繁產(chǎn)生和銷毀類似對(duì)象的情況;,例如,當(dāng)槍射擊子彈時(shí).
對(duì)象池的完整指南超出了本文的范圍,但它是一個(gè)非常有用的技術(shù),值得一試. 關(guān)于Unity學(xué)習(xí)網(wǎng)站上的對(duì)象池的這個(gè)教程是在Unity中實(shí)現(xiàn)對(duì)象池系統(tǒng)的一個(gè)很好的指導(dǎo)
引起不必要堆分配的常見(jiàn)原因
我們知道局部的,值類型的變量被分配在棧上,其他的都在堆上分配.但是很多情況下的堆分配可能讓人驚訝.我們來(lái)看看一些不必要的堆分配的常見(jiàn)原因,并考慮如何最好地減少這些。
字符串
在C#中,字符串是引用類型,而不是值類型,盡管它們似乎保持字符串的“值”. 這意味著創(chuàng)建和丟棄字符串會(huì)產(chǎn)生垃圾.由于字符串常用在很多代碼中,所以這些垃圾可能累積。
C#中的字符串也是不可變的,這意味著它們的值在第一次創(chuàng)建之后不能再被更改。 每次我們操縱一個(gè)字符串(例如,通過(guò)使用+運(yùn)算符來(lái)連接兩個(gè)字符串),Unity將創(chuàng)建一個(gè)包含更新值的新字符串,并丟棄舊字符串。 這會(huì)產(chǎn)生垃圾。
我們可以遵循一些簡(jiǎn)單的規(guī)則,將字符串產(chǎn)生的垃圾減至最少。 我們來(lái)看看這些規(guī)則,然后看一下應(yīng)用它們的例子。
- 減少不必要的字符串創(chuàng)建。 如果多次使用相同的字符串值,應(yīng)該創(chuàng)建一次該字符串并緩存該值。
- 減少不必要的字符串操作。 例如,如果有一個(gè)經(jīng)常更新的Text組件,并且包含一個(gè)連接的字符串,可以考慮將它分成兩個(gè)Text組件。
- 如果必須在運(yùn)行時(shí)構(gòu)建字符串,應(yīng)該使用StringBuilder類。 StringBuilder類用于創(chuàng)建沒(méi)有堆分配的字符串,并且在連接復(fù)雜字符串時(shí)減少生成的垃圾量。
- 當(dāng)不在需要調(diào)試時(shí),立即刪除對(duì)Debug.Log()的調(diào)用。即使沒(méi)有輸出任何內(nèi)容,對(duì)Debug.Log()的調(diào)用依然會(huì)被執(zhí)行。調(diào)用Debug.Log() 創(chuàng)建和處理至少一個(gè)字符串,所以如果我們的游戲包含許多這些調(diào)用,垃圾會(huì)累積
來(lái)看一個(gè)低效使用字符串而產(chǎn)生不必要垃圾的代碼的例子。 在下面的代碼中,在Update()中創(chuàng)建一個(gè)連接“TIME:”與浮點(diǎn)計(jì)時(shí)器的值的字符串來(lái)顯示分?jǐn)?shù),這產(chǎn)生了不必要的垃圾。
public Text timerText;
private float timer;
void Update()
{
timer += Time.deltaTime;
timerText.text = "TIME:" + timer.ToString();
}
下面我們做些改進(jìn)。 我們把單詞“TIME:”放在一個(gè)單獨(dú)的文本組件中,并在Start()中設(shè)置它的值。 這樣在Update()中,我們不再需要連接字符串。 可以大大減少垃圾的產(chǎn)生。
public Text timerHeaderText;
public Text timerValueText;
private float timer;
void Start()
{
timerHeaderText.text = "TIME:";
}
void Update()
{
timerValueText.text = timer.toString();
}
Unity函數(shù)調(diào)用
重要的是要注意,每當(dāng)我們調(diào)用不是自己寫(xiě)的代碼時(shí),無(wú)論是在Unity中還是在插件中,都可能會(huì)產(chǎn)生垃圾。 調(diào)用一些Unity函數(shù)會(huì)產(chǎn)生堆分配,因此應(yīng)謹(jǐn)慎使用以避免產(chǎn)生不必要的垃圾。
并沒(méi)有一個(gè)應(yīng)該避免使用的函數(shù)列表。 每個(gè)函數(shù)在某些情況下都是有用的,而在其他情況下則不太有用。所以最好仔細(xì)分析我們的游戲,確定垃圾的產(chǎn)生位置并仔細(xì)思考如何處理。 在某些情況下,可以緩存函數(shù)的結(jié)果; 在某些情況下,可以降低調(diào)用函數(shù)的頻率; 在其他情況下,最好重構(gòu)代碼以使用不同的函數(shù)。 話雖如此,我們來(lái)看幾個(gè)常見(jiàn)的會(huì)導(dǎo)致堆分配 的Unity函數(shù),并考慮如何更好地處理它們。
每次訪問(wèn)返回值為數(shù)組的Unity函數(shù)時(shí),都會(huì)創(chuàng)建一個(gè)新的數(shù)組,并將其作為返回值傳遞給我們。 這種行為并不總是顯而易見(jiàn)的或可預(yù)期的,特別是當(dāng)函數(shù)是訪問(wèn)器的時(shí)候(例如 Mesh.normals)。
下面的代碼中,每次循環(huán)迭代都會(huì)生成一個(gè)新的數(shù)組
void ExampleFunction()
{
for (int i = 0; i < myMesh.normals.Length; i++)
{
Vector3 normal = myMesh.normals[i];
}
}
這種情況下很容易減少分配:我們可以簡(jiǎn)單地緩存對(duì)數(shù)組的引用。 這樣可以只創(chuàng)建一個(gè)數(shù)組,并相應(yīng)地減少了產(chǎn)生的垃圾量。
下面的代碼演示了這一點(diǎn)。 在這種情況下,我們?cè)谘h(huán)之前調(diào)用Mesh.normals并緩存引用,這樣就只創(chuàng)建一個(gè)數(shù)組。
void ExampleFunction()
{
Vector3[] meshNormals = myMesh.normals;
for (int i = 0; i < meshNormals.Length; i++)
{
Vector3 normal = meshNormals[i];
}
}
訪問(wèn)GameObject.name或GameObject.tag也會(huì)有堆分配。 這兩個(gè)都是返回新字符串的訪問(wèn)器,這意味著調(diào)用這些函數(shù)會(huì)產(chǎn)生垃圾。 緩存該值可能是有用的,但在這種情況下,可以使用相關(guān)的Unity函數(shù)。 要檢查一個(gè)GameObject的標(biāo)簽的值而不產(chǎn)生垃圾,我們可以使用 GameObject.CompareTag()。
下面的示例代碼中,訪問(wèn)GameObject.tag會(huì)產(chǎn)生垃圾內(nèi)存:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.tag == playerTag;
}
如果使用 GameObject.CompareTag(),則該函數(shù)不會(huì)產(chǎn)生垃圾:
private string playerTag = "Player";
void OnTriggerEnter(Collider other)
{
bool isPlayer = other.gameObject.CompareTag(playerTag);
}
GameObject.CompareTag并不是唯一的,很多Unity的函數(shù)都有無(wú)堆分配的替代版本。比如可以使用Input.GetTouch() 和 Input.touchCount 替換Input.touches, 或者使用Physics.SphereCastNonAlloc() 替換 Physics.SphereCastAll()。
裝箱
裝箱是指當(dāng)一個(gè)值類型變量被用作一個(gè)引用類型變量時(shí)所執(zhí)行的操作。當(dāng)我們將值類型的變量(如int或float)傳遞給具有object類型參數(shù)的函數(shù)時(shí),通常會(huì)發(fā)生裝箱,如Object.Equals()函數(shù)。
例如,函數(shù)String.Format()接受一個(gè)string和一個(gè)object參數(shù)。 當(dāng)我們傳遞一個(gè)string和一個(gè)int時(shí),int就會(huì)被裝箱。 下面的代碼包含了一個(gè)裝箱的例子:
void ExampleFunction()
{
int cost = 5;
string displayString = String.Format("Price: {0} gold", cost);
}
裝箱會(huì)產(chǎn)生垃圾源于其后臺(tái)操作。當(dāng)一個(gè)值類型變量被裝箱時(shí),Unity在堆上創(chuàng)建一個(gè)臨時(shí)的System.Object來(lái)包裝值類型變量。 一個(gè)System.Object是一個(gè)引用類型的變量,所以當(dāng)這個(gè)臨時(shí)對(duì)象被處理掉時(shí)會(huì)產(chǎn)生垃圾。
裝箱是不必要的堆分配的常見(jiàn)原因。 即使我們不在我們的代碼中直接裝箱變量,我們可能也會(huì)使用導(dǎo)致裝箱的插件,裝箱也可能發(fā)生在其他函數(shù)的后臺(tái)。 最好的做法是盡可能避免裝箱,并刪除導(dǎo)致裝箱的任何函數(shù)調(diào)用。
協(xié)程
調(diào)用StartCoroutine()會(huì)產(chǎn)生少量的垃圾,因?yàn)閁nity必須創(chuàng)建一些管理協(xié)程的實(shí)例的類。 所以,當(dāng)游戲在交互時(shí)或在性能熱點(diǎn)時(shí)應(yīng)該限制對(duì)StartCoroutine()的調(diào)用。 為了減少這種方式產(chǎn)生的垃圾,必須在性能熱點(diǎn)運(yùn)行的協(xié)程應(yīng)該提前啟動(dòng),當(dāng)使用可能包含對(duì)StartCoroutine()的延遲調(diào)用的嵌套協(xié)程時(shí),我們應(yīng)特別小心。
協(xié)程中的yield語(yǔ)句不會(huì)自己產(chǎn)生堆分配; 然而,我們傳遞給yield語(yǔ)句的值可能會(huì)產(chǎn)生不必要的堆分配。 例如,以下代碼會(huì)產(chǎn)生垃圾:
yield return 0;
該代碼產(chǎn)生垃圾,因?yàn)閕nt變量0被裝箱。 在這種情況下,如果我們希望只是等待一個(gè)幀而不會(huì)導(dǎo)致任何堆分配,那么最好的方法是使用以下代碼:
yield return null;
協(xié)程的另一個(gè)常見(jiàn)錯(cuò)誤是在多次使用相同的值時(shí)使用了new操作, 例如,以下代碼將在循環(huán)迭代時(shí)每次都重復(fù)創(chuàng)建和銷毀一個(gè)WaitForSeconds對(duì)象:
while (!isComplete)
{
yield return new WaitForSeconds(1f);
}
如果緩存和復(fù)用WaitForSeconds對(duì)象,就能減少垃圾的產(chǎn)生量,請(qǐng)看以下示例代碼:
WaitForSeconds delay = new WaitForSeconds(1f);
while (!isComplete)
{
yield return delay;
}
如果我們的代碼由于協(xié)程而產(chǎn)生大量垃圾,我們可能考慮使用除協(xié)程之外的其他東西來(lái)重構(gòu)我們的代碼。 重構(gòu)代碼是一個(gè)復(fù)雜的問(wèn)題,每個(gè)項(xiàng)目都是獨(dú)一無(wú)二的,但是有一些常用的手段或許對(duì)協(xié)程問(wèn)題有幫助。 例如,如果我們主要使用協(xié)同程序來(lái)管理時(shí)間,我們可以簡(jiǎn)單地在一個(gè)Update()函數(shù)中記錄時(shí)間。 如果我們主要使用協(xié)同程序來(lái)控制游戲中發(fā)生的事情的順序,我們可以創(chuàng)建某種消息系統(tǒng)來(lái)允許對(duì)象進(jìn)行通信。 一個(gè)方法不能解決所有問(wèn)題,但是有必要記住,在代碼中可以有多種方法來(lái)實(shí)現(xiàn)相同的事情。
foreach循環(huán)
在Unity5.5之前的版本中,使用foreach遍歷數(shù)組之外的所有集合,在循環(huán)終止時(shí)都會(huì)產(chǎn)生垃圾,這是因?yàn)槠浜笈_(tái)的裝箱操作。當(dāng)循環(huán)開(kāi)始并且循環(huán)終止時(shí),一個(gè)System.Object對(duì)象被分配在堆上。 Unity 5.5中已修復(fù)此問(wèn)題。
在5.5之前的Unity版本中,以下代碼中的循環(huán)會(huì)生成垃圾:
void ExampleFunction(List listOfInts)
{
foreach (int currentInt in listOfInts)
{
DoSomething(currentInt);
}
}
如果我們無(wú)法升級(jí)我們的Unity版本,則有一個(gè)簡(jiǎn)單的解決方案來(lái)解決這個(gè)問(wèn)題。 for和while循環(huán)不會(huì)在后臺(tái)引起裝箱,因此不會(huì)產(chǎn)生任何垃圾。 當(dāng)?shù)皇菙?shù)組的集合時(shí),我們應(yīng)該優(yōu)先使用它們。
下面的代碼不會(huì)產(chǎn)生垃圾:
void ExampleFunction(List listOfInts)
{
for (int i = 0; i < listOfInts.Count; i ++)
{
int currentInt = listOfInts[i];
DoSomething(currentInt);
}
}
函數(shù)引用
函數(shù)引用,無(wú)論是引用匿名函數(shù)還是命名函數(shù),都是Unity中的引用類型變量。 它們將導(dǎo)致堆分配。 將匿名函數(shù)轉(zhuǎn)換為 閉包(匿名函數(shù)可在其創(chuàng)建時(shí)訪問(wèn)范圍中的變量)顯著增加了內(nèi)存使用量和堆分配數(shù)量。
函數(shù)引用和閉包如何分配內(nèi)存的精確細(xì)節(jié)因平臺(tái)和編譯器設(shè)置而異,但是如果GC是一個(gè)問(wèn)題,那么最好在游戲過(guò)程中盡量減少使用函數(shù)引用和閉包。 <u style="box-sizing: border-box; outline: 0px; margin: 0px; padding: 0px; overflow-wrap: break-word;">這個(gè)Unity性能最佳實(shí)踐指南</u> 在這個(gè)主題上有更多的技術(shù)細(xì)節(jié)。
LINQ和正則表達(dá)式
LINQ和正則表達(dá)式由于在后臺(tái)會(huì)有裝箱操作而產(chǎn)生垃圾。在有性能要求的時(shí)候最好不使用。 同樣,<u style="box-sizing: border-box; outline: 0px; margin: 0px; padding: 0px; overflow-wrap: break-word;">這個(gè)Unity性能最佳實(shí)踐指南</u> 提供了有關(guān)此主題的更多技術(shù)細(xì)節(jié)。
構(gòu)建代碼以最小化GC的影響
代碼的構(gòu)建方式可能會(huì)影響GC。即使代碼中沒(méi)有堆分配,也有可能增加GC的負(fù)擔(dān)。
可能增加GC的負(fù)擔(dān)之一是要求它檢查它不應(yīng)該檢查的東西。Structs是值類型變量,但是如果有一個(gè)包含引用類型變量的struct,那么垃圾收集器必須檢查整個(gè)結(jié)構(gòu)體。 如果有大量這樣的結(jié)構(gòu)體,那么垃圾回收器將增加大量額外的工作。
在這個(gè)例子中,下面的struct包含了一個(gè)引用類型的字符串。 現(xiàn)在在垃圾回收器運(yùn)行時(shí)必須檢查結(jié)構(gòu)體的整個(gè)數(shù)組。
public struct ItemData
{
public string name;
public int cost;
public Vector3 position;
}
private ItemData[] itemData;
在這個(gè)例子中,我們將數(shù)據(jù)存儲(chǔ)在單獨(dú)的數(shù)組中。 當(dāng)垃圾收集器運(yùn)行時(shí),它只需要檢查字符串?dāng)?shù)組,并且可以忽略其他數(shù)組。 這減少了垃圾收集器的工作。
private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;
另一個(gè)可能增加GC負(fù)擔(dān)的操作是使用不必要的對(duì)象引用,當(dāng)垃圾收集器搜索對(duì)堆上對(duì)象的引用時(shí),它必須檢查代碼中的每個(gè)當(dāng)前對(duì)象引用。 更少的對(duì)象引用意味著更少的工作量,即使我們不減少堆上的對(duì)象總數(shù)。
在這個(gè)例子中,我們有一個(gè)類填充一個(gè)對(duì)話框。 當(dāng)用戶查看對(duì)話框時(shí),會(huì)顯示另一個(gè)對(duì)話框。 我們的代碼包含對(duì)應(yīng)該顯示的DialogData的下一個(gè)實(shí)例的引用,這意味著垃圾回收器必須在其操作中檢查此引用:
public class DialogData
{
private DialogData nextDialog;
public DialogData GetNextDialog()
{
return nextDialog;
}
}
這里我們重構(gòu)下代碼,以便它返回一個(gè)用于查找下一個(gè)DialogData實(shí)例的標(biāo)識(shí)符,而不是實(shí)例本身。 這不是一個(gè)對(duì)象引用,所以它不會(huì)增加垃圾收集器所花費(fèi)的時(shí)間。
public class DialogData
{
private int nextDialogID;
public int GetNextDialogID()
{
return nextDialogID;
}
}
這是個(gè)小例子。 然而,如果我們的游戲中有許多包含對(duì)其他對(duì)象引用的對(duì)象,那么我們可以通過(guò)以這種方式重構(gòu)代碼來(lái)大大降低堆的復(fù)雜性。
定時(shí)GC
手動(dòng)強(qiáng)制GC
最后,我們可能希望自己觸發(fā)GC。 如果我們知道堆內(nèi)存已被分配但不再使用(例如,如果我們的代碼在加載資源時(shí)生成垃圾),并且我們知道垃圾收集凍結(jié)不會(huì)影響播放器(例如,當(dāng)加載界面還顯示時(shí)),我們可以使用以下代碼請(qǐng)求GC:
System.GC.Collect();
這將強(qiáng)制運(yùn)行GC,在我們方便的時(shí)候釋放未使用的內(nèi)存。
結(jié)論
我們已經(jīng)了解了GC在Unity中的工作原理,為什么會(huì)導(dǎo)致性能問(wèn)題,以及如何最大限度地減少對(duì)我們游戲的影響。 使用這些知識(shí)和分析工具,我們可以解決與GC相關(guān)的性能問(wèn)題,并構(gòu)建我們的游戲,以便有效地管理內(nèi)存。