05. 理解托管堆【上】

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

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

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


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

技術細節:內存堆運作原理和擴張原因

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

托管堆示例

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

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

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

內存回收示例

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

當分配對象的時候,需要記住,對象一定要占用內存空間中的某個連續塊。

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

產生內存碎片

所以,當某個大對象需要被分配的時候,并且內存中沒有足夠的連續區域用來存放,Unity的內存管理器就會執行兩個操作。

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

有關堆的關鍵問題

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

臨時分配

很多Unity工程都被發現每幀都會有幾十甚至幾百KB的臨時數據被分配給托管堆。這對一個項目的性能而言非常糟糕。考慮以下的數學計算:

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

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

通過剖析器查看GC

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

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

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

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

通常來講,當工程只要處在可交互狀態下時,開發者應該盡可能減少堆內存分配。非交互的情況下,如場景加載,則很少會出現問題。

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

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

基本的內存保護方案

有一些非常簡單易操作的技術可以用來減少托管堆的內存分配。

集合類和數組重復利用

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

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

    findDistancesToNearestNeighbors(nearestNeighbors);

    nearestNeighbors.Sort();

    // … use the sorted list somehow …

}

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

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

void Update() {

    m_NearestNeighbors.Clear();

    findDistancesToNearestNeighbors(NearestNeighbors);

    m_NearestNeighbors.Sort();

    // … use the sorted list somehow …

}

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

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容