深入淺出再談Unity內存泄漏

轉載自:https://wetest.qq.com/lab/view/150.html

在之前推送的文章《內存是手遊的硬傷——騰訊遊戲談Unity遊戲Mono內存管理及泄漏問題》中,已經對騰訊遊戲在Unity遊戲開發過程中常見的Mono內存管理問題進行了介紹,收到了很多用戶的反饋,希望能夠更全面的介紹關於unity內存管理的問題。本期微信推送騰訊WeTest團隊邀請到了公司中資深的測試專家Arthuryu,對Unity內存泄漏進行一個更加系統的介紹。

 

內存泄漏及其危害

相信各位程序猿們或多或少都會聽到過內存泄漏這個名詞,但是對於一些新手猿來說,或許不是很瞭解。內存泄漏?是內存漏出來了麼?和霸氣側漏一樣麼?讓我們先來看一下wikipedia的定義:

 

 

看了一遍冗長的定義,或許各位猿們心中就是一個大寫的“暈”字。讓我們打一個通俗的比方來解釋下這個定義。

內存泄漏,可以通俗解釋爲“借銀行錢不還”。在計算機的二進制世界裏,操作系統就是銀行;每一筆貸款,都是一次內存的申請;而你,就是一個應用程序。即銀行貸款 應用程序操作系統申請內存。當然,在計算機世界中,我們需要感謝操作系統,因爲他是一個不收利息的銀行,你借了多少內存,你就只需要還回多少內存。那麼我們可以總結一下,內存泄漏的簡單定義,就是申請了內存,卻沒有在該釋放的時候釋放

如果你總是貸款而不還錢,那麼銀行裏的錢就越來越少,最終導致其他人要借錢時,就無錢可借了。現實生活中,銀行爲了避免無錢可接,就會把總是借錢不還的人拉入黑名單,不再借他錢;而操作系統則更加兇殘,他會直接“做了你”,操作系統將會直接kill掉應用程序。由此可以看出,內存泄漏的危害性與嚴重性,如果持續泄漏,將因內存佔用過大而導致應用崩潰。當然泄漏還有其他的危害,例如內存被無用對象佔用,導致接下來的內存分配需要更高的時間成本,從而造成遊戲的卡頓等等。

 

Unity中的內存泄漏

在對內存泄漏有一個基本印象之後,我們再來看一下在特定環境——Unity下的內存泄漏。大家都知道,遊戲程序由代碼和資源兩部分組成,Unity下的內存泄漏也主要分爲代碼側的泄漏和資源側的泄漏,當然,資源側的泄漏也是因爲在代碼中對資源的不合理引用引起的。

  • 代碼中的泄漏 – Mono內存泄漏

熟悉Unity的猿類們應該都知道,Unity是使用基於Mono的C#(當然還有其他腳本語言,不過使用的人似乎很少,在此不做討論)作爲腳本語言,它是基於Garbage Collection(以下簡稱GC)機制的內存託管語言。那麼既然是內存託管了,爲什麼還會存在內存泄漏呢?因爲GC本身並不是萬能的,GC能做的是通過一定的算法找到“垃圾”,並且自動將“垃圾”佔用的內存回收。那麼什麼是垃圾呢?

我們先來看一下wikipedia上對於GC實現的簡介: 

 

                                       

 

定義還是過於冗長,我們來聯想一下生活中,我們一般把沒有利用價值的東西,稱爲垃圾,也就是沒有用的東西,就是垃圾。在GC的世界中,也是一樣的,沒有引用的東西,就是“垃圾”。因爲沒有引用了,就意味着對於其他任何對象而言,都認爲目標對象對我已經沒有利用價值了,那它就是“垃圾”了。根據GC的機制,其佔用的內存就會被回收。

基於以上的知識,我們很容易就可以想到爲什麼在託管內存的環境下,還是會出現內存泄漏了。這就像現實生活中的宅男宅女,吃了泡麪總是忘記把盒子扔到門外的垃圾箱裏;從計算機的角度來說,則是,在某對象超出其作用域時,我們 “忘記”清除對該無用對象的引用了。

說到這,有的同學可能會有疑問:我每次在代碼中申請的內存都非常小,少則幾B,多則幾十K,現在設備的內存都比較大(幾百M還是有的吧),即使泄漏會產生什麼大影響麼?

