1. 程式人生 > >Unity優化之GC——(二)合理優化Unity的GC

Unity優化之GC——(二)合理優化Unity的GC

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

  最近有點繁忙,白天干活晚上抽空寫點翻譯,還要運動,所以翻譯工作進行的有點緩慢 =。=

  PS: 最近重新回來更新了一遍,文章還是需要反覆修改才能寫的順暢,多謝各位的支援 :D

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

介紹:

  在遊戲執行的時候,資料主要儲存在記憶體中,當遊戲的資料在不需要的時候,儲存當前資料的記憶體就可以被回收以再次使用。記憶體垃圾是指當前廢棄資料所佔用的記憶體,垃圾回收(GC)是指將廢棄的記憶體重新回收再次使用的過程。

  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帶有引數返回,則會造成不必要的記憶體垃圾,例如:

1

yield return 0;

  由於需要返回0,引發了裝箱操作,所以會產生記憶體垃圾。這種情況下,為了避免記憶體垃圾,我們可以這樣返回:

1

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操作:

1

System.GC.Collect()

  通過主動的呼叫,我們可以主動驅使GC操作來回收堆記憶體。

 

總結

  通過本文對於unity中的GC有了一定的瞭解,對於GC對於遊戲效能的影響以及如何解決都有一定的瞭解。通過定位造成GC問題的程式碼以及程式碼重構我們可以更有效的管理遊戲的記憶體。

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