GC

優化垃圾內存

介紹

當我們運行遊戲的時候,使用內存純粹數據。當這些數據不需要的時候,這些內存就會釋放以便於我們重新使用。垃圾就是不被使用的數據佔用的內存。垃圾收集就是處理這些內存仍舊可以被利用的過程的名稱。

unity使用垃圾回收來管理一部分內存。如果垃圾收集發生的太頻繁或者處理的工作太多就會造成性能低下,這也就是意味着垃圾收集是影響性能問題的一個普遍的原因。

在這篇文章中我們會學到當垃圾回收發生時候垃圾收集器怎麼工作的和怎樣有效的使用內存以便於縮小垃圾收集對我們遊戲性能的影響.

垃圾收集問題診斷

垃圾收集主要帶來的性能問題主要是幀率下降, (jerky performance or intermittent freezes) 大概意思是遊戲開起來很卡。然而 其他因數也可能帶來的這樣的問題。如果我們的遊戲有這樣性能問題,第一件事情就是應該使用 unity的profiler 窗口來確定是不是垃圾回收帶來的。

Unity內存管理簡介

爲了理解垃圾收集器何時發生和怎麼工作,我們必須首先了解內存在unity中怎麼使用。首先,我們必須瞭解在運行自己的核心引擎代碼和運行我們在腳本中編寫的代碼時,Unity使用了不同的方法。

在運行自己的核心統一引擎代碼時,Unity管理內存的方式稱爲手動內存管理。這意味着核心引擎代碼必須顯式地說明如何使用內存。手動內存管理不使用垃圾收集,在本文中不再贅述。

在運行我們的代碼時,Unity管理內存的方式稱爲自動內存管理。這意味着我們的代碼不需要明確地告訴Unity如何以一種詳細的方式管理內存。unity已經爲我們考慮好了。

在最基本的層面上,unity的自動內存管理工作如下:

       1 .Unity可以訪問兩個內存池:堆棧和堆(也稱爲託管堆)。堆棧用於短期存儲小塊數據,堆用於長期存儲和較大的數據塊。
       2.當創建一個變量時,Unity會從堆棧或堆中請求一個內存塊。

       3.只要變量在範圍內(我們的代碼仍然可以訪問),分配給它的內存仍然在使用中。我們稱內存已經分配。我們將在堆棧內存            中保存的變量描述爲堆棧上的對象,並將在堆內存中保存的變量作爲堆中的對象。

       4.當變量不可以訪問的時候,內存便不再使用並且可以歸還給他來自的內存池了。當內存返回內存池的是我們稱內存被釋              放了.一旦變量不在可以訪問的時候分配的棧內存就會立即釋放。然而堆內存在這種情況下不會立即釋放並且會仍舊                    保留一段 時間。

        5.垃圾回收器識別並釋放未使用的堆內存。垃圾收集器定期運行以清理堆。

既然我們已經瞭解了事件的流程,那麼讓我們更仔細地看看堆棧分配和處理位置與堆分配和處理位置的區別。站

在堆棧分配和處理過程中會發生什麼?

棧的分配和回收是快速和簡單的。這是因爲堆棧只是用來儲存使用很短的時間的小數據。分配和回收總是以可預測的順序發生,並且具有可預測的大小。

堆棧的工作方式類似於堆棧數據類型:它是一個簡單的元素集合,在這個內存塊中,元素只能以嚴格的順序添加和刪除。這種簡單性和嚴格性使它如此快速:當一個變量被存儲在堆棧上時,它的內存只是從棧頭分配。當堆棧變量超出範圍時,用於存儲該變量的內存將立即返回到堆棧中以便重用。

在堆分配時候發生什麼?

堆分配比堆棧分配要複雜得多。這是因爲堆可以用來存儲長期和短期數據,以及許多不同類型和大小的數據。分配和釋放並不總是以可預測的順序發生,並且可能需要非常不同大小的內存塊。

創建堆變量時,將執行以下步驟:
       1. 首先,Unity必須檢查堆中是否有足夠的空閒內存。如果堆中有足夠的空閒內存,則分配該變量的內存。
       2.如果堆中沒有足夠的空閒內存,Unity就會觸發垃圾收集器,以釋放未使用的堆內存。這可能是一個緩慢的操作。如果堆           中有足夠的空閒內存,則分配該變量的內存。

       3.如果垃圾收集後堆中沒有足夠的空閒內存,Unity就會增加堆中的內存。這可能是一個緩慢的操作。然後分配該變量的內           存。

