05. 理解托管堆【上】

這是摘自Unity官方文檔有關(guān)優(yōu)化的部分,原文鏈接:https://docs.unity3d.com/Manual/BestPracticeUnderstandingPerformanceInUnity.html
總共分為如下系列:

  1. 采樣分析
  2. 內(nèi)存部分
  3. 協(xié)程
  4. Asset審查
  5. 理解托管堆 【推薦閱讀】
    5.1 上篇:原理,臨時分配內(nèi)存,集合和數(shù)組
    5.2 下篇:閉包,裝箱,數(shù)組
  6. 字符串和文本
  7. 資源目錄
  8. 通用的優(yōu)化方案
  9. 一些特殊的優(yōu)化方案

這個部分較長,所以分成兩個部分。
下篇參見


Unity開發(fā)者面對的另一個常見的問題是托管堆過大。在Unity中托管堆變大更容易,減小就沒那么容易了。而且,Unity的垃圾回收策略很容易產(chǎn)生內(nèi)存碎片,回收這些內(nèi)存碎片比較困難,進一步加劇了問題的嚴重性。

技術(shù)細節(jié):內(nèi)存堆運作原理和擴張原因

托管堆指的是被工程中的腳本運行環(huán)境(Mono或者IL2CPP)的內(nèi)存管理器控制的一塊內(nèi)存區(qū)域。在托管代碼中創(chuàng)建的所有引用類型的對象都會分配到托管堆中。【嚴格意義來講,所有非空引用類型對象和所有裝箱之后的值對象都要分配到托管堆中】

托管堆示例

在上圖中,白色方框表示由托管堆分配的一塊內(nèi)存,有顏色的方框表示存儲在托管堆的數(shù)據(jù)對象。當(dāng)空間不足,需要添加其他對象的時候,托管堆中會分配更多的空間以滿足需求。

垃圾回收器會定期運行【具體的時間由平臺決定】。垃圾回收器會掃描堆中的所有對象,標(biāo)記沒有被引用的對象,這些對象需要被刪除來釋放內(nèi)存空間。

需要特別指出,Unity使用的垃圾回收算法是Boehm GC algorithm,這個算法是非分代式非壓縮的。非分代式意味著GC當(dāng)執(zhí)行回收操作的時候,要掃描整個堆,所以堆越大,性能越差。非壓縮表示內(nèi)存中的對象不會被移動,進行壓縮,所以會有內(nèi)存碎片產(chǎn)生。

內(nèi)存回收示例

上面的圖展示了內(nèi)存碎片化的例子。當(dāng)對象被釋放的時候,內(nèi)存會空出一塊區(qū)域。但是,釋放的內(nèi)存空間不會成為被放到某個整塊的可用內(nèi)存池中。這塊空余內(nèi)存左右部分的內(nèi)存仍然會被其他對象所使用。所以這樣就會造成內(nèi)存之間出現(xiàn)空隙,如圖中的紅色圓圈表示的部分。這塊新釋放的內(nèi)存空間只能夠用來分配給和這塊區(qū)域相等或者更小的對象使用。

當(dāng)分配對象的時候,需要記住,對象一定要占用內(nèi)存空間中的某個連續(xù)塊。

所以這樣就會導(dǎo)致內(nèi)存碎片化:即使整個堆總共的可用內(nèi)存空間很多,但是大部分空間都是存在于已經(jīng)被分配的對象之間的間隙之中。這種情況下,即使總共的空間夠用,但是卻找不出連續(xù)的內(nèi)存空間用來滿足分配的需求。

產(chǎn)生內(nèi)存碎片

所以,當(dāng)某個大對象需要被分配的時候,并且內(nèi)存中沒有足夠的連續(xù)區(qū)域用來存放,Unity的內(nèi)存管理器就會執(zhí)行兩個操作。

  1. 如果GC沒有運行過,先運行GC操作,試圖能夠釋放更多的空間滿足需求;
  2. 如果GC運行之后,依舊沒有足夠的連續(xù)空間滿足需求,堆就會擴大。堆擴大的具體值和平臺有關(guān),大多數(shù)的Unity平臺執(zhí)行的操作是雙倍擴大堆內(nèi)存。

有關(guān)堆的關(guān)鍵問題

  • 當(dāng)托管堆擴展之后,Unity并不會經(jīng)常再去釋放掉這些內(nèi)存頁,它會繼續(xù)持有擴展加入的這部分內(nèi)存空間,即使有很大的一部分并未被利用。這樣是為了防止當(dāng)收回內(nèi)存之后繼續(xù)出現(xiàn)再次擴展內(nèi)存堆,減少這部分的開銷。
  • 在大多數(shù)平臺上,Unity最終會釋放掉托管堆中未被占用的內(nèi)存頁,交還給操作系統(tǒng)。但是什么時候,什么頻率進行這些操作,無法知曉,也不應(yīng)該依靠這些操作。
  • 托管堆用到的尋址空間從來不會還給操作系統(tǒng)。
  • 對于32位程序而言,如果托管堆反復(fù)擴展和收縮,會造成尋址空間被耗盡。當(dāng)尋址空間被耗盡的時候,操作系統(tǒng)會強制關(guān)閉應(yīng)用。
  • 對于64位程序而言,尋址空間足夠大,所以不太可能會出現(xiàn)尋址空間被耗盡的情況。

