Unity優化之GC——合理優化Unity的GC

     轉載請標明出處http://www.cnblogs.com/zblade/編程

  最近有點繁忙,白天干活晚上抽空寫點翻譯,還要運動,因此翻譯工做進行的有點緩慢 =。=c#

  PS: 最近從新回來更新了一遍,文章仍是須要反覆修改才能寫的順暢,多謝各位的支持 :D數組

  本文續接前面的unity的渲染優化,進一步翻譯Unity中的GC優化,英文連接在下:英文地址緩存

介紹:性能優化

  在遊戲運行的時候,數據主要存儲在內存中,當遊戲的數據在不須要的時候,存儲當前數據的內存就能夠被回收以再次使用。內存垃圾是指當前廢棄數據所佔用的內存,垃圾回收(GC)是指將廢棄的內存從新回收再次使用的過程。ide

  Unity中將垃圾回收看成內存管理的一部分,若是遊戲中廢棄數據佔用內存較大,則遊戲的性能會受到極大影響,此時垃圾回收會成爲遊戲性能的一大障礙點。函數

  本文咱們主要學習垃圾回收的機制,垃圾回收如何被觸發以及如何提GC收效率來提升遊戲的性能。性能

 

Unity內存管理機制簡介學習

  要想了解垃圾回收如何工做以及什麼時候被觸發,咱們首先須要瞭解unity的內存管理機制。Unity主要採用自動內存管理的機制,開發時在代碼中不須要詳細地告訴unity如何進行內存管理,unity內部自身會進行內存管理。這和使用C++開發須要隨時管理內存相比,有必定的優點,固然帶來的劣勢就是須要隨時關注內存的增加,不要讓遊戲在手機上跑「飛」了。測試

  unity的自動內存管理能夠理解爲如下幾個部分:

  1)unity內部有兩個內存管理池:堆內存和堆棧內存。堆棧內存(stack)主要用來存儲較小的和短暫的數據,堆內存(heap)主要用來存儲較大的和存儲時間較長的數據。

  2)unity中的變量只會在堆棧或者堆內存上進行內存分配,變量要麼存儲在堆棧內存上,要麼處於堆內存上。

  3)只要變量處於激活狀態,則其佔用的內存會被標記爲使用狀態,則該部分的內存處於被分配的狀態。

  4)一旦變量再也不激活,則其所佔用的內存再也不須要,該部份內存能夠被回收到內存池中被再次使用,這樣的操做就是內存回收。處於堆棧上的內存回收及其快速,處於堆上的內存並非及時回收的,此時其對應的內存依然會被標記爲使用狀態。

  5) 垃圾回收主要是指堆上的內存分配和回收,unity中會定時對堆內存進行GC操做。

  在瞭解了GC的過程後,下面詳細瞭解堆內存和堆棧內存的分配和回收機制的差異。

堆棧內存分配和回收機制

  堆棧上的內存分配和回收十分快捷簡單,由於堆棧上只會存儲短暫的或者較小的變量。內存分配和回收都會以一種順序和大小可控制的形式進行。

  堆棧的運行方式就像stack: 其本質只是一個數據的集合,數據的進出都以一種固定的方式運行。正是這種簡潔性和固定性使得堆棧的操做十分快捷。當數據被存儲在堆棧上的時候,只須要簡單地在其後進行擴展。當數據失效的時候,只須要將其從堆棧上移除。

 