堆分配可能很慢,特別是如果垃圾收集器必須運行,堆必須被擴展。

在垃圾收集過程中發生了什麼?

當堆變量超出範圍時,用於存儲它的內存不會立即被釋放。未使用的堆內存只有在垃圾收集器運行時纔會釋放。
每次垃圾收集器運行時,都會發生以下步驟:
      垃圾收集器檢查堆上的每個對象。
      垃圾收集器搜索所有當前對象引用,以確定堆上的對象是否仍然在範圍內。
      任何不再在範圍內的對象被標記爲刪除。

      被標記的對象被刪除,分配給它們的內存被返回給堆。

垃圾收集可能是一項昂貴的操作。堆上的對象越多,它必須做的工作越多,在我們的代碼中引用的對象越多,它必須做的工作就越多

垃圾收集何時發生

有三件事情會導致垃圾收集器運行:

       每當請求堆分配時,當使用空閒內存從堆中無法實現時候垃圾收集器就會運行
       垃圾收集器會自動地運行(儘管頻率因平臺而異)。

       垃圾收集器可以強制手動運行。

垃圾收集可以是一個頻繁的操作。每當從可用堆內存中無法實現堆分配時,就會觸發垃圾收集器,這意味着頻繁的堆分配和分配會導致頻繁的垃圾收集。

垃圾收集帶來的問題

現在我們瞭解了垃圾收集在統一內存管理中發揮的作用,我們可以考慮可能出現的問題類型。

最明顯的問題是垃圾收集器可以花相當長的時間運行。如果垃圾收集器在堆上有很多對象和或大量對象引用來檢查,那麼檢查所有這些對象的過程可能會很慢。這可以使我們的遊戲變得很卡或運行緩慢。

另一個問題是垃圾收集器可能在不方便的時候運行。如果CPU已經在我們遊戲的性能關鍵部分中努力工作,即使是垃圾收集的少量額外開銷也會導致我們的幀速率下降,性能顯著改變。

另一個不太明顯的問題是堆碎片。當內存從堆中分配時,根據必須存儲的數據的大小,從不同大小的塊中提取內存。當這些內存塊返回到堆時,堆就會被分割成許多小的空閒塊,這些塊被分配的塊分隔開。這意味着,儘管空閒內存總量可能很高,但我們無法在不運行垃圾收集器和或擴展堆的情況下分配大塊內存,因爲現有的塊都不夠大。

碎片堆有兩個後果。首先,我們遊戲的內存使用量將會比它需要的要高,第二個是垃圾收集器將會運行得更頻繁。關於堆碎片化的更詳細的討論,請參考this Unity best practice guide on performance.

發現堆分配

如果我們知道垃圾收集在我們的遊戲中造成了問題,我們需要知道我們的代碼中哪些部分產生了垃圾。當堆變量超出範圍時產生垃圾,所以首先我們需要知道是什麼原因導致在堆上分配一個變量。

什麼是在棧上和堆上分配(內存)

在Unity中,值類型的局部變量被分配到堆棧上,其他的都分配在堆上。如果您不確定在Unity中值和引用類型之間的區別,請參閱 this tutorial.

下面的代碼是一個堆棧分配的例子,因爲變量localInt是本地的和值類型的。分配給該變量的內存將在該函數完成運行後立即從堆棧中釋放。

void ExampleFunction()
{
    int localInt = 5;
}

下面的代碼是堆分配的一個示例,因爲變量localList是本地的,但是是引用類型的。當垃圾收集器運行時,分配給該變量的內存將被釋放。

void ExampleFunction()
{
    List localList = new List();

}

使用“Profiler”窗口找到堆分配

通過profiler窗口我們可以看到我們的代碼在堆分配情況(由於官網圖片模糊不清自己截取了一個本地的湊合能看)

在選擇了CPU使用profiler之後,我們可以選擇任何幀來查看在profiler窗口底部的那個的CPU使用數據的fame。其中一列數據

稱爲GC alloc。這一列顯示了在該Fram中正在進行的堆分配。如果我們選擇列標頭,我們可以根據這個統計數據對數據進行排序,

