轉載請標明出處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帶有參數返回,則會造成不必要的內存垃圾,例如:
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操作來回收堆內存。
總結
通過本文對於unity中的GC有了一定的了解,對於GC對於游戲性能的影響以及如何解決都有一定的了解。通過定位造成GC問題的代碼以及代碼重構我們可以更有效的管理游戲的內存。
接着我會繼續寫一些Unity相關的文章。翻譯的工作,在后面有機會繼續進行。