堆內存分配和回收機制

  堆內存上的內存分配和存儲相對而言更加複雜,主要是堆內存上能夠存儲短時間較小的數據,也能夠存儲各類類型和大小的數據。其上的內存分配和回收順序並不可控,可能會要求分配不一樣大小的內存單元來存儲數據。

  堆上的變量在存儲的時候,主要分爲如下幾步:

  1)首先,unity檢測是否有足夠的閒置內存單元用來存儲數據,若是有,則分配對應大小的內存單元;

  2)若是沒有足夠的存儲單元,unity會觸發垃圾回收來釋放再也不被使用的堆內存。這步操做是一步緩慢的操做,若是垃圾回收後有足夠大小的內存單元,則進行內存分配。

  3)若是垃圾回收後並無足夠的內存單元,則unity會擴展堆內存的大小,這步操做會很緩慢,而後分配對應大小的內存單元給變量。

  堆內存的分配有可能會變得十分緩慢,特別是在須要垃圾回收和堆內存須要擴展的狀況下,一般須要減小這樣的操做次數。

垃圾回收時的操做

  當堆內存上一個變量再也不處於激活狀態的時候,其所佔用的內存並不會馬上被回收,再也不使用的內存只會在GC的時候纔會被回收。

  每次運行GC的時候,主要進行下面的操做:

  1)GC會檢查堆內存上的每一個存儲變量;

  2)對每一個變量會檢測其引用是否處於激活狀態;

  3)若是變量的引用再也不處於激活狀態,則會被標記爲可回收;

  4)被標記的變量會被移除,其所佔有的內存會被回收到堆內存上。

  GC操做是一個極其耗費的操做,堆內存上的變量或者引用越多則其運行的操做會更多,耗費的時間越長。

 什麼時候會觸發垃圾回收

   主要有三個操做會觸發垃圾回收:

   1) 在堆內存上進行內存分配操做而內存不夠的時候都會觸發垃圾回收來利用閒置的內存;

   2) GC會自動的觸發,不一樣平臺運行頻率不同;

   3) GC能夠被強制執行。

  特別是在堆內存上進行內存分配時內存單元不足夠的時候,GC會被頻繁觸發,這就意味着頻繁在堆內存上進行內存分配和回收會觸發頻繁的GC操做。

 

GC操做帶來的問題

  在瞭解GC在unity內存管理中的做用後,咱們須要考慮其帶來的問題。最明顯的問題是GC操做會須要大量的時間來運行,若是堆內存上有大量的變量或者引用須要檢查,則檢查的操做會十分緩慢,這就會使得遊戲運行緩慢。其次GC可能會在關鍵時候運行,例如在CPU處於遊戲的性能運行關鍵時刻,此時任何一個額外的操做均可能會帶來極大的影響,使得遊戲幀率降低。

  另一個GC帶來的問題是堆內存的碎片劃。當一個內存單元從堆內存上分配出來,其大小取決於其存儲的變量的大小。當該內存被回收到堆內存上的時候,有可能使得堆內存被分割成碎片化的單元。也就是說堆內存整體可使用的內存單元較大,可是單獨的內存單元較小,在下次內存分配的時候不能找到合適大小的存儲單元,這也會觸發GC操做或者堆內存擴展操做。

  堆內存碎片會形成兩個結果,一個是遊戲佔用的內存會愈來愈大,一個是GC會更加頻繁地被觸發。

 

分析GC帶來的問題

  GC操做帶來的問題主要表現爲幀率運行低,性能間歇中斷或者下降。若是遊戲有這樣的表現,則首先須要打開unity中的profiler window來肯定是不是GC形成。

  瞭解如何運用profiler window,能夠參考此處,若是遊戲確實是由GC形成的,能夠繼續閱讀下面的內容。

分析堆內存的分配

  若是GC形成遊戲的性能問題,咱們須要知道遊戲中的哪部分代碼會形成GC,內存垃圾在變量再也不激活的時候產生,因此首先咱們須要知道堆內存上分配的是什麼變量。

  堆內存和堆棧內存分配的變量類型

   在Unity中,值類型變量都在堆棧上進行內存分配,其餘類型的變量都在堆內存上分配。若是你不知道值類型和引用類型的差異,能夠查看此處

  下面的代碼能夠用來理解值類型的分配和釋放,其對應的變量在函數調用完後會當即回收:

void ExampleFunciton() { int localInt = 5; }

  對應的引用類型的參考代碼以下,其對應的變量在GC的時候纔回收:

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

  利用profiler window 來檢測堆內存分配:

   咱們能夠在profier window中檢查堆內存的分配操做:在CPU usage分析窗口中,咱們能夠檢測任何一幀cpu的內存分配狀況。其中一個選項是GC Alloc,經過分析其來定位是什麼函數形成大量的堆內存分配操做。一旦定位該函數,咱們就能夠分析解決其形成問題的緣由從而減小內存垃圾的產生。如今Unity5.5的版本,還提供了deep profiler的方式深度分析GC垃圾的產生。

 下降GC的影響的方法

   大致上來講,咱們能夠經過三種方法來下降GC的影響:

  1)減小GC的運行次數;

  2)減小單次GC的運行時間;

  3)將GC的運行時間延遲,避免在關鍵時候觸發,好比能夠在場景加載的時候調用GC

      彷佛看起來很簡單,基於此,咱們能夠採用三種策略:

  1)對遊戲進行重構,減小堆內存的分配和引用的分配。更少的變量和引用會減小GC操做中的檢測個數從而提升GC的運行效率。

  2)下降堆內存分配和回收的頻率,尤爲是在關鍵時刻。也就是說更少的事件觸發GC操做,同時也下降堆內存的碎片化。

  3)咱們能夠試着測量GC和堆內存擴展的時間,使其按照可預測的順序執行。固然這樣操做的難度極大,可是這會大大下降GC的影響。

 

減小內存垃圾的數量

   減小內存垃圾主要能夠經過一些方法來減小:

   緩存

   若是在代碼中反覆調用某些形成堆內存分配的函數可是其返回結果並無使用,這就會形成沒必要要的內存垃圾,咱們能夠緩存這些變量來重複利用,這就是緩存。

   例以下面的代碼每次調用的時候就會形成堆內存分配,主要是每次都會分配一個新的數組:

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

  對比下面的代碼,只會生產一個數組用來緩存數據,實現反覆利用而不須要形成更多的內存垃圾:

private Renderer[] allRenderers;

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

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

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

   在MonoBehaviour中,若是咱們須要進行堆內存分配,最壞的狀況就是在其反覆調用的函數中進行堆內存分配,例如Update()和LateUpdate()函數這種每幀都調用的函數,這會形成大量的內存垃圾。咱們能夠考慮在Start()或者Awake()函數中進行內存分配,這樣能夠減小內存垃圾。

  下面的例子中,update函數會屢次觸發內存垃圾的產生:

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

  經過一個簡單的改變,咱們能夠確保每次在x改變的時候才觸發函數調用,這樣避免每幀都進行堆內存分配:

private float previousTransformPositionX; void Update() { float transformPositionX = transform.position.x; if(transfromPositionX != previousTransformPositionX) { ExampleGarbageGenerationFunction(transformPositionX); previousTransformPositionX = trasnformPositionX; } }

  另外的一種方法是在update中採用計時器,特別是在運行有規律可是不須要每幀都運行的代碼中,例如:

void Update()
{
    ExampleGarbageGeneratiingFunction()
}

  經過添加一個計時器,咱們能夠確保每隔1s才觸發該函數一次:

private float timeSinceLastCalled;
private float delay = 1f;
void Update()
{
    timSinceLastCalled += Time.deltaTime;
    if(timeSinceLastCalled > delay)
    {
         ExampleGarbageGenerationFunction();
         timeSinceLastCalled = 0f;
    }
}
                   

  經過這樣細小的改變,咱們可使得代碼運行的更快同時減小內存垃圾的產生。

  附: 不要忽略這一個方法,在最近的項目性能優化中,我常常採用這樣的方法來優化遊戲的性能,不少對於固定時間的事件回調函數中,若是每次都分配新的緩存,可是在操做完後並不釋放,這樣就會形成大量的內存垃圾,對於這樣的緩存,最好的辦法就是當前週期回調後執行清除或者標誌爲廢棄。

   清除鏈表

  在堆內存上進行鏈表的分配的時候,若是該鏈表須要屢次反覆的分配,咱們能夠採用鏈表的clear函數來清空鏈表從而替代反覆屢次的建立分配鏈表。

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

  經過改進,咱們能夠將該鏈表只在第一次建立或者該鏈表必須從新設置的時候才進行堆內存分配,從而大大減小內存垃圾的產生:

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

  對象池

  即使咱們在代碼中儘量地減小堆內存的分配行爲,可是若是遊戲有大量的對象須要產生和銷燬依然會形成GC。對象池技術能夠經過重複使用對象來下降堆內存的分配和回收頻率。對象池在遊戲中普遍的使用,特別是在遊戲中須要頻繁的建立和銷燬相同的遊戲對象的時候,例如槍的子彈這種會頻繁生成和銷燬的對象。

  要詳細的講解對象池已經超出本文的範圍,可是該技術值得咱們深刻的研究This tutorial on object pooling on the Unity Learn site對於對象池有詳細深刻的講解。

  附:對象池技術屬於遊戲中比較通用的技術,若是有閒餘時間,你們能夠學習一下這方面的知識。

 

形成沒必要要的堆內存分配的因素

  咱們已經知道值類型變量在堆棧上分配,其餘的變量在堆內存上分配,可是任然有一些狀況下的堆內存分配會讓咱們感到吃驚。下面讓咱們分析一些常見的沒必要要的堆內存分配行爲並對其進行優化。

  字符串  

   在c#中,字符串是引用類型變量而不是值類型變量,即便看起來它是存儲字符串的值的。這就意味着字符串會形成必定的內存垃圾,因爲代碼中常用字符串,因此咱們須要對其格外當心。

  c#中的字符串是不可變動的,也就是說其內部的值在建立後是不可被變動的。每次在對字符串進行操做的時候(例如運用字符串的「加」操做),unity會新建一個字符串用來存儲新的字符串,使得舊的字符串被廢棄,這樣就會形成內存垃圾。

  咱們能夠採用如下的一些方法來最小化字符串的影響:

  1)減小沒必要要的字符串的建立,若是一個字符串被屢次利用,咱們能夠建立並緩存該字符串。

  2)減小沒必要要的字符串操做,例如若是在Text組件中,有一部分字符串須要常常改變,可是其餘部分不會,則咱們能夠將其分爲兩個部分的組件,對於不變的部分就設置爲相似常量字符串便可,見下面的例子。

  3)若是咱們須要實時的建立字符串,咱們能夠採用StringBuilderClass來代替,StringBuilder專爲不須要進行內存分配而設計,從而減小字符串產生的內存垃圾。

  4)移除遊戲中的Debug.Log()函數的代碼,儘管該函數可能輸出爲空,對該函數的調用依然會執行,該函數會建立至少一個字符(空字符)的字符串。若是遊戲中有大量的該函數的調用,這會形成內存垃圾的增長。

  在下面的代碼中,在Update函數中會進行一個string的操做,這樣的操做就會形成沒必要要的內存垃圾:

public Text timerText;
private float timer;
void Update()
{
    timer += Time.deltaTime;
    timerText.text = "Time:"+ timer.ToString();
}

  經過將字符串進行分隔,咱們能夠剔除字符串的加操做,從而減小沒必要要的內存垃圾:

public Text timerHeaderText;
public Text timerValueText;
private float timer;
void Start()
{
    timerHeaderText.text = "TIME:";
}