這樣就可以很容易地看到我們的遊戲中哪些函數導致了最多的堆分配。一旦我們知道了哪個函數導致堆分配,我們就可以檢查這個函

數。一旦我們知道函數內的代碼導致了垃圾生成,我們就可以決定如何解決這個問題,並最小化生成的垃圾數量。

減少垃圾收集的影響

一般來說,我們可以通過以下三種方式減少垃圾收集對我們遊戲的影響:

      我們可以減少垃圾收集器運行的時間。
      我們可以減少垃圾收集器運行的頻率。

      我們可以故意地觸發垃圾收集器,這樣它就會在沒有性能臨界的情況下運行,例如在加載屏幕上。

瞭解這些以後,我們可以有三種方法來幫助我們

我們可以組織我們的遊戲,這樣我們的堆分配就更少,對象引用也更少。堆上的對象越少,檢查的引用就越少,這意味着當垃圾收集被觸發時,運行的時間就更少了。

我們可以減少堆分配和分配的頻率,特別是在性能關鍵時刻。更少的分配和分配意味着更少的觸發垃圾收集的場合。這也降低了堆碎片的風險。

我們可以嘗試時間垃圾收集和堆擴展,以便它們在可預測和方便的時間發生。這是一種更加困難和不可靠的方法,但是當作爲整體內存管理策略的一部分使用時,可以減少垃圾收集的影響。

減少創建的垃圾數量

讓我們來看看幾種可以減少垃圾產生的代碼

緩存

如果我們的代碼反覆調用產生堆內存我們可以規避產生這種不必要的垃圾。我們應該保留這些對象的引用一邊與重複利用。這種技術就是衆所周知的緩存。

在下面一個例子中這個代碼每次調用由於都會創建一個新的數組所以每次都會產生堆內存分配

void OnTriggerEnter(Collider other)
{
    Renderer[] allRenderers = FindObjectsOfType<Renderer>();
    ExampleFunction(allRenderers);
}

下面的代碼只導致一個堆分配,因爲數組被創建和填充一次,然後緩存。緩存的數組可以一次又一次地重複使用,而不會產生更多的垃圾。

private Renderer[] allRenderers;

void Start()
{
    allRenderers = FindObjectsOfType<Renderer>();
}


void OnTriggerEnter(Collider other)
{
    ExampleFunction(allRenderers);
}

不要在頻繁的調用函數中進行堆內存分配

如果我們必須在monobihaviour中分配堆內存,那麼我們所能做的最壞的地方就是頻繁運行的函數。例如,Update()和LateUpdate()在每個幀中被調用一次,所以如果我們的代碼在這裏生成垃圾,那麼它將會很快地累積起來。我們應該儘可能考慮在Start()或在Awake()中對象進行緩存引用,或者確保內存分配的代碼只在需要時運行。

讓我們看一個非常簡單的移動代碼示例,它只在發生變化時運行。在下面的代碼中,每次調用Update()都會調用一個導致內存分配的函數,它經常創建垃圾:

void Update()
{
    ExampleGarbageGeneratingFunction(transform.position.x);
}

通過簡單的更改,我們現在確保只在Transform.position.x發生變化時候分配函數調用。我們現在只在必要時進行堆分配,而不是在每一幀中.

void Update()
{
    float transformPositionX = transform.position.x;
    if (transformPositionX != previousTransformPositionX)
    {
        ExampleGarbageGeneratingFunction(transformPositionX);
        previousTransformPositionX = transformPositionX;
    }
}

清除集合

創建新集合會導致堆上的分配。如果我們發現我們在代碼中不止一次地創建新集合,那麼應該將引用緩存到集合中,並使用Clear()清空其內容,而不是重複調用新內容。

在下面的例子中每幀執行update時候一個新的堆分配都會發生

void Update()
{
    List myList = new List();
    PopulateList(myList);
}

在下面的示例中,只有在創建集合或在集合必須調整大小時候纔會發生內存分配。這大大減少了產生的垃圾數量。

private List myList = new List();
void Update()
{
    myList.Clear();
    PopulateList(myList);
}

對象池

(看到這個你一定不陌生)

