本文是Unity官方教程,性能優化系列的第三篇《Optimizing garbage collection in Unity games》的翻譯。
相關文章:
Unity性能優化(1)-官方教程The Profiler window翻譯
Unity性能優化(2)-官方教程Diagnosing performance problems using the Profiler window翻譯
Unity性能優化(3)-官方教程Optimizing garbage collection in Unity games翻譯
Unity性能優化(4)-官方教程Optimizing graphics rendering in Unity games翻譯
簡介
當游戲運行時,使用內存存儲數據。當數據不再需要時,存儲這些數據的內存被釋放,以便重新使用。我們把已經存儲了數據,但是已經不再使用這些數據的內存叫做垃圾。我們把重新使得這些存儲垃圾的內存變的可用的過程叫做垃圾回收。
Unity使用垃圾回收作為內存管理的一部分。如果垃圾回收執行的太頻繁或者垃圾太多,那么我們的游戲可能會性能較差。這意味着垃圾回收是很常見的引起性能問題的原因。
在這篇文章中,我們將學習垃圾回收怎樣工作,在什么時間發生,以及怎樣有效率的使用內存以便把垃圾回收對我們游戲的影響降到最低。
Unity內存管理概述
為了理解垃圾回收怎么樣工作以及什么時候發生,我們必須首先理解內存使用在Unity中是怎么工作的。Unity管理內存的方法叫做自動內存管理。這意味着我們的代碼不需要明確的告訴Unity怎么樣在細節上去管理內存。Unity替我們做了這些。
在本質上,Unity中的自動內存管理是這樣工作的:
-Unity在兩種內存池中存取:棧內存和堆內存。棧用來存儲短期的和小塊的數據,堆用來存儲長期的和大塊的數據。
-當一個變量創建時,Unity在棧或堆上申請一塊內存。
-只要變量在作用域內(仍然能夠被我們的代碼訪問),分配給它的內存表示在使用中。我們稱這塊內存已經被分配。我們描述一個變量被分配到棧內存為棧上對象,一個變量被分配到堆內存為堆上對象。
-當變量不在作用域了,內存不再被需要了,就可以被返回到它被申請的內存池。當內存被返回到內存池時,我們稱之為內存釋放。當變量不在作用域內時,棧上的內存會立刻被釋放。而當堆上的變量不在作用域時,在堆上的內存並不會在這一刻馬上被釋放,並且此時內存狀態仍然是已被分配狀態。
-垃圾回收器識別並且釋放無用的堆內存。垃圾回收器周期性的清理堆。
現在我們清楚了流程,讓我們更近一步,看看在棧上分配釋放內存和在堆上分配釋放內存的區別。
棧上分配和釋放內存時發生了什么
棧上分配和釋放內存很快並且很簡單。這是因為棧上只是用來存儲小數據且很短的時間。分配和釋放內存總是按照預期的順序和預期的大小。
棧工作像棧數據類型一樣:它是一個一些元素的簡單集合,在這里是一些內存塊,元素智能按照嚴格的順序添加或者移除。因為這種簡潔和嚴格,所以很快:當一個變量存儲在棧上時,內存簡單的在棧的“末尾”被分配,當棧上的變量不在作用域時,存儲它的內存馬上被返還回棧以便重用。
當堆上內存分配時發生了什么
堆上分配內存比棧上要復雜很多。因為堆上會存儲長期和短期的數據,並且數據有很多不同的類型和大小。堆上內存的分配和釋放並不總是有預期的順序,並且可能需要不同大小的內存塊。
當一個堆變量被創建,會發生以下步驟:
-首先Unity必須先檢測堆上是否有足夠的空閑內存。如果堆上空閑內存足夠,那么為變量分配內存。
-如果堆上內存不足,Unity觸發垃圾回收器嘗試釋放堆上無用的內存。這個操作可能會比較慢。如果現在空閑內存足夠了,那么為變量分配內存。
-如果執行垃圾回收后,堆上空閑內存仍然不足,Unity會增加堆內存容量。這操作可能會比較慢。這樣就可以為變量分配內存了。
堆上分配內存可能會很慢,尤其是需要執行垃圾回收和擴展堆內存時。
垃圾回收時發生了什么
當堆變量不在作用域后,存儲它的內存不會馬上被釋放。只有執行垃圾回收時,堆上的無用內存才會被釋放。
每次垃圾回收執行時,會發生如下步驟:
-垃圾回收器檢查堆上的每個對象。
-垃圾回收器查找所有當前對象的引用,確認堆上對象是否還在作用域。
-任何不在作用域的對象被標記為待刪除。
-被標記的對象被刪除掉,且把為他們分配的內存返還到堆中。
垃圾回收可能是昂貴的操作。堆上的對象越多,它需要做的工作就越多;我們代碼里對象的引用越多,它需要做的工作就越多。
垃圾回收什么時候發生
三件事情可能會觸發垃圾回收:
-當堆上分配內存時,且空閑內存不足,會觸發垃圾回收。
-垃圾回收隨着時間自動觸發(雖然頻率由平台決定)。
-我們可以手動強制執行垃圾回收。
垃圾回收可能會很頻繁。因為當堆上分配內存時,且空閑堆內存不足,會觸發垃圾回收,這意味着頻繁的分配釋放堆內存可能會導致頻繁的垃圾回收。
垃圾回收帶來的問題
現在我們理解了垃圾回收在Unity內存管理中扮演的角色,我們可以考慮可能會遇到哪些類型的問題了。
最明顯的問題是垃圾回收會花費一部分時間去執行。如果垃圾回收器有很多堆上的對象需要檢查,並且很多對象的引用需要檢查,檢查所有這些對象可能會比較慢。這可能會引起游戲卡頓或運行慢。
另一個問題是垃圾回收可能在不恰當的時機執行。如果cpu已經努力工作在我們游戲的性能臨界的部分了,這時甚至垃圾回收引起的一點額外消耗可能會引起幀率下降和性能的明顯改變。
另一個不太容易被注意到的問題是堆碎片化。當內存在堆上被分配時,是在空閑空間中根據要被存儲的數據大小分塊的。當這些內存塊被返還回堆內存時,堆內存中的空閑空間被分成了一些碎塊。這意味着,也許堆上總的空閑很大,但是我們不能直接分配大塊內存給需要的變量,因為沒有足夠大的內存塊了,除非執行垃圾回收或者擴展堆內存。
碎片化的堆內存會帶來兩個影響。首先,我們游戲使用的內存占用比實際需要的高,其次,垃圾回收會更頻繁的執行。
診斷垃圾回收問題
垃圾回收引起的性能問題,可能表現出來為低幀率,性能不穩定或者是間歇性的卡死。如果你的游戲性能問題表現如此,那么首先你應該使用Unity Profiler去確認問題是否真的由垃圾回收造成。
學習怎么使用Profiler診斷性能問題,請閱讀Unity性能優化(2)-官方文檔簡譯。
如果使用Profiler確認問題是垃圾回收引起的,那么請繼續閱讀。
查找堆內存分配
如果我們知道了是垃圾回收引起的性能問題,那么我們需要知道是我們代碼的哪部分生成了垃圾。當堆變量不在作用域了,會產生垃圾,我們要先知道是什么引起了變量分配在堆上。
什么分配在堆上和棧上?
在Unity中,值類型的變量分配在棧上,除此外所有變量都分配在堆上。如果你不確認Unity中值類型和引用類型的區別,請看這個教程this tutorial.
下面代碼是棧上分配的例子,變量localInt是局部變量和值類型的。為這個變量分配的內存,會在這個函數執行完畢后立刻釋放。
void ExampleFunction() { int localInt = 5; }
下面代碼是堆內存分配的例子,變量localList是局部的引用類型。為它分配的內存會在垃圾回收執行時才被釋放。
void ExampleFunction() { List localList = new List(); }
使用Profiler查找堆分配
我們能夠在Profiler窗口中看到我們的代碼在哪創建了堆分配。
選中CPU usage profiler,我們可以選擇任意幀,在下部窗口查看cpu使用數據。其中一列數據叫做GC alloc。這一列顯示了這幀中執行的堆分配。如果我們點擊這列的標題,可以把數據按照GC alloc排序,使我們可以很輕松的看到游戲中哪些函數引起了最多的堆內存分配。一旦我們知道了引起問題的函數,我們就可以仔細的檢查它。
一旦我們知道了這個函數中的哪些代碼引起了垃圾生成,我們可以決定怎么去解決這個問題,並最小化垃圾的生成。
降低垃圾回收的影響
泛泛地說,我們有三種方法降低垃圾回收對我們游戲的影響:
-我們可以降低垃圾回收執行的時間。
-我們可以降低垃圾回收執行的頻率。
-我們可以在合適的時機手工觸發垃圾回收,防止它在性能臨機時發生,如在加載界面執行垃圾回收。
有三種策略可以幫助我們:
-我們可以組織我們的代碼,較少的分配堆內存,且較少對象引用。這意味着當垃圾回收觸發時,執行的比較快。
-我們可以降低堆內存的分配和釋放,尤其是在性能臨界時。這意味將較少的觸發垃圾回收,也能降低堆內存碎片化的風險。
-我們可以嘗試手工觸發垃圾回收和堆內存擴展以便他們在可預期的和方便的時間執行,這是比較困難且不太可靠的方法,但是作為整體內存管理策略的一部分也是能降低垃圾回收的影響的。
減少垃圾生成
讓我們看看幾項可以幫助我們代碼中降低垃圾生成的技術。
緩存
如果代碼重復的調用造成堆內存分配的方法,然后丟棄結果,將造成不必要的垃圾。作為代替,我們應該應該保存結果的引用並復用他們。這項技術成為緩存。
下面例子中,函數每次被調用時都會造成堆內存分配,因為有新的數組創建。
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() { ExampleGarbageGeneratingFunction(transform.position.x); }
通過簡單的修改,現在我們確保只有transform.position.x改變時,才調用生成垃圾的代碼。現在我們只有在必要的時候才會進行堆內存分配,比原來每幀都進行要好很多。
private float previousTransformPositionX; void Update() { float transformPositionX = transform.position.x; if (transformPositionX != previousTransformPositionX) { ExampleGarbageGeneratingFunction(transformPositionX); previousTransformPositionX = transformPositionX; } }
另一個在Update()中降低垃圾生成的技術是使用計時器。這種情況適用於造成堆內存分配的代碼必須定期運行,但是不需要每幀都運行時。
下面的例子代碼中,每幀生成垃圾:
void Update() { ExampleGarbageGeneratingFunction(); }
下面的例子中,我們使用計時器,每秒生成垃圾:
private float timeSinceLastCalled; private float delay = 1f; void Update() { timeSinceLastCalled += Time.deltaTime; if (timeSinceLastCalled > delay) { ExampleGarbageGeneratingFunction(); timeSinceLastCalled = 0f; } }
像這些小小的改變,很好的降低了垃圾生成的數量。
清除集合
創建新集合會在堆上分配內存。如果我們發現代碼中不止一次的創建新集合,那么我們應該緩存集合的引用,並使用Clear()方法清空內容來替代重復的創建新集合。
下面例子中,每次使用New方法,都會在堆上分配內存。
void Update() { List myList = new List(); PopulateList(myList); }
在下面的例子中,只有在集合創建或者在底層集合必須調整大小的時候才會分配堆內存,極大的降低了垃圾的生成數量。
private List myList = new List(); void Update() { myList.Clear(); PopulateList(myList); }
對象池
即使我們在腳本中降低了堆內存分配,我們可能還是有垃圾回收問題,如果我們在運行時創建和摧毀很多對象。對象池技術可以降低堆內存的分配和釋放,通過復用對象來替代重復的創建和摧毀對象。對象池技術在游戲中應用廣泛,特別適合當我們需要頻繁的生成和摧毀相似的對象時。例如射擊的子彈。
對象池的全面指導超出了本文范圍,但是他是一項值得學習的很有用處的技術。This tutorial on object pooling on the Unity Learn site是在Unity中實現對象池系統的很好的指導。
引起不必要的堆內存分配的通常原因
我們理解局部的,值類型的變量分配在棧上,其余類型的變量都分配在堆上,盡管如此,有許多在堆上分配內存的行為可能會出乎我們的意料。讓我們看看一些引起不必要的堆內存分配的通常原因並且考慮怎么做去減少這些問題。
字符串
在C#中,字符串是引用類型的,但是使用時候他們看起來像值類型那樣用。這意味着,創建和丟棄字符串會產生垃圾。由於字符串在我們代碼中很常用,這些垃圾可能會積少成多。
C#中的字符串是不可變的,這意味着她們的值在他們創建后不能被改變。我們每次操作字符串(例如,使用+去連接兩個字符串),Unity都創建一個新的字符串並保存結果值,然后丟棄舊的字符串,這會產生垃圾。
我們可以遵循下面一些簡單的規則使得從字符串生成的垃圾最小化。讓我們考慮這些規則,並看看應用例子。
-我們應該減少沒必要的字符串的創建。如果我們使用一樣的字符串多過一次,我們應該只創建一次字符串然后緩存它。
-我們應該減少沒必要的字符串操作。例如,如果我們需要頻繁的更新一個文本組件的值,並且其中包含一個連接字符串操作,我們應該考慮把它分成兩個獨立的文本組件。
-如果我們必須在運行時組建字符串,我們應該使用StringBuilder類。這個類是設計來做字符串組建的,並且不產生堆內存分配,在我們連接復雜字符串時,這將減少很多垃圾的產生。
-我們應該移除Debug.Log(),當不需要進行調試后。在我們游戲的正式版本中他雖然沒有輸出任何東西,但是仍然會被執行。調用一次Debug.Log()至少創建和釋放一次字符串,所以如果我們的游戲包含了很多調用,垃圾會積少成多。
讓我們看看下面的例子,它低效的使用字符串,產生了沒必要的垃圾。我們創建了字符串,並且在Update()方法中合並字符串,產生了很多沒必要的垃圾。
public Text timerText; private float timer; void Update() { timer += Time.deltaTime; timerText.text = "TIME:" + timer.ToString(); }
在下面的例子中,我們把字符串拆分成獨立的兩部分,在Update()中不再需要合並字符串了,減少了垃圾的產生。
public Text timerHeaderText; public Text timerValueText; private float timer; void Start() { timerHeaderText.text = "TIME:"; } void Update() { timerValueText.text = timer.toString(); }
Unity函數調用
意識到我們調用任何不是我們自己寫的代碼,無論是Unity自身還是插件,都會產生垃圾是十分重要的。一些Unity函數會造成堆內存分配,所以我們應該小心的使用,避免產生沒必要的垃圾。
這里並沒有我們應該避免調用的清單。每個函數都是在一些情況下有用,在另外一些情況下沒什么作用。一如既往,我們最好使用Profiler仔細的分析我們的游戲,確認垃圾在哪產生的,並且仔細思考如何處理他。有些情況下,可能緩存函數調用的結果是很明智的。另外一些情況下,也許不要調用函數那么頻繁。有時可能最好去重構我們的代碼,並且使用不同的函數。說了這么多,讓我們看看幾個例子,一些常用的Unity函數在堆上分配內存,我們如何去處理好他們。
每次我們調用一個返回數組的Unity函數,一個新的數組被創建,並且作為返回值返回給我們。這個行為並不總是很明顯或者像預期一樣。特別是當函數是存取器時(例如Mesh.normals)。
下面代碼,每次循環都會創建新的數組。
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方法替代他們。當檢查游戲對象的Tag是否相等時,使用GameObject.CompareTag()不會產生垃圾。
下面代碼中,GameObject.tag產生了垃圾:
private string playerTag = "Player"; void OnTriggerEnter(Collider other) { bool isPlayer = other.gameObject.tag == playerTag; } 我們修改后,不會產生任何垃圾:
private string playerTag = "Player"; void OnTriggerEnter(Collider other) { bool isPlayer = other.gameObject.CompareTag(playerTag); }
Unity還有很多的函數,有類似的不會引起堆內存分配的版本,例如我們可以使用Input.GetTouch() 和 Input.touchCount 代替Input.touches ,或者使用Physics.SphereCastNonAlloc() 代替Physics.SphereCastAll().
裝箱
當一個值類型變量,用在一個需要引用類型變量的位置時,會發生裝箱操作。裝箱操作,通常發生在我們把值類型的變量如int或者float,傳遞給需要object參數的函數時如Object.Equals()。
例如,函數String.Format()接收一個字符串和一個object參數。當我們傳參數一個字符串和一個int時,int必須被裝箱,下面是例子代碼:
void ExampleFunction() { int cost = 5; string displayString = String.Format("Price: {0} gold", cost); }
裝箱會產生垃圾是因為底層發生的行為。當一個值類型變量被裝箱時,Unity創建一個臨時的System.Object在堆上,去包裝值類型變量。System.Object是引用類型的變量,所以當這個臨時的對象被創建和銷毀時產生了垃圾。
裝箱引起的不必要的堆內存分配是十分常見的。即使我們在代碼中沒有直接裝箱操作,我們使用的插件或者其他間接調用的函數也可能在幕后進行了裝箱操作。避免裝箱操作的最佳實踐就是盡可能的少使用導致裝箱操作的函數,以及避免使用直接的裝箱操作。
協程
調用StartCoroutine()會產生少量的垃圾,因為Unity必須創建管理協程的類實例。考慮到這一點,當我們關注游戲的交互和性能時,應該有限制的使用協程。為了減少協程產生垃圾的影響,我們不建議在性能臨界的時候使用協程。我們還應該特別小心套嵌的協程,如包含延遲調用的協程。
協程中的yield語句自身不會產生堆內存分配;盡管如此,我們通過yield傳遞的值可能會產生不必要的堆內存分配。例如下面的代碼就會產生垃圾。
yield return 0;
這會產生垃圾是因為發生了裝箱,如果我們只是想要等待一幀,而不產生垃圾,最好是使用下面的代碼:
yield return null;
另一個使用協程的常見錯誤是在yield返回相同的值得時候,多次使用new。例如下面代碼中,每次循環都會創建和釋放WaitForSeconds對象:
while (!isComplete) { yield return new WaitForSeconds(1f); }
如果緩存了WaitForSeconds,可以減少很多垃圾:
WaitForSeconds delay = new WaitForSeconds(1f); while (!isComplete) { yield return delay; }
如果我們的代碼因為協程產生了很多的垃圾,我們也許需要考慮重構代碼去使用其他方法來代替協程。重構是很復雜的工作,並且每個項目都有自己的獨特性,但是一些關於協程的通用的改進方法,我們應該銘記在心。例如,如果我們使用協程只是為了管理時間,我們可以直接在Update()方法中處理時間相關的內容。如果我們使用協程是為了管理函數的執行順序,我們可以創建一個消息系統來使對象之間互相交流。這里沒有萬能的處理方法,但是請牢記,在代碼中實現相同的功能,通常並不只有一種方法。
foreach循環
在Unity5.5之前,foreach循環數組以外的集合時,每次循環都會產生垃圾。這是因為幕后的裝箱操作。每次循環開始System.Object都會在堆上被創建,在循環結束時被銷毀。這個問題已經在Unity5.5版本中修復了。
例如,在5.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); } }
函數引用
函數引用,不論是匿名方法還是命名的方法,在Uniyt中都是引用類型的變量。他們會引起堆內存分配。把匿名方法轉換為閉包會顯著的增加內存占用和堆內存分配的大小。
函數引用和閉包具體怎么明確的分配內存,取決於不同的平台和編譯設置,但是考慮到垃圾回收,我們最好少使用函數引用和閉包。
LINQ和正則表達式
他們都會產生垃圾,因為需要裝箱操作,如果需要考慮性能問題,那么最好不要使用他們。
組織我們的代碼以便最小化垃圾回收的影響
我們代碼的組織方式可以影響垃圾回收。甚至我們的代碼沒有產生堆內存分配,也可以增加垃圾回收器的負載。
我們不必要的增加垃圾回收器的負載的一種情況是,我們要求他去檢查原本不必要檢查的東西。結構體是值類型,但是如果我們在結構體中包含了引用類型的變量,那么垃圾回收器就需要檢查整個結構體。如果我們有一個由大量這種結構體組成的數組,那么將使得垃圾回收器做了很多額外的工作。
下面的例子中,結構體包含了字符串,垃圾回收器必須要檢查整個數組。
public struct ItemData { public string name; public int cost; public Vector3 position; }
private ItemData[] itemData;
在這個例子中,我們可以用三個獨立的數組存儲信息,這樣垃圾回收器就只需要處理字符串數組了。
private string[] itemNames; private int[] itemCosts; private Vector3[] itemPositions;
另個增加沒必要的垃圾回收器負載的情況是沒必要的持有對象引用。當垃圾回收器在堆上檢查對象的引用時,他必須在代碼中檢查每一個持有該對象的引用。我們持有堆中對象的引用越少,垃圾回收器的工作就會越少,哪怕我們並沒有減少堆上的對象。
在下面的例子中,我們有一個用於填充對話框的類。當用戶查看過后對話框時,另一個對話框會顯示出來。我們的代碼持有了一個用於填充下一個對話庫的數據類DialogData,這意味着垃圾回收器必須檢查這個引用。
public class DialogData { private DialogData nextDialog; public DialogData GetNextDialog() { return nextDialog; } }
這里,我們重構代碼,返回下一個DialogData的實例id,替代返回實例本身。這就不需要引用對象,也就不會增加垃圾回收器的負載了。
public class DialogData { private int nextDialogID; public int GetNextDialogID() { return nextDialogID; } }
這個例子比較瑣碎。盡管如此,如果我們的游戲擁有很多對象,並且持有很多其他對象,那么我們可以使用這種方式去重構代碼,以便降低垃圾回收器的負載。
安排垃圾回收的時間
手工強制執行垃圾回收
最終,我們也許希望我們自己觸發垃圾回收。如果我們知道堆內存被分配,但是已經不再使用了(例如,我們的代碼在加載資源時產生的垃圾)並且我們知道垃圾回收在此時不會影響玩家體驗(例如在顯示加載界面的時候),我們可以用下面的代碼強制執行垃圾回收:
System.GC.Collect();
這將會強制執行垃圾回收,釋放未被使用的堆內存,我們可以在方便的時機調用。
總結
我們已經學習了垃圾回收在Unity中是怎樣工作的,為什么他會引起性能問題,以及怎樣去最小化他對我們游戲的影響。使用這些知識和性能分析工具,我們可以解決垃圾回收相關的性能問題並且組織我們的代碼使得他們更有效的管理內存。
下面一些資源提供了相關主題的更多信息。
擴展閱讀
Unity中的內存管理和垃圾回收
Unity Manual: Understanding Automatic Memory Management
Gamasutra: C# Memory Management for Unity Developers by Wendelin Reich
Gamasutra: C# memory and performance tips for Unity by Robert Zubek
Gamasutra: Reducing memory allocations to avoid Garbage Collection on Unity by Grhyll JDD
Gamasutra: Unity Garbage Collection Tips and Tricks by Megan Hughes
裝箱
MSDN: Boxing and Unboxing (C# Programming Guide)
對象池
Unity Learn: Object Pooling Tutorial
Wikipedia: Object Pool Pattern
字符串
Best Practices for Using Strings in the .NET Framework