標籤:image data 容器 += 工具 使用者 equals 用處 調用
原文Optimizing garbage collection in Unity games。這裡是對這篇文章的粗略翻譯,當作自己的筆記。
Garbage Collector在這裡被翻譯成名詞GC。對於Garbage Collection這裡翻譯成動詞記憶體回收。
記憶體垃圾: 代碼中銷毀了(disposed)但是GC還沒有清理的記憶體。
Unity的託管記憶體的簡單介紹
為了理解GC(本文GC指代Garbage Collector)在記憶體配置和回收時是怎樣工作的,我們必須首先瞭解Unity的引擎代碼和我們自己編寫的指令碼是怎麼利用記憶體來工作的。
Unity引擎代碼運行時管理記憶體的方式叫做手動管理記憶體(manual memory management)。也就是說引擎代碼必須顯示的聲明記憶體是怎麼使用的。手動管理記憶體沒有用到GC,這部分內容本文不做介紹。
當運行我們自己編寫的指令碼時Unity管理這部分記憶體的方式叫做自動記憶體管理(automatic memory management).這意味這我們自己編寫的代碼不需要很詳細的去告訴Unity怎樣去管理這部分記憶體。Unity都幫我們做好了。
簡而言之,Unity的自動管理記憶體工作方式如下:
- Unity有兩個可以訪問的記憶體池:棧(stack)和堆 (heap)(也被叫做託管堆。棧記憶體被用在短期儲存一些小片段的資料,堆則用來儲存一些長期並且比較大的片段資料)。
- 當一個變數建立好了,Unity會在棧或者堆中申請一個記憶體塊。
- 只要這個建立的變數在作用範圍之內(我們的代碼一直可以訪問到它),為它分配的相應的那塊記憶體就一直是保持可用狀態。我們把這塊記憶體成為已經被分配的(allocated)。我們把在棧中分配記憶體的變數叫做棧上的對象,把記憶體配置在堆上的變數成為堆上的對象。
- 當這個變數已經脫離了它的作用地區,相應的這個變數所分配的記憶體也就不再需要了,這塊記憶體隨之就會把返回給分配時的那個記憶體池。我們稱這塊記憶體叫做已回收(deallocated)。棧上的記憶體一旦離開了作用地區就會立即被回收。如果是堆上的記憶體即便是它的引用已經超出了作用地區也不會被立即回收,它還會保持它的分配狀態。
- GC會標記並且回收堆上沒有被使用的記憶體。它會周期性的對堆上的記憶體進行清理。
現在我們瞭解了記憶體使用量的流程,讓我們來更深入的來瞭解棧和堆上的記憶體的分配和釋放。
棧在分配和釋放記憶體的時候發生了什嗎?
棧記憶體的分配和回收比較快速和簡單。這是因為棧僅僅用於儲存一些小片段短周期的資料。分配和回收的操作基本上是按照預期的順序和規模(多少量的記憶體)發生的。
棧的工作原理類似與資料結構類型的棧: 它是一個簡單的元素收集器,在這種情況下棧的記憶體塊只能按照嚴格的順序來進行添加和刪除。簡單和嚴格的規則使得這個操作非常的快,當一個變數需要儲存到棧上,記憶體會為它在棧底簡單的分配一塊記憶體。當一個棧的變數已經超出了範圍,儲存這個變數的這塊記憶體會立即釋放掉變成可用記憶體。
堆在分配和釋放記憶體的時候發生了什嗎?
堆的記憶體配置要比棧的記憶體配置負責很多。這是因為堆既能夠用來儲存長周期的的資料也能夠用來儲存短周期的資料。並且這些資料可以是各種不同類型和不同大小。分配和回收不會按照預期的順序經常發生,並且這個分配和回收的記憶體有可能是大小差異比較大的記憶體塊。
當建立了一個堆記憶體的變數,它會順序執行一下這些步驟:
- 首先, Unity必須檢查在堆上是否有足夠的剩餘可以分配的記憶體。如果足夠了則會為這個變數分配相應大小的記憶體
- 如果在堆上的記憶體不夠分配這個變數所需的記憶體。 Unity的GC觸發器會去嘗試釋放堆上的沒有被使用的記憶體。這將會是一個比較慢的操作。如果釋放之後有了足夠的剩餘記憶體,則為這個變數分配相應大小的記憶體。
- 如果GC在釋放記憶體之後堆上的剩餘記憶體還是不夠這個變數所需要的記憶體,Unity將會增加堆記憶體的大小。這也是一個比較慢的操作。這個操作之後將會為這個變數分配到相應大小的記憶體空間。
堆記憶體配置比較慢,尤其是觸發了GC運行和堆記憶體的擴充。
GC進行記憶體回收的時候發生了什嗎?
當一個堆記憶體變數已經超出了它的作用範圍(或者說生命週期)時,儲存這個變數的那塊記憶體不會立即被回收,這塊記憶體可以被稱作未被使用的記憶體。未被使用的記憶體只有當GC啟動並執行時候才會被回收。
每當GC運行一次,它會順序執行一下步驟:
- GC會檢查每個在堆上的對象
- GC會尋找所有當前對象的引用情況來決定這個對象是否還在作用範圍之內。
- 任何對象只要不在作用範圍之內就會被GC標記為需要刪除的對象。
- 刪除被標記的對象然後回收這些變數所佔用的記憶體。
GC回收記憶體是一個比較昂貴的操作。堆上的對象越多,對象的引用也會越多,GC需要處理的工作隨之也會更多。
什麼情況下會導致GC的運行
有三種情況會導致GC的運行:
- 不管什麼時候只要堆的記憶體已經不能滿足使用者申請的記憶體的時候。
- GC會自己不定時的執行(這個頻率是根據平台來定的)。
- 使用者主動強制調用GC回收的時候
GC回收記憶體是一個比較頻繁的操作。不管何時只要堆記憶體不能滿足變數申請的記憶體的大小GC回收記憶體操作將會被觸發,這也意味著頻繁的堆記憶體的申請和釋放操作會導致GC的頻繁運行。
GC回收記憶體的問題
現在我們已經瞭解了GC回收記憶體在Unity的記憶體管理中所扮演的角色,接下來我們可以考慮下在GC回收記憶體的時候可能發生的各種問題。
一個很明顯的問題是GC會花費很多的時間。如果GC需要檢查大量的堆記憶體對象或者是大量的對象參考關聯性,這個檢查的過程將會非常的慢,這也會導致我們的遊戲出現卡頓(stutter)或者運行緩慢。
另外一個問題是GC可能在不恰當的時間執行。如果CPU在執行我們遊戲的效能關鍵區段已經承受了非常承重的負擔了,這個時候即使想CPU添加一個很小的額外負擔都會造成我們遊戲的幀率下降並且效能會明顯變差。
還有個不太明顯的問題是堆記憶體的片段化。當從堆中分配記憶體的時候,需要根據儲存資料的大小從剩餘的記憶體塊從分配,當這些被分配的記憶體重新回收後,堆記憶體就會得到大量的被分割成很多小的不連續的可用的記憶體塊。這意味著GC沒有進行記憶體回收的情況下即時總共的可用記憶體會比較多,但是我們卻不能分配一個比較大的一塊連續記憶體。
這裡我們總結了兩個記憶體片段化將會導致的問題。一方面我們的遊戲記憶體使用量比我們需要的會更高,另一方面GC記憶體回收操作將會更加頻繁。想要堆記憶體片段化更詳細的討論, 可以看這篇文章 this Unity best practice guide on performance
尋找堆記憶體的分配點
如果我們知道在我們遊戲中導致GC啟動並執行原因,我們需要知道哪一部分代碼產生了記憶體垃圾。記憶體垃圾產生於超出了範圍的堆記憶體變數,因此我們首先需要知道變數把記憶體配置在堆中的原因。
下面列出的代碼是一個在棧上分配記憶體的例子,變數localInt是局部的同時也是實值型別的變數(value-type)這個變數的記憶體將會在這個函數運行之後被立即回收到棧中。
void ExampleFunction(){ int localInt = 5;}
下面列出的代碼是一個在堆上分配記憶體的例子,變數localList是局部變數但是是參考型別的(reference-type)。這個變數的記憶體回收是在GC啟動並執行時候(函數執行完並不會立即釋放)。
void ExampleFunction(){ List localList = new List();}
使用Unity的Profile視窗可以找到堆記憶體的分配
我們可以通過Profiler視窗看到我們代碼建立堆記憶體的地方
在CPU使用方式的分析器中,我們可以選擇任何一幀然後在Profiler視窗的底部部分查看這一幀的CPU的資料使用方式,其中有一列資料叫做 GC Alloc 。這一列顯示了這一幀進行中的記憶體配置情況。如果我們選擇這一列的頭部我們可以對這些統計好的資料進行排序,以便更能輕鬆的看出來在我們遊戲中是哪些函數產生最大的堆記憶體配置。一旦我們知道是哪個函數產生分配堆記憶體,我們可以檢查這個函數。
當我們知道了在這個函數中引起記憶體配置的那段代碼,我們可以決定怎樣去解決這個問題使得這裡的分配的記憶體量最小化。
減少GC回收記憶體帶來的影響
概括來說,我們可以通過三種方式來降少GC回收記憶體給遊戲帶來的影響:
- 我們可以減少GC啟動並執行時間
- 我們可以減少個GC啟動並執行頻率
- 我們可以謹慎的在效能壓力比較小的時候觸發GC回收記憶體,例如在loading介面
基於這幾個想法,這裡有三個策略可以協助我們:
- 我們可以構造好我們的遊戲以便我們有更少的堆記憶體配置和更少的對象引用。堆上對象較少並且檢查的對象引用更少意味著當GC被觸發,GC回收記憶體需要更少的時間。
- 我們可以減少堆記憶體的分配和釋放的頻率,尤其是當效能壓力比較大的時候。更少的記憶體配置和釋放就會有更少情境觸發GC進行記憶體回收。這也較少了堆記憶體片段的風險
- 我們可以嘗試在預期的在方便的時間時主動調用GC記憶體回收進行記憶體的拓展。這樣做比較困難且比較難達到預期,但是當這個方式作為整個記憶體管理原則的一部分時可以減少GC記憶體回收的影響。
減少垃圾記憶體建立的數量
讓我們來做一些技術上的測試來協助我們減少我們自己的代碼中產生的垃圾記憶體。
緩衝
如果我們的代碼在重複的調用一個函數,這個函數會導致堆記憶體配置而且每次我們調用完這個函數之後會丟棄這個函數的計算結果,這裡會產生不必要的記憶體垃圾。取而代之,我們應該儲存這個這些對象來重複使用。這個技術叫做緩衝。
在下面列出的例子中,這段代碼每次調用都會產生堆記憶體配置。這是因為會建立一個新的數組。
void OnTriggerEnter(Collider other){ Renderer[] allRenderers = FindObjectsOfType<Renderer>() ExampleFunction(allRenderers);}
下面列出的代碼是修改之後的,現在無論調用OnTriggerEnter多少次都只會造成一次堆記憶體配置,這個數組被建立好並賦值然後緩衝起來。這個緩衝數組可以被重複使用並不會產生額外的記憶體。
private Renderer[] allRenderers;void start(){ allRenderes = FindObjectsOfType<Renderer>() }void OnTriggerEnter(Collider other){ ExampleFunction(allRenderers);}
不要在頻繁調用的函數裡面分配記憶體
如果我們必須在MonoBehaviour指令碼裡面分配堆記憶體,最糟糕的是我們把這個操作放在頻繁啟動並執行函數裡。例如Update()和LateUpdate, 這兩個函數每幀都會調用。所以如果我們會分配堆記憶體的代碼放在這這種函數裡面,記憶體非常快的增加。這種情況我們儘可能的考慮讓這個對象在Start()或者Awake()函數中建立好然後使用引用來儲存這個對象,或者確保這段代碼只有在需要的時候才分配記憶體。
來看個非常簡單的例子,這個例子稍作改動使得函數只有當條件改變的時候才運行。下面列出的代碼,有個會產生記憶體配置的函數每幀都會被Update()調用,會頻繁的建立記憶體垃圾。
void Update(){ ExampleGarbageGeneratingFunction(transform.position.x);}
做一個簡單的改動,我們現在確保只有當transform.position.x的值發生改變的時候才調用ExampleGarbageGeneratingFunction。改動之後只有當我們需要的時候才會分配記憶體而不是每幀都會分配一次。
private float previousTransformPositionX;void Update(){ float transformPositionX = transform.position.x; if (transformPositionX != previousTransformPositionX) { ExampleGarbageGeneratingFunction(transformPositionX); previousTransformPositionX = transformPositionX; }}
另外一個可以減少產生記憶體垃圾的方法是在Update()裡面使用計時器。這個方法比較適合用於我們的這個會產生記憶體垃圾的函數ExampleGarbageGeneratingFunction必須有規律的運行,但是不需要每幀那麼頻繁。
下列例子中的代碼,函數ExampleGarbageGeneratingFunction會每幀都會被調用,這個函數內部會產生記憶體垃圾。
void Update(){ ExampleGarbageGeneratingFunction();}
我們使用計時器的方式來修改下這個這段代碼,確保這個會產生記憶體垃圾的函數每秒才調用一次。
private float timeSinceLastCalled;private float delay = 1f;void Update(){ timeSinceLastCalled += Time.deltaTime; if (timeSinceLastCalled > delay) { ExampleGarbageGeneratingFunction(); timeSinceLastCalled = 0f; }}
當我們的代碼啟動並執行非常頻繁的時候,只做一點小的改動就能夠大幅度較少記憶體記憶體垃圾量。
清理容器
建立一個新的容器(List、Dictionary等)會申請分配堆上的記憶體。如果我們發現在代碼中有超過一次的建立新的容器,那麼我們應緩衝起來這個指向這個容器的引用,然後使用Clear()來清空這個容器而不是重複調用New來重新建立。
下面這個例子中,每次調用new關鍵字都會引起分配一次堆記憶體。
void Update(){ List myList = new List(); PopulateList(myList);}
下面這個例子,只有當容器建立或者容器內部必須改變儲存大小的時候才會發生記憶體配置。這能夠大幅減少產生記憶體垃圾的量。
private List myList = new List();void Update(){ myList.Clear(); PopulateList(myList);}
對象池
即使我們減少我們指令碼中的記憶體配置,如果我們在遊戲運行時大量的建立和銷毀對象那麼GC回收記憶體的問題任然存在。對於重複使用的對象,我們沒有必要每次使用到都去重複的建立和銷毀,利用Object pooling可以減少對於重複利用的對象的記憶體的分配和釋放。Object pooling被廣泛的應用在遊戲中頻繁產生和銷毀的小對象的場合。例如,從槍中射出的子彈。
本文不討論完整的object pooling使用規範,但是這是一項非常有用且值得學習的技術。This tutorial on object pooling on the Unity Learn site 是一個非常好的針對於Unity的object pooling系統的實現規範。
常見的導致不必要的堆記憶體配置的一些原因
我們已經理解了實值型別的變數是分配的棧記憶體,參考型別的變數分配的堆記憶體。然而有很多分配堆記憶體的地方會讓我們自己感到驚訝,因為根本就沒必要分配堆記憶體。下面讓我們來看一些常見的不必要的堆記憶體配置情況並且想出最好的辦法去減少這種情況。
String
在C#中, string是參考型別而不是實值型別,儘管它看上去像是持有了字串的值。這意味著string建立和丟棄之後會產生記憶體垃圾。作為一個在大量代碼中常用的類型,這些記憶體垃圾會一直增長。
C#中的String類型同事也是不可變類型,意思就是在它建立之後就不能改變它的值了。當我們每次操作一個字串(例如,使用重載運算子+來串連兩個string)。Unity如果需要更新一個字串的值,它會建立一個新的字串來儲存新值然後丟棄當前這個字串。這會產生記憶體垃圾。
我們可以遵循一些簡單的規則讓string產生的記憶體垃圾最小化。先考慮下這些規則,然後在例子中看看它是怎麼被應用的。
- 我們應該減少那些不必要的string建立。如果我們使用同一個string的值超過一次,那麼我們應該建立好這個string然後緩衝起來。
- 我們應該減少哪些不必要的string的值的更改。例如,如果我們有個頻繁更新的Text組件,Text組件顯示的值是每次兩個string的串連成一個新string得到的。我們應該考慮把把這個Text組件分成兩個Text組件,這樣分別顯示兩個string的值,就不需要每次會建立一個新的string了。
- 如果我們在遊戲運行時需要構建一個string(如多個字串值串連成一個字串)。我們應該使用StringBuilder類。StringBuilder類是專門為了構建string而設計的,它不會產生記憶體配置而且在我們串連一個比較複雜的string時會避免產生大量的記憶體垃圾。
- 一旦我們不需要調試項目時我們應該把項目裡面的Debug.Log()全部去掉。在我們遊戲中調用Debug.Log()會持續的構建string,即使它們不在輸出任何調試日誌。每調用一次Debug.Log()會建立和銷毀至少一個string對象,所以如果我們遊戲裡麵包含了大量的這種調用,那麼記憶體垃圾會增加很多。
讓我們來寫個例子來測試下,這個例子包含無效率的string而導致產生了不必要的記憶體垃圾的代碼。在下面這段代碼中,我們建立了一個記錄分數值的string對象,它在Update()裡面和值為“TIME:”的float類型對象timer進行合并。這裡產生了不必要的記憶體垃圾。
public Text timerText;private float timer;void Update(){ timer += Time.deltaTime; timerText.text = "TIME:" + timer.ToString();}
再來看看下面列出的代碼,我們對一些地方做了比較有效改進。我們把"TIME:"字串獨立出來在Start()中賦值給一個單獨Text組件。這意味著在Update()函數中我們不再需要併合字串了。這非常有效減少了大量的記憶體垃圾的產生。
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的函數都會放返回一個數組,這個函數記憶體會建立一個新的數組作為傳回值返回給我們。這種行為並不總是明顯或者可以意料到的,尤其是這個函數是被定義為get形式訪問的變數(例如,Mesh.normals)。
下面列出的代碼,在這個迭代迴圈中,沒迴圈一次都會建立一個新的數組。
void ExampleFunction(){ for (int i=0; i<meMesh.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中找到。這兩個函數也是get類型訪問的變數返回一個新的字串,這意味著調用這些函數將會產生記憶體垃圾。一個比較有效方法是緩衝這些變數值,但在一些情況中我們可以使用Unity的函數來代替調用這些會產生記憶體垃圾的方法。要在不產生記憶體垃圾的前提下去檢查GameObject的tag值,我們可以使用GameObject.CompareTag()。
在下列代碼中,調用GameObject.tag建立了記憶體垃圾。
private string palyerTag = "Player";void OnTriggerEnter(Collider other){ bool isPlayer = other.gameObject.tag == playerTag;}
如果我們使用GameObject.CompareTag(),這個函數就不再會產生任何記憶體垃圾了。
private playerTag = "Player";void OnTriggerEnter(Collider other){ bool isPlayer = other.gameObject.CompareTag(playerTag);}
GameObject.CompateTag不是Unity唯一的這類函數;很多Unity的函數都有不會引起對記憶體配置的版本。比如,我們可以使用Input.GetTouch()和Input.touchCount來代替Input.touches,可以使用Physics.SpereCastNonAlloc()來代替Physics.SphereCastAll()
裝箱
裝箱(Boxing)是實值型別變數被用做參考型別變數的地方發生的。它通常發生在我們傳入一個實值型別的變數給一個參數是參考型別的函數。比如給Object.Equals()傳入的值是int或者float值,但是Object.Equals()的參數類型是一個參考型別的。
舉個例子,在函數Stirng.Format()使用了字串和對象參數,當我們傳入一個string和一個int類型的參數,這個int類型的變數就會被裝箱。下列的程式碼封裝含了一個裝箱的例子:
void ExampleFunction(){ int cost = 5; string displayString = String.Format("Price:{0}gold",cost);}
裝箱在內部會建立記憶體垃圾。當一個實值型別的變數被裝箱,Unity會在堆上建立一個零時的System.Object對象包包裹這個實值型別的變數。System.Object時參考型別的變數,所以當這個零時的變數被銷毀時產生了記憶體垃圾。
裝箱是一個非常常見的引起不必要的堆記憶體配置的原因。即使我們在自己的代碼中去避免這種變數的裝箱操作,但是我們可能使用了第三方的外掛程式會造成裝箱或者是外掛程式的代碼內部實現存在裝箱。最好的習慣是避免任何可能出現裝箱的代碼,同時去掉所有的導致裝箱的函數。
協程
調用StartCoroutine()會建立少量的記憶體垃圾,因為Unity必須建立一個執行個體來管理StartCoroutine()所返回的coroutine執行個體。考慮到這點,盡量減少對StartConoroutine的調用,因為我們遊戲的互動和效能是一個核心所在。為了減少協程所帶來的記憶體垃圾,任何在效能負載高的時期啟動並執行協程應該提前執行同時我們應該特別注意那些嵌套的協程,這些協程裡面可能也包含延遲調用StartCoroutine。
協程內的yield生命不會自己建立堆記憶體。然而,我們傳遞的yield狀態的值可能會導致不必要的堆記憶體配置。例如,下面的代碼會產生記憶體垃圾:
yield return 0;
這段代碼因為值0會裝箱(Boxing)會導致記憶體垃圾。在這個例子中,如果我們想不會產生任何堆記憶體實現簡單的等待一幀效果,最好的方式是這樣做:
yiled return null;
另外一個比較常見的錯誤是在協程裡面使用new操作多次建立一個值相同的yield狀態變數。例如,下面的代碼每次迴圈都會建立和銷毀一個WaitForSeconds對象。
while(!isComplete){ yield return new WaitForSeconds(1f);}
如果我們緩衝WaitForSeconds這個變數然後重複使用,這回極大的減少記憶體垃圾。下面的代碼展示了修改之後的方式:
WaitForSeconds delay = new WaitForSeconds(1f);while (!isComplete){ yield return delay;}
如果我們的代碼在協程運行期間會產生大量的記憶體垃圾,我們可能希望考慮下使用非協程的方式來重構這部分代碼。重構代碼是一個複雜的主題同時每個項目都是唯一的,但是這裡也提供兩個常用的協程的替代方式我們希望能夠記住。例如,如果我們使用協程主要是用來行政時間的話,我們可以在Update()函數裡面保持簡單的時間記錄。如果我們使用協程主要是用來控制我們遊戲中一些邏輯業務執行順序時,我們可以建立一些有序的訊息發送系統來允許對象間的通訊。沒有一些方法能夠適合所有的情況,但是請記住,在代碼中往往不止一種方式可以實現相同的目的。
foreach 迴圈
譯: 這個問題的具體分析可以看C# foreach帶來的記憶體問題這篇文章
在Unity5.5之前的版本中,一個foreach遍曆出了數組之外的任何集合,每次迴圈結束之後都會產生記憶體垃圾。這是由於在foreach內部發生了裝箱操作。當一個迴圈開始的時候會在建立一個System.Object堆記憶體對象,當迴圈結束的時候這個對象會被銷毀,從而產生了一個記憶體垃圾。這個問題在Unity5.5的版本中修複了。
舉個例子,在Unity5.5之前的版本中,下面這段嗎的迴圈會產生記憶體垃圾:
void ExampleFunction(List listOfInts){ foreach(int currentInt in listOfInts) { DoSomething(currentInt); }}
如果我們不能夠升級我們的Unity版本,這裡又一個簡單的解決方案。for和while迴圈不會在內部導致裝箱操作因此也不會產生堆記憶體垃圾。當我們要遍曆的集合不是數組的時候,我們應該更偏向於使用這兩個方式。
下面的代碼迴圈遍曆不會導致產生記憶體垃圾:
void ExampleFunction(List listOfInts){ for (int i=0; i<listOfInts.Count; ++i) { int currentInt = listOfInts[i]; DoSomething(currentInt); }}
函數的引用
不管是匿名函數還是普通函數,在Unity中都是參考型別的變數。他們都會產生堆記憶體配置。把一個匿名方法轉換為閉包(匿名函數內部訪問了外部變數)會增加很多記憶體開銷同時建立多個堆記憶體對象。
更加精確和詳細的函數引用和閉包的記憶體配置變化依賴於所在的平台和編譯器的設定。但是如果GC記憶體回收是一個比較需要注意的問題,那麼在遊戲運行過程中最好減少函數的引用和閉包的使用。[This Unity best practice guide on performance]針對這個主題有更加深入的技術細節的介紹。
LINQ和Regex
LINQ和Regex內部都會因為裝箱產生記憶體垃圾。最好的做法是在效能消耗的集中點禁止使用它們。還是這篇文章,this Unity best practice guide on performance 提供了非常好的關於這個主題的技術細節
重構我們的代碼把GC記憶體回收的影響減小到最小
我們代碼的架構方式會影響GC的記憶體回收。即使我們代碼沒有建立任何堆記憶體,它也會增加GC的負擔。
我們的代碼沒必要增加GC的負擔的一種情況是它需要去檢查(譯:判斷這個對象是否引用情況來決定是否回收這個對象)本該不需要檢查的事情。結構體是一個實值型別的變數,但是當我們有一個結構體包含了一個參考型別的變數的時候GC必須去檢查這整個結構體了。如果我們有個很大這種結構體的數組,這樣就給GC造成了非常多的額外工作。
在這個例子中,這個結構體包含了一個string類型的對象,它是參考型別的。當這段代碼運行時GC必須對這整個結構體數組都進行檢查。
public strct ItemData{ public string name; public int cost; public Vector3 position;}
private ItemData[] itemData;
在下面這個例子中,我們將資料存放區在單獨的數組中。當GC運行起來的時候,它只需要檢查string類型的數組同時忽略調用其他實值型別的數組。這使得GC只需要做必要的工作。
private string[] itemNames;private int[] itemCosts;private Vector3[] itemPositions;
另外一種在我們代碼中給GC增加了不必要的負擔的情況是只有了不必要的記憶體引用。當GC尋找堆記憶體上的對象的引用的時候,它必須檢查當前我們代碼每個對象的引用,即使我們不需要減少總的堆記憶體上的對象的數量,代碼中更少的對象引用意味著GC需要做的工作就更少。
下面這個例子中,我們實現了個對話方塊的類。當使用者查看這個對話時,將會顯示一個對話方塊。我們的程式碼封裝含了指向下一個應該顯示的對話方塊資料執行個體的引用。這意味GC必須去檢查這部分操作的引用:
public class DialogData{ private DialogData nextDialog; public DialogData GetNextDialog() { return nextDialog; }}
這裡我們重構下這段代碼,讓這個函數返回一個指向像個對話資料執行個體的標識符來代替返回執行個體本身。修改之後沒有引用,這就不會增加GC的時間開銷了。
public class DialogData{ private int nextDialogID; public int GetNextDialogID() { return nextDialogID; }}
這是一個非常簡單的例子。然而,如果我們的遊戲中包含了很多持有了其他對象的引用的對象。那麼我們需要考慮使用這種方式來重構我們的代碼,減少堆記憶體對象的複雜度。
定時進行GC記憶體回收手動強制進行記憶體回收
最終,我們可能希望自己去觸發GC回收記憶體。如果我們知道存在堆記憶體已經分配了但是不再使用了(例如, 如我們的代碼在載入資源時產生了記憶體垃圾)同時我們知道GC回收記憶體操作不會影響到玩家(例如,loading介面一直顯示的時候),我們可以通過下面的代碼來要求GC執行記憶體回收操作。
System.GC.Collect();
這段代碼會強制GC運行,方便我們在這個時間點釋放那些沒有任何引用的對象的的記憶體。
總結
我們學習了Unity的GC記憶體回收的工作原理,引起效能問題的原因是什麼? 怎麼去減少這些問題對我們的遊戲的影響。使用本文介紹的知識和我們的效能最佳化工具,我們可以修正GC記憶體回收的效能問題和重構我們的代碼使得他們更有效率的管理記憶體。
Unity遊戲記憶體回收的最佳化(譯)