即使我們減少了腳本中的分配,如果在運行時創建和銷燬大量對象,我們仍然可能存在垃圾收集問題。對象池是一種通過重用對象而不是重複地創建和銷燬對象來減少分配和交換位置的技術。對象池在遊戲中被廣泛使用,最適合於我們頻繁地生成和銷燬相似對象的情況;舉個例子,從槍裏射出子彈。

對對象池的完整介紹超出了本文的範圍,但它是一種非常有用的技術,值得學習。這篇關於統一學習網站上對象池的教程This tutorial on object pooling on the Unity Learn site

常引起不必要的內存分配的情況

我們瞭解了 局部變量、值類型的變量是在堆棧上分配的,而其他所有的變量都是在堆上分配的。然而,有很多情況下堆分配可能會讓我們大吃一驚。讓我們來看看一些不必要的堆分配的常見原因,並考慮如何最好地減少它們。

string

在c#中,字符串是引用類型,而不是值類型,即使它們似乎持有字符串的“值”。這意味着創建和刪除字符串會創建垃圾。由於字符串在很多代碼中都是常用的,所以這種垃圾真的可以累加起來。

c#中的字符串也是不可變的,這意味着它們的值在創建後不能更改。每次我們操作一個字符串(例如,通過使用+運算符將兩個字符串連接起來),Unity就會創建一個新的字符串,並使用更新後的值,並丟棄舊的字符串。這就產生了垃圾。

我們可以遵循一些簡單的規則,將垃圾從字符串保持到最小值。讓我們考慮一下這些規則,然後看看如何應用它們的示例。

      我們應該減少不必要的字符串創建。如果我們使用相同的字符串值不止一次,我們應該創建一次字符串並緩存該值。

      我們應該減少不必要的字符串操作。例如,如果我們有一個經常更新的文本組件,並且包含一個連接的字符串,我們可以考        慮將它分爲兩個文本組件。

      如果我們必須在運行時構建字符串,我們應該使用StringBuilder類。StringBuilder類是爲構建字符串而設計的,它不需要分        配,並且可以節省在連接複雜字符串時產生的垃圾數量.

      我們應該在不需要調試的情況下立即刪除對Debug.Log()的調用。對Debug.Log()的調用仍然在我們遊戲的所有構建中執            行,即使它們沒有輸出到任何東西。對Debug.Log()的調用創建和處理至少一個字符串,因此,如果我們的遊戲包含許多這        樣的調用,則垃圾可以相加。

Unity 方法調用

重要的是要意識到,當我們調用代碼時,不管它是在Unity中還是在插件中,我們都可以生成垃圾。一些Unity函數調用會創建堆分配,因此應該小心使用,以避免生成不必要的垃圾。

沒有我們應該避免的函數列表。每個函數在某些情況下都是有用的,在其他情況下用處不大。與以往一樣,最好仔細地剖析我們的遊戲,確定在哪裏創建了垃圾,並仔細考慮如何處理它。在某些情況下,緩存函數的結果可能是明智的;在其他情況下,更少地調用函數是明智的;在其他情況下,最好重構代碼以使用不同的函數。說到這一點,讓我們來看看幾個Unity函數的例子,它們會引起堆分配,並考慮如何最好地處理它們。

每當我們訪問一個返回一個數組的unity函數時候,就會創建一個新的數組並將其作爲返回值傳遞給我們。這種行爲並不總是明顯的或預期的,特別是當函數是訪問器(例如,Mesh.normals)時。

下面的例子代碼中,每次迭代循環都會創建一個新數組

void ExampleFunction()
{
    for (int i = 0; i < myMesh.normals.Length; i++)
    {
        Vector3 normal = myMesh.normals[i];
    }
}
在這樣的情況下,很容易減少分配:我們可以簡單地緩存對數組的引用。當我們這樣做時,只創建一個數組,並相應地減少創建的垃圾數量。

下面的代碼演示了這一點。在這種情況下,在循環之前我們調用Mesh.normals並緩存這個引用一次只創建一個數組

