Unity遊戲Mono內存管理與泄漏

  這幾天看了一篇騰訊WeTest中關於Unity內存管理和泄漏問題的文章,感覺非常棒,報了了下來。原文地址:http://mp.weixin.qq.com/s?__biz=MzA3NjA3NTI5Mg==&mid=2656329129&idx=1&sn=81e7c9481ce03dadc51658b28213cfd6&scene=21#wechat_redirect
無論是遊戲還是VR應用,內存管理都是其研發階段的重中之重。然而,90%以上的項目都存在不同程度的內存使用問題。就目前基於Unity引擎開發的移動遊戲和移動VR遊戲而言,內存的開銷無外乎以下三大部分:1.資源內存佔用;2.引擎模塊自身內存佔用;3.託管堆內存佔用。

今天我們將針對由Mono分配和管理的託管堆內存,介紹Unity遊戲開發中面臨的Mono內存管理及泄漏問題。


什麼是Mono內存
對於目前絕大多數基於Unity引擎開發的項目而言,其託管堆內存是由Mono分配和管理的。“託管” 的本意是Mono可以自動地改變堆的大小來適應你所需要的內存,並且適時地調用垃圾回收(Garbage Collection)操作來釋放已經不需要的內存,從而降低開發人員在代碼內存管理方面的門檻。
Unity遊戲在運行時的內存佔用情況可以用下圖表示:

Unity遊戲Mono內存管理與泄漏 - IQ007偉哥 - IQ007偉哥的博客

 

目前絕大部分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會向操作系統申請內存,並擴充堆內存,具體如下圖所示。

Unity遊戲Mono內存管理與泄漏 - IQ007偉哥 - IQ007偉哥的博客 


通過上文可知,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)。

Unity遊戲Mono內存管理與泄漏 - IQ007偉哥 - IQ007偉哥的博客


如上圖所示,假設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內存泄漏的例子。

Unity遊戲Mono內存管理與泄漏 - IQ007偉哥 - IQ007偉哥的博客

 

解決辦法

對於mono內存泄漏,一般只能通過猜測+不斷修改代碼測試的方法來修復問題,效率很低,騰訊Wetest平臺的Cube工具提供了mono內存快照對比的功能,幷包括對象分配堆棧,對象引用關係等詳細信息,是定位mono內存泄漏問題的一大利器。下面結合具體的代碼嘗試使用Cube定位mono內存泄漏問題。
首先我們定義類A,並在A的構造函數中申請了一塊int[1000]大小的內存。
Unity遊戲Mono內存管理與泄漏 - IQ007偉哥 - IQ007偉哥的博客


接着我們定義A類型的靜態變量objectA,在遊戲界面上繪製一個按鈕,並在按鈕點擊事件中給objectA賦值,此時新生成了new int[1000]對象,並由objectA引用。
Unity遊戲Mono內存管理與泄漏 - IQ007偉哥 - IQ007偉哥的博客



使用Cube的mono內存檢測功能,並在按鈕按下之前和按下之後分別進行一次快照,對比兩次快照,查看快照間新增對象。
Unity遊戲Mono內存管理與泄漏 - IQ007偉哥 - IQ007偉哥的博客



可以看到,按鈕按下前後新增的最大對象即爲代碼中生成的new int[1000]對象,並且該對象被引用的次數爲1,爲了查看詳細的引用關係,下載快照文件snapshot2,其中有這樣兩行數據:
Unity遊戲Mono內存管理與泄漏 - IQ007偉哥 - IQ007偉哥的博客



第一行說明在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遊戲開發過程中不斷改善玩家的體驗。目前功能免費開放中。




發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章