首先,水滴石穿的典故相信大家都知道,實際代碼中,並非只有顯示調用new纔會分配內存,很多隱式的分配是不容易被發現的,例如產生一個List來存儲數據,緩存了服務器下發的一份配置,產生一個字符串等等,這些操作都會產生內存的分配。你分配幾十K,他分配幾十K,一會兒內存就沒了。

其次,有一點需要說明的是,在Unity環境下,Mono堆內存的佔用,是隻會增加不會減少的。具體來說,可以將Mono堆,理解爲一個內存池,每次Mono內存的申請,都會在池內進行分配;釋放的時候,也是歸還給池,而不會歸還給操作系統。如果某次分配,發現池內內存不夠了,則會對池進行擴建——向操作系統申請更多的內存擴大池以滿足該次的內存分配。需要注意的是,每次對池的擴建,都是一次較大的內存分配,每次擴建,都會將池擴大6-10M左右(此處無官方數據,是觀察所得)。

 

 

上圖是某遊戲經過Cube測試的結果,可以看到Mono堆(圖中黑線)內存已經達到70M+。

由此可知,Mono內存泄漏是Unity遊戲開發中需要特別重視的部分。

  • 資源中的泄漏 – Native內存泄漏

資源泄漏,顧名思義,是指將資源加載之後佔有了內存,但是在資源不用之後,沒有將資源卸載導致內存的無謂佔用。

同樣的,在討論資源內存泄漏的原因之前,我們先來看一下Unity的資源管理與回收方式。爲什麼要將資源內存和代碼內存分開討論,也是因爲其內存管理方式存在不同的原因。

上文中說的代碼分配的內存,是通過Mono虛擬機,分配在Mono堆內存上的,其內存佔用量一般較小,主要目的是程序猿在處理程序邏輯時使用;而Unity的資源,是通過Unity的C++層,分配在Native堆內存上的那部分內存。舉個簡單的例子,通過UnityEngine命名空間中的接口分配的內存,將會通過Unity分配在Native堆;通過System命名空間中的接口分配的內存,將會通過Mono Runtime分配在Mono堆。

 

 

瞭解了分配與管理方式的區別,我們再來看看回收的方式。如上文所說,Mono內存是通過GC來回收的,而Unity也提供了一種類似的方式來回收內存。不同的是,Unity的內存回收是需要主動觸發的。就好比說,我們把垃圾扔在門口的垃圾桶裏,GC是每天來看一次,有垃圾就收走;而Unity則需要你打個電話給它,通知它有垃圾要回收,它纔會來。主動調用的接口是Resources.UnloadUnusedAssets()。其實GC也提供了同樣的接口GC.Collect()

用來主動觸發垃圾回收,這兩個接口都需要很大的計算量,我們不建議在遊戲運行時時不時主動調用一番,一般來說,爲了避免遊戲卡頓,建議在加載環節來處理垃圾回收的操作。有一點需要說明的是,Resources.UnloadUnusedAssets()
內部本身就會調用GC.Collect()。Unity還提供了另外一個更加暴力的方式——Resources.UnloadAssets()來卸載資源,但是這個接口無論資源是不是“垃圾”,都會直接刪除,是一個很危險的接口,建議確定資源不使用的情況下,再調用該接口。

基於上述基礎知識,我們再來看一下爲什麼會有資源的泄漏。首先和代碼側的泄漏一樣,由於“存在該釋放卻沒有釋放的錯誤引用”,導致回收機制認爲目標對象不是“垃圾”,以至於不能被回收,這也是最常見的一種情況。

針對資源,還有一種典型的泄漏情況。由於資源卸載是主動觸發的,那麼清除對資源引用的時機就顯得尤爲重要。現在遊戲的邏輯趨於複雜化,同時如果有新成員加入項目組,也未必能夠清楚地瞭解所有資源管理的細節,如果“在觸發了資源卸載之後,才清除對資源引用”,同樣也會出現內存泄漏了。

 

趕上了資源回收     

 

 錯過了資源回收

