Unity GC垃圾回收

王王王渣渣發表於2019-04-04

今天記錄一些GC相關的知識點,加上一些自己的理解。

英文原文:https://unity3d.com/de/learn/tutorials/topics/performance-optimization/optimizing-garbage-collection-unity-games?playlist=44069

翻譯參考:http://www.cnblogs.com/zblade/p/6445578.html

 

GC (garbage collection)簡介

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

Unity中將垃圾回收當作記憶體管理的一部分,如果遊戲中廢棄資料佔用記憶體較大,則遊戲的效能會受到極大影響,此時垃圾回收會成為遊戲效能的一大障礙點。

 

Unity記憶體管理機制簡介

Unity主要採用自動記憶體管理的機制,開發時在程式碼中不需要詳細地告訴unity如何進行記憶體管理,unity內部自身會進行記憶體管理。這和使用C++開發需要隨時管理記憶體相比,有一定的優勢,當然帶來的劣勢就是需要隨時關注記憶體的增長。

unity的自動記憶體管理可以理解為以下幾個部分:

1.unity內部有兩個記憶體管理池:堆記憶體和堆疊記憶體。堆疊記憶體(stack)主要用來儲存較小的和短暫的資料,堆記憶體(heap)主要用來儲存較大的和儲存時間較長的資料。

2.unity中的變數只會在堆疊或者堆記憶體上進行記憶體分配,值型別變數都在堆疊上進行記憶體分配,其他型別的變數都在堆記憶體上分配。

3.只要變數處於啟用狀態,則其佔用的記憶體會被標記為使用狀態,則該部分的記憶體處於被分配的狀態。

4.一旦變數不再啟用,則其所佔用的記憶體不再需要,該部分記憶體可以被回收到記憶體池中被再次使用,這樣的操作就是記憶體回收。處於堆疊上的記憶體回收及其快速,處於堆上的記憶體並不是及時回收的,此時其對應的記憶體依然會被標記為使用狀態。

5.垃圾回收主要是指堆上的記憶體分配和回收,unity中會定時對堆記憶體進行GC操作。

 

堆疊記憶體分配和回收機制

堆疊上的記憶體分配和回收十分快捷簡單,因為堆疊上只會儲存短暫的或者較小的變數。記憶體分配和回收都會以一種順序和大小可控制的形式進行。

堆疊的執行方式就像stack: 其本質只是一個資料的集合,資料的進出都以一種固定的方式執行。正是這種簡潔性和固定性使得堆疊的操作十分快捷。當資料被儲存在堆疊上的時候,只需要簡單地在其後進行擴充套件。當資料失效的時候,只需要將其從堆疊上移除。

堆記憶體分配和回收機制

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

堆上的變數在儲存的時候,

1.首先unity會先檢測是否有足夠的閒置記憶體單元用來儲存資料,如果有,則分配對應大小的記憶體單元;

2.如果沒有,就觸發垃圾回收(GC)來釋放不再被使用的堆記憶體(緩慢的操作),如果垃圾回收後有足夠大小的記憶體單元,則進行記憶體分配。

3.如果還不夠,則會擴充套件堆記憶體的大小(緩慢的操作),最後分配對應大小的記憶體單元給變數。

堆記憶體的分配有可能會變得十分緩慢,特別是在需要垃圾回收和堆記憶體需要擴充套件的情況下,通常需要減少這樣的操作次數。

 

GC相關的一些資訊

GC的操作過程:

當堆記憶體上一個變數不再處於啟用狀態的時候,其所佔用的記憶體並不會立刻被回收,不再使用的記憶體只會在GC的時候才會被回收。其操作如下

1.GC會檢查堆記憶體上的每個儲存變數;

2.對每個變數會檢測其引用是否處於啟用狀態;

3.如果變數的引用不再處於啟用狀態,則會被標記為可回收;

4.被標記的變數會被移除,其所佔有的記憶體會被回收到堆記憶體上。

GC操作是一個極其耗費的操作,堆記憶體上的變數或者引用越多則其執行的操作會更多,耗費的時間越長。

 

何時觸發GC:

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

1.在堆記憶體上進行記憶體分配操作而記憶體不夠的時候都會觸發垃圾回收來利用閒置的記憶體;

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

3.GC可以被強制執行。

特別是在堆記憶體上進行記憶體分配時記憶體單元不足夠的時候,GC會被頻繁觸發,這就意味著頻繁在堆記憶體上進行記憶體分配和回收會觸發頻繁的GC操作。

 

GC操作帶來的問題:

1.需要大量的時間來執行,可能會使得遊戲執行緩慢。其次GC可能會在關鍵時候執行,例如在CPU處於遊戲的效能執行關鍵時刻,此時任何一個額外的操作都可能會帶來極大的影響,使得遊戲幀率下降。

2.堆記憶體的碎片劃。當一個記憶體單元從堆記憶體上分配出來,其大小取決於其儲存的變數的大小。當該記憶體被回收到堆記憶體上的時候,有可能使得堆記憶體被分割成碎片化的單元。也就是說堆記憶體總體可以使用的記憶體單元較大,但是單獨的記憶體單元較小,在下次記憶體分配的時候不能找到合適大小的儲存單元,這也會觸發GC操作或者堆記憶體擴充套件操作。

堆記憶體碎片會造成兩個結果,一個是遊戲佔用的記憶體會越來越大,一個是GC會更加頻繁地被觸發。

 

利用profiler window 來檢測堆記憶體分配(unity工具欄Window->Profiler開啟):

在CPU usage分析視窗中,我們可以檢測任何一幀cpu的記憶體分配情況。其中一個列是GC Alloc,通過分析其來定位是什麼函式造成大量的堆記憶體分配操作。一旦定位該函式,我們就可以分析解決其造成問題的原因從而減少記憶體垃圾的產生。現在Unity5.5的版本,還提供了deep profiler的方式深度分析GC垃圾的產生。如圖

 

優化方案

 降低GC影響的方法

1.減少GC的執行次數;

2.減少單次GC的執行時間;

3.將GC的執行時間延遲,避免在關鍵時候觸發,比如可以在場景載入的時候呼叫GC

主要策略為

1.對遊戲進行重構,減少堆記憶體的分配和引用的分配。更少的變數和引用會減少GC操作中的檢測個數從而提高GC的執行效率。

2.降低堆記憶體分配和回收的頻率,尤其是在關鍵時刻。也就是說更少的事件觸發GC操作,同時也降低堆記憶體的碎片化。

3.我們可以試著測量GC和堆記憶體擴充套件的時間,使其按照可預測的順序執行。當然這樣操作的難度極大,但是這會大大降低GC的影響。

 

具體如下:

減少記憶體垃圾的數量

1.快取

如果在程式碼中反覆呼叫某些造成堆記憶體分配的函式但是其返回結果並沒有使用,這就會造成不必要的記憶體垃圾,我們可以快取這些變數來重複利用,這就是快取。

例如下面的程式碼每次呼叫的時候就會造成堆記憶體分配,主要是每次都會分配一個新的陣列:

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

對比下面的程式碼,只會生產一個陣列用來快取資料,實現反覆利用而不需要造成更多的記憶體垃圾:

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

2.不要在頻繁呼叫的函式中反覆進行堆記憶體分配

在MonoBehaviour中,如果我們需要進行堆記憶體分配,最壞的情況就是在其反覆呼叫的函式中進行堆記憶體分配,例如Update()和LateUpdate()函式這種每幀都呼叫的函式,這會造成大量的記憶體垃圾。我們可以考慮在Start()或者Awake()函式中進行記憶體分配,這樣可以減少記憶體垃圾。

下面的例子中,update函式會多次觸發記憶體垃圾的產生:

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

通過一個簡單的改變,我們可以確保每次在x改變的時候才觸發函式呼叫,這樣避免每幀都進行堆記憶體分配:

float previousTransformPositionX;

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

另外的一種方法是在update中採用計時器,特別是在執行有規律但是不需要每幀都執行的程式碼中,例如:

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

3.清除連結串列

在堆記憶體上進行連結串列的分配的時候,如果該連結串列需要多次反覆的分配,我們可以採用連結串列的clear函式來清空連結串列從而替代反覆多次的建立分配連結串列。

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

通過改進,我們可以將該連結串列只在第一次建立或者該連結串列必須重新設定的時候才進行堆記憶體分配,從而大大減少記憶體垃圾的產生:

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

4.物件池

即便我們在程式碼中儘可能地減少堆記憶體的分配行為,但是如果遊戲有大量的物件需要產生和銷燬依然會造成GC。物件池技術可以通過重複使用物件來降低堆記憶體的分配和回收頻率。物件池在遊戲中廣泛的使用,特別是在遊戲中需要頻繁的建立和銷燬相同的遊戲物件的時候,例如槍的子彈這種會頻繁生成和銷燬的物件。

可以看之前寫的:Unity 物件池

 

造成不必要的堆記憶體分配的因素

1.字串

在c#中,字串是引用型別變數而不是值型別變數,即使看起來它是儲存字串的值的。這就意味著字串會造成一定的記憶體垃圾,由於程式碼中經常使用字串,所以我們需要對其格外小心。

