內存是手游的硬傷——Unity遊戲Mono內存管理與泄漏
WeTest導讀
內存是遊戲的硬傷,如果沒有做好內存的管理問題,遊戲極有可能會出現卡頓,閃退等影響用戶體驗的現象。本文介紹了在騰訊遊戲在Unity遊戲開發過程中常見的Mono內存管理問題,並介紹了一系列解決的策略和方法。什麼是Mono內存
對於目前絕大多數基於Unity引擎開發的項目而言,其託管堆內存是由Mono分配和管理的。「託管」 的本意是Mono可以自動地改變堆的大小來適應你所需要的內存,並且適時地調用垃圾回收(Garbage Collection)操作來釋放已經不需要的內存,從而降低開發人員在代碼內存管理方面的門檻。Unity遊戲在運行時的內存佔用情況可以用下圖表示:
目前絕大部分Unity遊戲邏輯代碼所使用的語言為C#,C#代碼所佔用的內存又稱為mono內存,這是因為Unity是通過mono來跨平台解析並運行C#代碼的,在Android系統上,遊戲的lib目錄下存在的libmono.so文件,就是mono在Android系統上的實現。C#代碼通過mono解析執行,所需要的內存自然也是由mono來進行分配管理,下面就介紹一下mono的內存管理策略以及內存泄漏分析。
Mono內存管理策略
Mono通過垃圾回收機制(Garbage Collect,簡稱GC)對內存進行管理。Mono內存分為兩部分,已用內存(used)和堆內存(heap),已用內存指的是mono實際需要使用的內存,堆內存指的是mono向操作系統申請的內存,兩者的差值就是mono的空閑內存。當mono需要分配內存時,會先查看空閑內存是否足夠,如果足夠的話,直接在空閑內存中分配,否則mono會進行一次GC以釋放更多的空閑內存,如果GC之後仍然沒有足夠的空閑內存,則mono會向操作系統申請內存,並擴充堆內存,具體如下圖所示。通過上文可知,GC的主要作用在於從已用內存中找出那些不再需要使用的內存,並進行釋放。Mono中的GC主要有以下幾個步驟:
1.停止所有需要mono內存分配的線程。
2.遍歷所有已用內存,找到那些不再需要使用的內存,並進行標記。
3.釋放被標記的內存到空閑內存。
4.重新開始被停止的線程。
除了空閑內存不足時mono會自動調用GC外,也可以在代碼中調用GC.Collect()手動進行GC,但是,GC本身是比較耗時的操作,而且由於GC會暫停那些需要mono內存分配的線程(C#代碼創建的線程和主線程),因此無論是否在主線程中調用,GC都會導致遊戲一定程度的卡頓,需要謹慎處理。另外,GC釋放的內存只會留給mono使用,並不會交還給操作系統,因此mono堆內存是只增不減的。
Mono內存泄漏分析
Mono是如何判斷已用內存中哪些是不再需要使用的呢?是通過引用關係的方式來進行的。Mono會跟蹤每次內存分配的動作,並維護一個分配對象表,當GC的時候,以全局數據區和當前寄存器中的對象為根節點,按照引用關係進行遍歷,對於遍歷到的每一個對象,將其標記為活的(alive)。如上圖所示,假設A是處於全局數據區的一個對象,那麼在GC的時候將作為根節點進行遍歷,由於B、C、D對象都可以由A遍歷到,因此被標記為活的,E、F對象則沒有被標記。注意,由於引用關係是單向的,A引用了B並不代表B也引用了A,所以遍歷也只能單向進行。
由於GC以全局數據區和當前寄存器中的對象為根節點進行遍歷,所以對象的被標記意味著該對象可以通過全局對象或者當前上下文訪問到,而沒有被標記的對象則意味著該對象無法通過任何途徑訪問到,即該對象「失聯」了,GC最終會將所有「失聯」的對象內存進行回收,上圖中的E和F將會在GC過程中被回收。
既然mono已經有了完善的GC機制,那是否還會存在內存泄漏呢?答案是肯定的,只是此處的內存泄漏需要重新定義一下,我們把對象已經不再需要使用卻沒有被GC回收的情況稱為mono內存泄漏。Mono內存泄漏會使空閑內存減少,GC頻繁,mono堆不斷擴充,最終導致遊戲內存佔用的升高。下圖就是一個mono內存泄漏的例子。
解決辦法
對於mono內存泄漏,一般只能通過猜測+不斷修改代碼測試的方法來修復問題,效率很低,騰訊Wetest平台的Cube工具提供了mono內存快照對比的功能,並包括對象分配堆棧,對象引用關係等詳細信息,是定位mono內存泄漏問題的一大利器。下面結合具體的代碼嘗試使用Cube定位mono內存泄漏問題。首先我們定義類A,並在A的構造函數中申請了一塊int[1000]大小的內存。接著我們定義A類型的靜態變數objectA,在遊戲界面上繪製一個按鈕,並在按鈕點擊事件中給objectA賦值,此時新生成了new int[1000]對象,並由objectA引用。
使用Cube的mono內存檢測功能,並在按鈕按下之前和按下之後分別進行一次快照,對比兩次快照,查看快照間新增對象。可以看到,按鈕按下前後新增的最大對象即為代碼中生成的new int[1000]對象,並且該對象被引用的次數為1,為了查看詳細的引用關係,下載快照文件snapshot2,其中有這樣兩行數據:第一行說明在OnGUI函數中生成了一個A類型的對象,其指針為1533098928,第二行說明在OnGUI()->A:.cotr()中生成了一個Int32[]類型的對象,並且該對象被指針為1533098928的對象引用。即new int[1000]對象被objectA引用,這也是導致new int[1000]對象無法被GC回收的原因。而objectA本身是一個靜態對象,是GC的根節點,因此沒有對象引用。如果需要生成的new int[1000]對象被回收怎麼做呢?很簡單,將objectA.a設置為null,沒有了objectA對其的引用,自然會被GC回收了。需要說明的是,將objectA.a設置為null只是斷絕了引用關係,真正對象的回收要等到GC的時候才會進行,Cube在獲取內存快照的時候會首先進行一次GC,防止由於沒有及時調用GC導致的誤判。
遊戲中大部分mono內存泄漏的情況都是由於靜態對象的引用引起的,因此對於靜態對象的使用需要特別注意,盡量少用靜態對象,對於不再需要的對象將其引用設置為null,使其可以被GC及時回收,但是由於遊戲代碼過於複雜,對象間的引用關係層層嵌套,真正操作起來難度很大。可以首先使用Cube工具進行分析,根據mono內存趨勢找出泄漏的具體場景,然後再使用快照對比功能進行詳細分析。
騰訊遊戲品質管理團隊專門打造的工具「Cube」目前已經可以使用,「Cube」可以幫助開發者發現Unity手游內分類資源的佔用情況,尤其是對Unity遊戲場景中的FPS、CPU、PSS的變化趨勢重點關注,幫助在Unity遊戲開發過程中不斷改善玩家的體驗。目前功能免費開放中。推薦閱讀:
※深度緩衝優化指南
※在日本公司做手游,能賺多少?
※他們禁遊戲機的時候,我沒有說話
※Cocos2d-x 會不會被 Creator帶到溝里去?
※新成立的遊戲外包公司怎樣尋找客戶?