臨時分配

很多Unity工程都被發(fā)現(xiàn)每幀都會有幾十甚至幾百KB的臨時數(shù)據(jù)被分配給托管堆。這對一個項目的性能而言非常糟糕??紤]以下的數(shù)學(xué)計算:

如果一個程序在每幀都會分配1KB的臨時內(nèi)存,幀率60FPS,每秒就必須分配60KB的臨時內(nèi)存。一分鐘之后,內(nèi)存中就會多出3.6MB的垃圾。每秒執(zhí)行GC就會影響性能,而每分鐘要分配3.6MB的內(nèi)存對于低端設(shè)備而言問題非常嚴重。

更近一步,考慮到加載操作。如果在一個重度的Asset加載操作過程中產(chǎn)生了大量的臨時Object,直到操作完成真正的對象才會被引用,所以GC不能再加載過程中釋放掉這些臨時的Object,托管堆需要擴展,雖然很短之后這些臨時的內(nèi)存會被釋放掉。

通過剖析器查看GC

跟蹤托管堆分配相對比較容易。在Unity的CPU剖析器中,Overview中有一列“GC Alloc”。這一列展示了在某一幀有多少字節(jié)分配給了托管堆。【注意,這個參數(shù)并不等同于該幀分配了多少臨時字節(jié)大小。剖析器只會顯示在某一幀之內(nèi)分配的總內(nèi)存大小,即使有部分或者所有的內(nèi)存會在后面幾幀被重新利用】。當(dāng)在“Deep Profiling”模式下,可以追蹤到是在哪些方法里面執(zhí)行了這些分配。

Unity剖析器并不會追蹤不在主線程中分配的內(nèi)存,所有“GC”一列并不會顯示用戶自己創(chuàng)建的線程分配了多少內(nèi)存。如果需要檢測,最好是把這部分的代碼從子線程移動到主線程中進行分析。

如果是需要在真機上進行偵測,一定要打development build包。

注意,部分腳本方法只會在Editor中運行的時候才會分配內(nèi)存,當(dāng)打包到真機后,這部分代碼并沒有分配內(nèi)存。GetComponent方法是最常見的例子;這個方法在Editor中運行的時候會分配內(nèi)存,但是在打包好的工程中不會分配內(nèi)存。

通常來講,當(dāng)工程只要處在可交互狀態(tài)下時,開發(fā)者應(yīng)該盡可能減少堆內(nèi)存分配。非交互的情況下,如場景加載,則很少會出現(xiàn)問題。

Visual Studio的Jetbrains Resharper Plugin可以找到進行分配的代碼。

使用Unity的Deep Profile模式也可以找到托管堆內(nèi)存分配的具體原因。在Deep Profile模式下,所有的方法調(diào)用都被記錄,會提供一個更清楚的方法調(diào)用樹形圖,可以更方便的確定堆內(nèi)存分配。Deep Profile只在Editor下面才可行,最好不要在真機設(shè)備上使用。

基本的內(nèi)存保護方案

有一些非常簡單易操作的技術(shù)可以用來減少托管堆的內(nèi)存分配。

集合類和數(shù)組重復(fù)利用

當(dāng)使用C#中的Collection類或者數(shù)組的時候,應(yīng)該考慮盡可能重用或者池化管理已經(jīng)分配的內(nèi)存空間。Collection類雖然暴露了Clear方法用來置空某個類,但是并沒有釋放被分配的內(nèi)存空間。

void Update() {
    
    List<float> nearestNeighbors = new List<float>();

    findDistancesToNearestNeighbors(nearestNeighbors);

    nearestNeighbors.Sort();

    // … use the sorted list somehow …

}

這對于為了進行某些負責(zé)運算臨時分配的“helper”Collection類。下面的代碼是個非常簡單的例子:
在這個例子里,nearestNeighbors列表每幀都會進行分配內(nèi)存,用來收集數(shù)據(jù)點。將該列表提取為這個類的私有變量就可以避免每幀進行分配List的內(nèi)存。

List<float> m_NearestNeighbors = new List<float>();

void Update() {

    m_NearestNeighbors.Clear();

    findDistancesToNearestNeighbors(NearestNeighbors);

    m_NearestNeighbors.Sort();

    // … use the sorted list somehow …

}

上面的版本就是優(yōu)化之后的版本,List部分的內(nèi)存可以反復(fù)利用,只有列表空間不夠的時候才會再次進行分配。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容