還有一種資源上的泄漏,是因爲Unity的一些接口在調用時會產生一份拷貝(例如Renderer.Material參考https://docs.unity3d.com/ScriptReference/Renderer-material.html),如果在使用上不注意的話,運行時會產生較多的資源拷貝,造成內存的無端浪費。但是此類內存拷貝一般量較少,修復起來也比較簡單,這裏不做大篇幅的介紹。

 

修復內存泄漏

根據上文描述,我們知道只要在回收到來之前,將引用解開就可以避免內存泄漏了,似乎是個很簡單的問題。但是由於實際項目的邏輯複雜度往往超出想象,引用關係也不是簡單的一層兩層(有時候往往會多達十幾層,甚至數十層才連接到最終的引用對象),並且可能存在交叉引用、環狀引用等複雜情況,單純從代碼review的角度,是很難正確地解開引用的。如何查找導致泄漏的引用,是修復泄漏的難點和重點,也是本文主要想介紹的部分,下面就針對如何查找引用介紹一些思路和方法。至於時序問題,比較簡單,在此不做贅述。

  • New Memory Profiler For Unity5

Unity的Memory Profiler一直就是一個被用戶詬病的地方,對於內存的使用量,被誰使用等信息,沒有很好的反映。Unity5作爲最新一代的Unity產品,對於這個弱點進行了一些補強,推出了新一代的內存分析工具,較好地解決了上述問題。但是沒有提供兩次(或多次)內存快照的比較功能,這點比較遺憾。

注:內存快照比較是尋找內存泄漏的常用手段,將兩次內存的狀態截取出來,進行比較,可以清楚地發現內存的變化,尋找內存的增量與泄漏點。一般會在遊戲進關前以及出關後做兩次dump,其中新增的內存分配,可以視爲泄漏。

 

 

 

由於是Unity官方的工具,網上有比較詳細的使用教程,在此不加贅述,可以參考下列鏈接或Google:

Unity-Technologies MemoryProfiler
memoryprofiler intro

由於Unity5普及度及穩定性還有待提升,公司內普遍還是4.x的環境,那麼上述的新工具就不適用了。有的同學說,升級一個5的工程來做Memory Profile嘛,這個當然也可以,不過Unity5對於4的兼容性不太好,升級過程中需要修改不少東西,維護兩個工程也是比較麻煩的事。

那麼,下面就給出兩個在Unity4環境下也可以使用的泄漏追蹤工具。

  • Mono內存的放大鏡——Cube

Cube是 騰訊遊戲下的騰訊WeTest平臺上針對Unity項目的性能指標收集工具,通過Cube可以較方便地獲取到遊戲的各項性能指標,爲性能優化提供了方向。同時Cube也是遊戲性能一個很好的衡量工具。微信號沒法直接點開鏈接,所以點擊“文尾鏈接”可以進到工具頁面。(我真的不是在做廣告)

 

這裏我們利用“MONO內存對象深度分析”的特點。該功能可以允許用戶抓取某一時刻的Mono內存狀態,並且提供不同時刻內存狀態的比較,快速定位到新增的內存分配。 

鑑於Cube官方已經給出了詳細的使用說明,就不再贅述數據的抓取過程。這裏簡單聊一下如何通過Cube抓取的數據更好地追蹤和解決問題。

如下圖所示,假設我們已經抓取了兩次數據(snapshot1 & snapshot2),並且進行比較,得到兩次內存快照之間新增的分配數據。

 

 

比較之後得到如下圖所示的一系列數據,總結來說,就是在某個堆棧,分配了某個類型的對象,佔用xx內存。這樣的數據會有成千上萬條(上文所說,代碼中的內存分配,是非常細碎,並且數量極多的,在這裏得到了驗證),並且其中有很多堆棧是重複的,因爲每一次的內存分配(即使是同一處位置產生的分配),都會產生一條記錄。無序的數據影響了我們對數據的處理,這裏我們對數據做一些分析整理。

 

 

我們舉一些簡單的例子來說明處理的過程。

每一條記錄,都是經過一系列的函數調用(堆棧),最終分配了一些內存,用圖形化的方式表示爲:

 

 

 

讓我們多加一些數據:

 

 

通過對圖的觀察,我們發現可以把上述離散的圖整理成一顆樹:

 

 

將所有數據都做同樣的歸類處理之後,可以得到一棵或多棵這樣的分配樹。這麼做的好處是:

1) 根據函數,可以將內存的分配做一個模塊的劃分,快速定位到相關的模塊