c#中的字串是不可變更的,也就是說其內部的值在建立後是不可被變更的。每次在對字串進行操作的時候(例如運用字串的"+"操作),unity會新建一個字串用來儲存新的字串,使得舊的字串被廢棄,這樣就會造成記憶體垃圾。

我們可以採用以下的一些方法來最小化字串的影響:

1.減少不必要的字串的建立,如果一個字串被多次利用,我們可以建立並快取該字串。

2.減少不必要的字串操作,例如如果在Text元件中,有一部分字串需要經常改變,但是其他部分不會,則我們可以將其分為兩個Text元件,對於不變的部分就設定為類似常量字串即可。

3.如果我們需要實時的建立字串,我們可以採用StringBuilderClass來代替,StringBuilder專為不需要進行記憶體分配而設計,從而減少字串產生的記憶體垃圾。

4.移除遊戲中的Debug.Log()函式的程式碼,儘管該函式可能輸出為空,對該函式的呼叫依然會執行,該函式會建立至少一個字元(空字元)的字串。如果遊戲中有大量的該函式的呼叫,這會造成記憶體垃圾的增加。

 

2.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()來替代。除此之外我們還可以用Input.GetTouch()和Input.touchCount()來代替Input.touches,或者用Physics.SphereCastNonAlloc()來代替Physics.SphereCastAll()。

 

3.裝箱拆箱操作

裝箱操作是指一個值型別變數被用作引用型別變數時候的內部變換過程,如果我們向帶有物件型別引數的函式傳入值型別,這就會觸發裝箱操作。比如String.Format()函式需要傳入字串和物件型別引數,如果傳入字串和int型別資料,就會觸發裝箱操作。

 

4.協程

呼叫 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函式中保持對時間的記錄。如果用協程來控制遊戲中事件的發生順序,最好對於不同事件之間有一定的資訊通訊的方式。對於協程而言沒有適合各種情況的方法,只有根據具體的程式碼來選擇最好的解決辦法。

 

5.函式引用

函式的引用,無論是指向匿名函式還是顯式函式,在unity中都是引用型別變數,這都會在堆記憶體上進行分配。匿名函式的呼叫完成後都會增加記憶體的使用和堆記憶體的分配。具體函式的引用和終止都取決於操作平臺和編譯器設定,但是如果想減少GC最好減少函式的引用。

 

6.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;
}
ItemData[] itemData;

我們可以將該struct拆分為多個陣列的形式,從而減小GC的工作量:

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

 

另外一種在程式碼中增加GC工作量的方式是儲存不必要的Object引用,在進行GC操作的時候會對堆記憶體上的object引用進行檢查,越少的引用就意味著越少的檢查工作量。在下面的例子中,當前的對話方塊中包含一個對下一個對話方塊引用,這就使得GC的時候會去檢查下一個物件框:

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

通過重構程式碼,我們可以返回下一個對話方塊實體的標記,而不是對話方塊實體本身,這樣就沒有多餘的object引用,從而減少GC的工作量:

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

 

定時執行GC操作

如果我們知道堆記憶體在被分配後並沒有被使用,我們希望可以主動地呼叫GC操作,或者在GC操作並不影響遊戲體驗的時候(例如場景切換的時候),我們可以主動的呼叫GC操作:

System.GC.Collect()

 

補充

下面是一些個人知道的可以提高效能的一些注意點。有什麼不對或者漏的歡迎大家補充

1.減少.Count .Length的呼叫

for(int i = 0; i < list.Count; i++) {
    //do something
}

應改為:

for(int i = 0, j = list.Count; i < j ; i++) {
    //do something
}

 

2.減少gameobject,transform,GetComponent<T>的使用

我們可以在Start()方法中預先儲存好這些值,之後使用的時候呼叫預先儲存好的值即可。

GameObject m_gameObject;
Transform m_transform;
Text m_text;

void Start () {
    m_gameObject = gameObject;
    m_transform = transform;
    m_text = GetComponent<Text>();
}

 

3.減少SetActive(bool)的使用

對於要頻繁顯示隱藏的物體,我們可以減少使用SetActive(bool),而是通過transform.position資訊將其移除視線外,例如UI的隱藏。

 

增量式GC

Unity2019新增增量式GC的功能,簡單來說就是將GC的操作跨多個幀來操作,來降低GC的峰值。

官方文件:https://blogs.unity3d.com/2018/11/26/feature-preview-incremental-garbage-collection/

推薦的翻譯文章:https://www.bilibili.com/read/cv3260881?spm_id_from=333.788.b_636f6d6d656e74.38

 

相關文章