void ExampleFunction()
{
    Vector3[] meshNormals = myMesh.normals;
    for (int i = 0; i < meshNormals.Length; i++)
    {
        Vector3 normal = meshNormals[i];
    }
}
堆分配的另一個意想不到的原因是在函數的GameObject.name或GameObject.tag中。它們都是返回新字符串的訪問器,這意味着調用這些函數將生成垃圾。緩存值可能是有用的,但是在這種情況下,我們可以使用一個對用的Unity函數來代替。爲了在不生成垃圾的情況下檢查GameObject的標籤,我們可以使用GameObject. comparetag()。

在下面的示例代碼中,通過調用GameObject.tag創建了垃圾。

private string playerTag = "Player";

void OnTriggerEnter(Collider other)
{
    bool isPlayer = other.gameObject.tag == playerTag;
}

如果我們使用GameObject.CompareTag()這個函數就不會產生垃圾

private string playerTag = "Player";

void OnTriggerEnter(Collider other)
{
    bool isPlayer = other.gameObject.CompareTag(playerTag);
}

GameObject.CompareTag isn’t unique; many Unity function calls have alternative versions that cause no heap allocations. For example, we could use Input.GetTouch() and Input.touchCount in place of Input.touches, or Physics.SphereCastNonAlloc() in place of Physics.SphereCastAll().

Boxing

裝箱是指當一個值類型的變量被用來代替一個引用類型的變量時發生的事情。當我們將值類型的變量(例如ints或浮點數)傳遞給一個具有對象參數(如object . equals())的函數時,通常會發生裝箱。

例如,函數string . format()採用字符串和對象參數。當我們傳遞一個字符串和一個整數時,int必須被裝箱。因此下面的代碼包含了一個裝箱示例:


void ExampleFunction()
{
    int cost = 5;
    string displayString = String.Format("Price: {0} gold", cost);
}

     因爲在場景裏裝箱會產生垃圾。當一個值類型變量被裝箱時,Unity會在堆上創建一個臨時對象來包住這個值類型變量。於是當這個臨時對象被處理時候就會產生垃圾。

裝箱是不必要的堆分配的一個非常常見的原因。即使我們不直接在代碼中直接設置變量來裝箱,我們使用的插件也可能會導致裝箱發生,或者場景裏的其他功能會產生裝箱。最好的做法是儘可能避免裝箱,並移除導致裝箱的任何功能調用。

Coroutines

調用StartCoroutine()會創建了少量的垃圾,因爲Unity必須創建一個實例子來管理coroutine。考慮到這一點,對StartCoroutine()的調用應該是有限的,而我們的遊戲是交互式的,性能是一個問題。爲了減少以這種方式創建的垃圾,在性能關鍵時刻必須運行的任何coroutines都應該提前啓動,在使用可能包含延遲調用StartCoroutine()的嵌套coroutines時,我們應該特別小心。

coroutines中的yield語句並沒有在自己的使用範圍內創建堆分配;但是,我們使用yield語句傳遞的值可能會造成不必要的堆分配。例如,下面的代碼創建了垃圾:

yield return 0;

此代碼創建垃圾,因爲值爲0的int被裝箱。在這種情況下,如果我們希望簡單地等待一個框架而不引起任何堆分配,那麼最好的方法就是使用以下代碼:

yield return null;


coroutines的另一個常見錯誤是可以使用相同值時候仍然去創建新值。例如,下面的代碼每次循環迭代時處理WaitForSeconds對象時候都會創建新對象

while (!isComplete)
{
    yield return new WaitForSeconds(1f);
}
如果我們緩存和重用WaitForSeconds對象,就會創建更少的垃圾。下面的代碼展示了這個示例:
WaitForSeconds delay = new WaitForSeconds(1f);

while (!isComplete)
{
    yield return delay;
}

如果我們的代碼生成了大量的垃圾,我們可能會考慮重構我們的代碼來使用除coroutines以外的其他東西。重構代碼是一個複雜的主題,每個項目都是獨一無二的,但是有一些共同的替代方案,我們可能希望記住。例如,如果我們主要使用coroutines來管理時間,我們可能希望簡單地跟蹤Upate()函數中的時間。如果我們使用coroutines主要是爲了控制遊戲中發生的事情的順序,我們可能希望創建某種類型的消息傳遞系統,以允許對象進行通信。沒有一種方法適合通用,但是記住,在代碼中實現相同的方法常常不止一種方法。

foreach loops

unity 5.5 版本前循環遍歷都會產生垃圾用while for 替換就可以,5.5版本後不會了所以不再敘述