void Update()
{
   timerValueText.text = timer.ToString();
}

  Unity函數調用

  在代碼編程中,當咱們調用不是咱們本身編寫的代碼,不管是Unity自帶的仍是插件中的,咱們均可能會產生內存垃圾。Unity的某些函數調用會產生內存垃圾,咱們在使用的時候須要注意它的使用。

  這兒沒有明確的列表指出哪些函數須要注意,每一個函數在不一樣的狀況下有不一樣的使用,因此最好仔細地分析遊戲,定位內存垃圾的產生緣由以及如何解決問題。有時候緩存是一種有效的辦法,有時候儘可能下降函數的調用頻率是一種辦法,有時候用其餘函數來重構代碼是一種辦法。如今來分析unity中常見的形成堆內存分配的函數調用。

  在Unity中若是函數須要返回一個數組,則一個新的數組會被分配出來用做結果返回,這不容易被注意到,特別是若是該函數含有迭代器,下面的代碼中對於每一個迭代器都會產生一個新的數組:

void ExampleFunction() { for(int i=0; i < myMesh.normals.Length;i++) { Vector3 normal = myMesh.normals[i]; } }

  對於這樣的問題,咱們能夠緩存一個數組的引用,這樣只須要分配一個數組就能夠實現相同的功能,從而減小內存垃圾的產生:

void ExampleFunction()
{
    Vector3[] meshNormals = myMesh.normals;
    for(int i=0; i < meshNormals.Length;i++)
    {
        Vector3 normal = meshNormals[i];
    }
}

  此外另外的一個函數調用GameObject.name 或者 GameObject.tag也會形成預想不到的堆內存分配,這兩個函數都會將結果存爲新的字符串返回,這就會形成沒必要要的內存垃圾,對結果進行緩存是一種有效的辦法,可是在Unity中都對應的有相關的函數來替代。對於比較gameObject的tag,能夠採用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,unity中許多其餘的函數也能夠避免內存垃圾的生成。好比咱們能夠用Input.GetTouch()和Input.touchCount()來代替Input.touches,或者用Physics.SphereCastNonAlloc()來代替Physics.SphereCastAll()。

  裝箱操做

  裝箱操做是指一個值類型變量被用做引用類型變量時候的內部變換過程,若是咱們向帶有對象類型參數的函數傳入值類型,這就會觸發裝箱操做。好比String.Format()函數須要傳入字符串和對象類型參數,若是傳入字符串和int類型數據,就會觸發裝箱操做。以下面代碼所示:

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

  在Unity的裝箱操做中,對於值類型會在堆內存上分配一個System.Object類型的引用來封裝該值類型變量,其對應的緩存就會產生內存垃圾。裝箱操做是很是廣泛的一種產生內存垃圾的行爲,即便代碼中沒有直接的對變量進行裝箱操做,在插件或者其餘的函數中也有可能會產生。最好的解決辦法是儘量的避免或者移除形成裝箱操做的代碼。

  協程

  調用 StartCoroutine()會產生少許的內存垃圾,由於unity會生成實體來管理協程。因此在遊戲的關鍵時刻應該限制該函數的調用。基於此,任何在遊戲關鍵時刻調用的協程都須要特別的注意,特別是包含延遲迴調的協程。

  yield在協程中不會產生堆內存分配,可是若是yield帶有參數返回,則會形成沒必要要的內存垃圾,例如:

yield return 0;

  因爲須要返回0,引起了裝箱操做,因此會產生內存垃圾。這種狀況下,爲了不內存垃圾,咱們能夠這樣返回:

yield return null;

  另一種對協程的錯誤使用是每次返回的時候都new同一個變量,例如:

while(!isComplete)
{
    yield return new WaitForSeconds(1f);
}

  咱們能夠採用緩存來避免這樣的內存垃圾產生:

WaitForSeconds delay = new WaiForSeconds(1f);
while(!isComplete)
{
    yield return delay;
}

  若是遊戲中的協程產生了內存垃圾,咱們能夠考慮用其餘的方式來替代協程。重構代碼對於遊戲而言十分複雜,可是對於協程而言咱們也能夠注意一些常見的操做,好比若是用協程來管理時間,最好在update函數中保持對時間的記錄。若是用協程來控制遊戲中事件的發生順序,最好對於不一樣事件之間有必定的信息通訊的方式。對於協程而言沒有適合各類狀況的方法,只有根據具體的代碼來選擇最好的解決辦法。

  foreach 循環

  在unity5.5之前的版本中,在foreach的迭代中都會生成內存垃圾,主要來自於其後的裝箱操做。每次在foreach迭代的時候,都會在堆內存上生產一個System.Object用來實現迭代循環操做。在unity5.5中解決了這個問題,好比,在unity5.5之前的版本中,用foreach實現循環:

void ExampleFunction(List listOfInts)
{
    foreach(int currentInt in listOfInts)
    {
        DoSomething(currentInt);
    }
}

  若是遊戲工程不能升級到5.5以上,則能夠用for或者while循環來解決這個問題,因此能夠改成:

void ExampleFunction(List listOfInts)
{
    for(int i=0; i < listOfInts.Count; i++)
    {
        int currentInt = listOfInts[i];
        DoSomething(currentInt);
    }
}

  函數引用

   函數的引用,不管是指向匿名函數仍是顯式函數,在unity中都是引用類型變量,這都會在堆內存上進行分配。匿名函數的調用完成後都會增長內存的使用和堆內存的分配。具體函數的引用和終止都取決於操做平臺和編譯器設置,可是若是想減小GC最好減小函數的引用。

  LINQ和常量表達式

  因爲LINQ和常量表達式以裝箱的方式實現,因此在使用的時候最好進行性能測試。

 

重構代碼來減少GC的影響

  即便咱們減少了代碼在堆內存上的分配操做,代碼也會增長GC的工做量。最多見的增長GC工做量的方式是讓其檢查它沒必要檢查的對象。struct是值類型的變量,可是若是struct中包含有引用類型的變量,那麼GC就必須檢測整個struct。若是這樣的操做不少,那麼GC的工做量就大大增長。在下面的例子中struct包含一個string,那麼整個struct都必須在GC中被檢查:

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

  咱們能夠將該struct拆分爲多個數組的形式,從而減少GC的工做量:

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

  另一種在代碼中增長GC工做量的方式是保存沒必要要的Object引用,在進行GC操做的時候會對堆內存上的object引用進行檢查,越少的引用就意味着越少的檢查工做量。在下面的例子中,當前的對話框中包含一個對下一個對話框引用,這就使得GC的時候會去檢查下一個對象框:

public class DialogData
{
     private DialogData nextDialog;
     public DialogData GetNextDialog()
     {
           return nextDialog;
                    
     }
}

  經過重構代碼,咱們能夠返回下一個對話框實體的標記,而不是對話框實體自己,這樣就沒有多餘的object引用,從而減小GC的工做量:

public class DialogData
{
    private int nextDialogID;
    public int GetNextDialogID()
    {
       return nextDialogID;
    }
}

  固然這個例子自己並不重要,可是若是咱們的遊戲中包含大量的含有對其餘Object引用的object,咱們能夠考慮經過重構代碼來減小GC的工做量。

  

定時執行GC操做

  主動調用GC操做

   若是咱們知道堆內存在被分配後並無被使用,咱們但願能夠主動地調用GC操做,或者在GC操做並不影響遊戲體驗的時候(例如場景切換的時候),咱們能夠主動的調用GC操做:

System.GC.Collect()

  經過主動的調用,咱們能夠主動驅使GC操做來回收堆內存。

 

總結

  經過本文對於unity中的GC有了必定的瞭解,對於GC對於遊戲性能的影響以及如何解決都有必定的瞭解。經過定位形成GC問題的代碼以及代碼重構咱們能夠更有效的管理遊戲的內存。

  接着我會繼續寫一些Unity相關的文章。翻譯的工做,在後面有機會繼續進行。

相關文章
相關標籤/搜索