2) 可以清晰地看到每一層函數的分配總量(如A函數總共分配4096+20+4096B),可以根據佔用內存的多少決定修復的優先級

將對比之後的新增項一一清理之後,就可以基本清除Mono內存的多餘分配和泄漏了。

  • 順藤摸瓜——從Mono中尋找資源引用

在嘗試尋找資源引用,修復資源泄露之前,我們需要先了解一下如何在Unity中定位資源泄漏。

我們需要使用Unity自帶的Memory Profiler(注意不是上文說的Unity5的新Profiler,是老的殘疾版Profiler)。舉個簡單的例子,在Unity編輯器環境下運行遊戲工程,經過“大廳”頁面,進入到“單局”。此時打開Unity Profiler,切換到Memory並做一次內存採樣(具體請參考https://docs.unity3d.com/Manual/ProfilerMemory.html,不贅述)。在採樣的結果中(其中包含採樣時刻內存中所有的資源),點開Assets->Texture2D,如果其中可以看到有“大廳”UI使用的貼圖(如下圖),那麼我們可以定義這張UI貼圖,屬於資源上的泄漏。

 

 

爲什麼說這種情況就屬於資源泄漏呢,因爲這張UI貼圖,是在“大廳”時申請的,但是在“單局”時,它已經不被需要了,可是它還在內存中。這種在不需要的時候,卻還存在的內存佔用,就是上文我們定義的內存泄漏。

那麼在平時項目中,我們如何找到這些泄漏的資源呢?

最直觀的方法,當然也是最笨的方法,就是在每次遊戲狀態切換的時候,做一次內存採樣,並且將內存中的資源一一點開查看,判斷它是否是當前遊戲狀態真正需要的。這種方法最大的問題,就是耗時耗力,資源數量太多眼睛容易看花看漏。

這裏介紹兩種討巧的方法:

1) 通過資源名來識別。即在美術資源(如貼圖、材質)命名的時候,就將其所屬的遊戲狀態放在文件名中,如某貼圖叫做BG.png,在大廳中使用,則修改爲OG_BG.png(OG = OutGame)。這樣在一坨IG(IG=InGame)資源裏面,混入了一個OG,可以很容易地識別出來,也方便利用程序來識別。這麼做還有一個好處,可以強化美術對資源生命週期的認識,在製作資源,特別是規劃UI圖集時,可以有一個指導意義。

2) 通過Unity提供的接口Resources.UnloadUnusedAssets()進行資源的Dump,可以根據需求Dump貼圖、材質、模型或其他資源類型,只需要將Type作爲參數傳入即可。Dump成功之後我們將結果保存成一份文本文件,這樣可以用Beyond Compare對多次Dump之後的結果進行比較,找到新增的資源,那麼這些資源就是潛在的泄漏對象,需要重點追查。

結合上述的方法與思路,應該可以輕鬆找到泄漏的資源了。

此時我們再回頭看一下Unity Profiler,其實Unity提供了資源索引的查找功能,只不過該功能是以一個樹形結構的文本來展示的(如下圖)。上文曾提到過,Unity內部的引用關係往往是非常複雜的,可能需要通過十幾甚至幾十層的引用,才能找到最終的引用者,並且引用關係錯綜複雜,形成一張龐大的圖,此時光靠展開樹形結構來查找,幾乎是不可能的事了。

 

 

 

防微杜漸,避免內存泄漏

說完了如何修復內存泄漏,我還想往下多講一步,只要我們在平時開發的過程多做思考,防微杜漸,內存泄漏是完全可以避免的。相對於等泄漏發生了再回頭來追查,平時多花點時間清理“垃圾”反而是更加高效的做法。

落地到平時的開發流程中,在這裏提出幾點建議,歡迎各位大牛補充:

1) 在架構上,多添加析構的abstract接口,提醒團隊成員,要注意清理自己產生的“垃圾”。

2) 嚴格控制static的使用,非必要的地方禁止使用static。

3) 強化生命週期的概念,無論是代碼對象還是資源,都有它存在的生命週期,在生命週期結束後就要被釋放。如果可能,需要在功能設計文檔中對生命週期加以描述。

作爲一名合格的程序猿,也應該能夠處理好代碼中的“垃圾”,不要讓我們的遊戲成爲一個“垃圾場”。

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