方法引用

方法的引用,不管是匿名方法,還是命名的方法(其實也就是類似於委託),在untiy中都當引用類型來處理。將匿名方法轉換爲閉包(匿名方法在創建時可以訪問範圍內的變量)會顯著增加內存使用量和堆分配的數量。

方法引用的精確細節和閉包分配內存依據不同的平臺和編譯設定。但是如果垃圾收集是一個問題的話,請減少方法參數引用和閉包使用。This Unity best practice guide on performance有這方面技術詳細概述。

LINQ 和正則表達式

由於LINQ和正則表達式會產生裝箱所以會產生垃圾。

機構化代碼來減少垃圾產生

我們的代碼結構化的方式可以影響垃圾收集。即使我們的代碼沒有創建堆分配,它也可以增加垃圾收集器的工作負載。

我們的代碼不必要地添加到垃圾收集器的工作負載的一種方法是,要求它檢查它不需要檢查的東西。struct是值類型的變量,但是如果我們有一個包含了引用類型變量的結構,那麼垃圾收集器必須檢查整個結構。如果我們有大量的這些結構,那麼這將爲垃圾收集器創建大量額外的工作。

在這個示例中,struct包含一個字符串,它是引用類型。現在,整個結構的數組必須在運行時由垃圾收集器檢查。

public struct ItemData
{
    public string name;
    public int cost;
    public Vector3 position;
}
private ItemData[] itemData;

在本例中,我們將數據存儲在單獨的數組中。當垃圾收集器運行時,它只需要檢查字符串數組,並且可以忽略其他數組。這減少了垃圾收集器必須做的工作。

private string[] itemNames;
private int[] itemCosts;
private Vector3[] itemPositions;

我們的代碼不必要地添加到垃圾收集器的工作負載的另一種方式是使用不必要的對象引用。當垃圾收集器在堆上搜索對對象的引用時,它必須檢查我們代碼中的每個當前對象引用。在我們的代碼中有較少的對象引用意味着它有更少的工作要做,即使我們不減少堆上的對象總數。

在本例中,我們有一個填充對話框的類。當用戶看到對話框時,會顯示另一個對話框。我們的代碼包含了一個關於應該顯示的對話數據的下一個實例的引用,這意味着垃圾收集器必須檢查此引用作爲其操作的一部分:

public class DialogData
{
    private DialogData nextDialog;

    public DialogData GetNextDialog()
    {
        return nextDialog;
    }
}


這裏,我們對代碼進行了重構,以便它返回一個標識符,該標識符用於查找DialogData的下一個實例,而不是實例本身。這不是一個對象引用,因此它不會添加到垃圾收集器所佔用的時間。

public class DialogData
{
    private int nextDialogID;

    public int GetNextDialogID()
    {
        return nextDialogID;
    }
}

就其本身而言,這個示例非常簡單。但是,如果我們的遊戲中包含了大量引用其他對象的對象,那麼我們可以通過重構我們的代碼來大大降低堆的複雜度。

垃圾收集時間

手動強制垃圾收集

最後,我們可能希望自己觸發垃圾收集。如果我們知道堆內存被分配,但不再使用(例如,如果我們的代碼生成的垃圾),我們直接可以手動觸發。

結論

我們已經瞭解了垃圾收集是如何在unity中工作的,爲什麼它會導致性能問題,以及如何減少它對我們遊戲的影響。利用這些知識和分析工具,我們可以修復與垃圾收集和結構相關的性能問題,從而有效地管理內存。

下面的鏈接提供了本文中涉及的主題的進一步信息。

Unity Manual: Understanding Optimization in Unity

Unity Manual: Understanding Automatic Memory Management

Gamasutra: C# Memory Management for Unity Developers by Wendelin Reich

Gamasutra: C# memory and performance tips for Unity by Robert Zubek

Gamasutra: Reducing memory allocations to avoid Garbage Collection on Unity by Grhyll JDD

Gamasutra: Unity Garbage Collection Tips and Tricks by Megan Hughes

Boxing

MSDN: Boxing and Unboxing (C# Programming Guide)

Object pooling

Unity Learn: Object Pooling Tutorial

Wikipedia: Object Pool Pattern

Strings

Best Practices for Using Strings in the .NET Framework


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章