【Unity3d遊戲開發】淺談Unity中的GC以及優化

Unity GC分析及優化

介紹

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

Unity中將垃圾回收當作內存管理的一部分,如果遊戲中垃圾回收十分複雜,則遊戲的性能會受到極大影響,此時垃圾回收會成爲遊戲性能的一大障礙點。

下面我們將會學習垃圾回收的機制,掌握垃圾回收如何被觸發以及如何提高垃圾回收效率來減小其對遊戲行性能的影響。

Unity內存管理機制簡介

要想了解垃圾回收如何工作以及何時被觸發,我們首先需要了解unity的內存管理機制。Unity主要採用自動內存管理的機制,開發時在代碼中不需要詳細地告訴unity如何進行內存管理,unity內部自身會進行內存管理。

unity的自動內存管理可以理解爲以下幾個部分:

unity內部有兩個內存管理池:堆內存和堆棧內存。堆棧內存(stack)主要用來存儲較小的和短暫的數據片段,堆內存(heap)主要用來存儲較大的和存儲時間較長的數據片段。
unity中的變量只會在堆棧或者堆內存上進行內存分配。
只要變量處於激活狀態,則其佔用的內存會被標記爲使用狀態,則該部分的內存處於被分配的狀態,變量要麼存儲在堆棧內存上,要麼處於堆內存上。
一旦變量不再激活,則其所佔用的內存不再需要,該部分內存可以被回收到內存池中被再次使用,這樣的操作就是內存回收。處於堆棧上的內存回收及其快速,處於堆上的內存並不是及時回收的,其對應的內存依然會被標記爲使用狀態。
垃圾回收主要是指堆上的內存分配和回收,unity中會定時對堆內存進行GC操作。
在瞭解了GC的過程後,下面詳細瞭解堆內存和堆棧內存的分配和回收機制的差別。

棧內存分配和回收機制

棧上的內存分配和回收十分快捷簡單,主要是棧上只會存儲短暫的較小的變量。內存分配和回收都會以一種可控制順序和大小的方式進行。

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

堆內存分配和回收機制

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

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

首先,unity檢測是否有足夠的閒置內存單元用來存儲數據,如果有,則分配對應的內存單元;
如果沒有足夠的存儲單元,unity會觸發垃圾回收來釋放不再被使用的堆內存。這步操作是一步緩慢的操作,如果垃圾回收後有足夠的內存單元,則進行內存分配。
如果垃圾回收後並沒有足夠的內存單元,則unity會擴展堆內存的大小,這步操作會很緩慢,然後分配對應的內存單元給變量。
堆內存的分配有可能會變得十分緩慢,特別是需要垃圾回收和堆內存需要擴展的情況下。

垃圾回收時的操作

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

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

GC會檢查堆內存上的每個存儲變量;
對每個變量會檢測其引用是否處於激活狀態;
如果變量的引用不再處於激活狀態,則會被標記爲可回收;
被標記的變量會被移除,其所佔有的內存會被回收到堆內存上。
GC操作是一個極其耗費的操作,堆內存上的變量或者引用越多則其運行的操作會更多,耗費的時間越長。

何時會觸發垃圾回收

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

在堆內存上進行內存分配操作而內存不夠的時候都會觸發垃圾回收來利用閒置的內存;
GC會自動的觸發,不同平臺運行頻率不一樣;
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,通過分析其來定位是什麼函數造成大量的堆內存分配操作。一旦定位該函數,我們就可以分析解決其造成問題的原因從而減少內存垃圾的產生。

降低GC的影響的方法

大體上來說,我們可以通過三種方法來降低GC的影響:

減少GC的運行次數;
減少單次GC的運行時間;
將GC的運行時間延遲,避免在關鍵時候觸發,比如可以在場景加載的時候調用GC
基於此,我們可以採用三種策略:

對遊戲進行重構,減少堆內存的分配和引用的分配。更少的變量和引用會減少GC操作中的檢測個數從而提高GC的運行效率。
降低堆內存分配和回收的頻率,尤其是在關鍵時刻。也就是說更少的事件觸發GC操作,同時也降低堆內存碎片。
我們可以試着測量GC和堆內存擴展的時間,使其按照可預測的順序執行。當然這樣操作的難度極大,但是這會大大降低GC的影響。
減少內存垃圾的數量

減少內存垃圾主要可以通過一些方法來減少:

   緩存

  如果在代碼中反覆調用某些造成堆內存分配的函數但是其返回結果並沒有使用,這就會造成不必要的內存垃圾,我們可以緩存這些變量來重複利用,這就是緩存。

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

void OnTriggerEnter(Collider other)
{
Renderer[] allRenderers = FindObjectsOfType();
ExampleFunction(allRenderers);
}
  對比下面的代碼,只會生產一個數組用來緩存數據,實現反覆利用而不需要造成更多的內存垃圾:

private Renderer[] allRenderers;

void Start()
{
allRenderers = FindObjectsOfType();
}

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。對象池技術可以通過重複使用objects來降低堆內存的分配和回收頻率。對象池在遊戲中廣泛的使用,特別是在遊戲中需要頻繁的創建和銷燬相同的遊戲對象的時候,例如槍的子彈。

  要詳細的講解對象池已經超出本文的範圍,但是該技術值得我們深入的研究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操作來回收堆內存。

作者:馬三小夥兒
出處:http://www.cnblogs.com/msxh/p/6531725.html
請尊重別人的勞動成果,讓分享成爲一種美德,歡迎轉載。另外,文章在表述和代碼方面如有不妥之處,歡迎批評指正。留下你的腳印,歡迎評論!

Unity遊戲的GC(garbage collection)優化
原文:https://unity3d.com/cn/learn/tutorials/topics/performance-optimization/optimizing-garbage-collect

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