作者:Arthuryu,騰訊高級開發(fā)工程師
著作權(quán)歸作者所有。商業(yè)轉(zhuǎn)載請聯(lián)系騰訊WeTest獲得授權(quán),非商業(yè)轉(zhuǎn)載請注明出處。
WeTest導(dǎo)讀
本文通過對內(nèi)存泄漏(what)及其危害性(why)的介紹,引出在Unity環(huán)境下定位和修復(fù)內(nèi)存泄漏的方法和工具(how)。最后提出了一些避免泄漏的方法與建議。
在之前推送的文章《內(nèi)存是手游的硬傷——騰訊游戲談Unity游戲Mono內(nèi)存管理及泄漏問題》中,已經(jīng)對騰訊游戲在Unity游戲開發(fā)過程中常見的Mono內(nèi)存管理問題進(jìn)行了介紹,收到了很多用戶的反饋,希望能夠更全面的介紹關(guān)于unity內(nèi)存管理的問題。本期微信推送騰訊WeTest團(tuán)隊邀請到了公司中資深的測試專家Arthuryu,對Unity內(nèi)存泄漏進(jìn)行一個更加系統(tǒng)的介紹。
內(nèi)存泄漏及其危害
相信各位程序猿們或多或少都會聽到過內(nèi)存泄漏這個名詞,但是對于一些新手猿來說,或許不是很了解。內(nèi)存泄漏?是內(nèi)存漏出來了么?和霸氣側(cè)漏一樣么?讓我們先來看一下wikipedia的定義:
看了一遍冗長的定義,或許各位猿們心中就是一個大寫的“暈”字。讓我們打一個通俗的比方來解釋下這個定義。
內(nèi)存泄漏,可以通俗解釋為“借銀行錢不還”。在計算機(jī)的二進(jìn)制世界里,操作系統(tǒng)就是銀行;每一筆貸款,都是一次內(nèi)存的申請;而你,就是一個應(yīng)用程序。即你向銀行貸款 = 應(yīng)用程序向操作系統(tǒng)申請內(nèi)存。當(dāng)然,在計算機(jī)世界中,我們需要感謝操作系統(tǒng),因為他是一個不收利息的銀行,你借了多少內(nèi)存,你就只需要還回多少內(nèi)存。那么我們可以總結(jié)一下,內(nèi)存泄漏的簡單定義,就是申請了內(nèi)存,卻沒有在該釋放的時候釋放。
如果你總是貸款而不還錢,那么銀行里的錢就越來越少,最終導(dǎo)致其他人要借錢時,就無錢可借了?,F(xiàn)實生活中,銀行為了避免無錢可接,就會把總是借錢不還的人拉入黑名單,不再借他錢;而操作系統(tǒng)則更加兇殘,他會直接“做了你”,操作系統(tǒng)將會直接kill掉應(yīng)用程序。由此可以看出,內(nèi)存泄漏的危害性與嚴(yán)重性,如果持續(xù)泄漏,將因內(nèi)存占用過大而導(dǎo)致應(yīng)用崩潰。當(dāng)然泄漏還有其他的危害,例如內(nèi)存被無用對象占用,導(dǎo)致接下來的內(nèi)存分配需要更高的時間成本,從而造成游戲的卡頓等等。
Unity中的內(nèi)存泄漏
在對內(nèi)存泄漏有一個基本印象之后,我們再來看一下在特定環(huán)境——Unity下的內(nèi)存泄漏。大家都知道,游戲程序由代碼和資源兩部分組成,Unity下的內(nèi)存泄漏也主要分為代碼側(cè)的泄漏和資源側(cè)的泄漏,當(dāng)然,資源側(cè)的泄漏也是因為在代碼中對資源的不合理引用引起的。
代碼中的泄漏 – Mono內(nèi)存泄漏
熟悉Unity的猿類們應(yīng)該都知道,Unity是使用基于Mono的C#(當(dāng)然還有其他腳本語言,不過使用的人似乎很少,在此不做討論)作為腳本語言,它是基于Garbage Collection(以下簡稱GC)機(jī)制的內(nèi)存托管語言。那么既然是內(nèi)存托管了,為什么還會存在內(nèi)存泄漏呢?因為GC本身并不是萬能的,GC能做的是通過一定的算法找到“垃圾”,并且自動將“垃圾”占用的內(nèi)存回收。那么什么是垃圾呢?
我們先來看一下wikipedia上對于GC實現(xiàn)的簡介:
定義還是過于冗長,我們來聯(lián)想一下生活中,我們一般把沒有利用價值的東西,稱為垃圾,也就是沒有用的東西,就是垃圾。在GC的世界中,也是一樣的,沒有引用的東西,就是“垃圾”。因為沒有引用了,就意味著對于其他任何對象而言,都認(rèn)為目標(biāo)對象對我已經(jīng)沒有利用價值了,那它就是“垃圾”了。根據(jù)GC的機(jī)制,其占用的內(nèi)存就會被回收。
基于以上的知識,我們很容易就可以想到為什么在托管內(nèi)存的環(huán)境下,還是會出現(xiàn)內(nèi)存泄漏了。這就像現(xiàn)實生活中的宅男宅女,吃了泡面總是忘記把盒子扔到門外的垃圾箱里;從計算機(jī)的角度來說,則是,在某對象超出其作用域時,我們 “忘記”清除對該無用對象的引用了。
說到這,有的同學(xué)可能會有疑問:我每次在代碼中申請的內(nèi)存都非常小,少則幾B,多則幾十K,現(xiàn)在設(shè)備的內(nèi)存都比較大(幾百M還是有的吧),即使泄漏會產(chǎn)生什么大影響么?
首先,水滴石穿的典故相信大家都知道,實際代碼中,并非只有顯示調(diào)用new才會分配內(nèi)存,很多隱式的分配是不容易被發(fā)現(xiàn)的,例如產(chǎn)生一個List來存儲數(shù)據(jù),緩存了服務(wù)器下發(fā)的一份配置,產(chǎn)生一個字符串等等,這些操作都會產(chǎn)生內(nèi)存的分配。你分配幾十K,他分配幾十K,一會兒內(nèi)存就沒了。
其次,有一點需要說明的是,在Unity環(huán)境下,Mono堆內(nèi)存的占用,是只會增加不會減少的。具體來說,可以將Mono堆,理解為一個內(nèi)存池,每次Mono內(nèi)存的申請,都會在池內(nèi)進(jìn)行分配;釋放的時候,也是歸還給池,而不會歸還給操作系統(tǒng)。如果某次分配,發(fā)現(xiàn)池內(nèi)內(nèi)存不夠了,則會對池進(jìn)行擴(kuò)建——向操作系統(tǒng)申請更多的內(nèi)存擴(kuò)大池以滿足該次的內(nèi)存分配。需要注意的是,每次對池的擴(kuò)建,都是一次較大的內(nèi)存分配,每次擴(kuò)建,都會將池擴(kuò)大6-10M左右(此處無官方數(shù)據(jù),是觀察所得)。
上圖是某游戲經(jīng)過Cube測試的結(jié)果,可以看到Mono堆內(nèi)存為39M左右,而建議值一般為 50M。
我們必須知道,Mono內(nèi)存泄漏是Unity游戲開發(fā)中需要特別重視的部分。
資源中的泄漏 – Native內(nèi)存泄漏
資源泄漏,顧名思義,是指將資源加載之后占有了內(nèi)存,但是在資源不用之后,沒有將資源卸載導(dǎo)致內(nèi)存的無謂占用。
同樣的,在討論資源內(nèi)存泄漏的原因之前,我們先來看一下Unity的資源管理與回收方式。為什么要將資源內(nèi)存和代碼內(nèi)存分開討論,也是因為其內(nèi)存管理方式存在不同的原因。
上文中說的代碼分配的內(nèi)存,是通過Mono虛擬機(jī),分配在Mono堆內(nèi)存上的,其內(nèi)存占用量一般較小,主要目的是程序猿在處理程序邏輯時使用;而Unity的資源,是通過Unity的C++層,分配在Native堆內(nèi)存上的那部分內(nèi)存。舉個簡單的例子,通過UnityEngine命名空間中的接口分配的內(nèi)存,將會通過Unity分配在Native堆;通過System命名空間中的接口分配的內(nèi)存,將會通過Mono Runtime分配在Mono堆。
了解了分配與管理方式的區(qū)別,我們再來看看回收的方式。如上文所說,Mono內(nèi)存是通過GC來回收的,而Unity也提供了一種類似的方式來回收內(nèi)存。不同的是,Unity的內(nèi)存回收是需要主動觸發(fā)的。就好比說,我們把垃圾扔在門口的垃圾桶里,GC是每天來看一次,有垃圾就收走;而Unity則需要你打個電話給它,通知它有垃圾要回收,它才會來。主動調(diào)用的接口是Resources.UnloadUnusedAssets()。其實GC也提供了同樣的接口GC.Collect()
用來主動觸發(fā)垃圾回收,這兩個接口都需要很大的計算量,我們不建議在游戲運行時時不時主動調(diào)用一番,一般來說,為了避免游戲卡頓,建議在加載環(huán)節(jié)來處理垃圾回收的操作。有一點需要說明的是,Resources.UnloadUnusedAssets()內(nèi)部本身就會調(diào)用GC.Collect()。Unity還提供了另外一個更加暴力的方式——Resources.UnloadAsset()來卸載資源,但是這個接口無論資源是不是“垃圾”,都會直接刪除,是一個很危險的接口,建議確定資源不使用的情況下,再調(diào)用該接口。
基于上述基礎(chǔ)知識,我們再來看一下為什么會有資源的泄漏。首先和代碼側(cè)的泄漏一樣,由于“存在該釋放卻沒有釋放的錯誤引用”,導(dǎo)致回收機(jī)制認(rèn)為目標(biāo)對象不是“垃圾”,以至于不能被回收,這也是最常見的一種情況。
針對資源,還有一種典型的泄漏情況。由于資源卸載是主動觸發(fā)的,那么清除對資源引用的時機(jī)就顯得尤為重要。現(xiàn)在游戲的邏輯趨于復(fù)雜化,同時如果有新成員加入項目組,也未必能夠清楚地了解所有資源管理的細(xì)節(jié),如果“在觸發(fā)了資源卸載之后,才清除對資源引用”,同樣也會出現(xiàn)內(nèi)存泄漏了。
還有一種資源上的泄漏,是因為Unity的一些接口在調(diào)用時會產(chǎn)生一份拷貝(例如Renderer.Material參考https://docs.unity3d.com/ScriptReference/Renderer-material.html),如果在使用上不注意的話,運行時會產(chǎn)生較多的資源拷貝,造成內(nèi)存的無端浪費。但是此類內(nèi)存拷貝一般量較少,修復(fù)起來也比較簡單,這里不做大篇幅的介紹。
修復(fù)內(nèi)存泄漏
根據(jù)上文描述,我們知道只要在回收到來之前,將引用解開就可以避免內(nèi)存泄漏了,似乎是個很簡單的問題。但是由于實際項目的邏輯復(fù)雜度往往超出想象,引用關(guān)系也不是簡單的一層兩層(有時候往往會多達(dá)十幾層,甚至數(shù)十層才連接到最終的引用對象),并且可能存在交叉引用、環(huán)狀引用等復(fù)雜情況,單純從代碼review的角度,是很難正確地解開引用的。如何查找導(dǎo)致泄漏的引用,是修復(fù)泄漏的難點和重點,也是本文主要想介紹的部分,下面就針對如何查找引用介紹一些思路和方法。至于時序問題,比較簡單,在此不做贅述。
New Memory Profiler For Unity5
Unity的Memory Profiler一直就是一個被用戶詬病的地方,對于內(nèi)存的使用量,被誰使用等信息,沒有很好的反映。Unity5作為最新一代的Unity產(chǎn)品,對于這個弱點進(jìn)行了一些補(bǔ)強(qiáng),推出了新一代的內(nèi)存分析工具,較好地解決了上述問題。但是沒有提供兩次(或多次)內(nèi)存快照的比較功能,這點比較遺憾。
注:內(nèi)存快照比較是尋找內(nèi)存泄漏的常用手段,將兩次內(nèi)存的狀態(tài)截取出來,進(jìn)行比較,可以清楚地發(fā)現(xiàn)內(nèi)存的變化,尋找內(nèi)存的增量與泄漏點。一般會在游戲進(jìn)關(guān)前以及出關(guān)后做兩次dump,其中新增的內(nèi)存分配,可以視為泄漏。
由于是Unity官方的工具,網(wǎng)上有比較詳細(xì)的使用教程,在此不加贅述,可以參考下列鏈接或Google:
Unity-Technologies MemoryProfiler
memoryprofiler intro
由于Unity5普及度及穩(wěn)定性還有待提升,公司內(nèi)普遍還是4.x的環(huán)境,那么上述的新工具就不適用了。有的同學(xué)說,升級一個5的工程來做Memory Profile嘛,這個當(dāng)然也可以,不過Unity5對于4的兼容性不太好,升級過程中需要修改不少東西,維護(hù)兩個工程也是比較麻煩的事。
那么,下面就給出兩個在Unity4環(huán)境下也可以使用的泄漏追蹤工具。
Mono內(nèi)存的放大鏡——Cube
Cube是 騰訊游戲下的騰訊WeTest平臺上針對Unity項目的性能指標(biāo)收集工具,通過Cube可以較方便地獲取到游戲的各項性能指標(biāo),為性能優(yōu)化提供了方向。同時Cube也是游戲性能一個很好的衡量工具。微信號沒法直接點開鏈接,所以點擊“閱讀原文”可以進(jìn)到工具頁面。(我真的不是在做廣告)
鑒于Cube官方已經(jīng)給出了詳細(xì)的使用說明,就不再贅述數(shù)據(jù)的抓取過程。這里簡單聊一下如何通過Cube抓取的數(shù)據(jù)更好地追蹤和解決問題。
如下圖所示,假設(shè)我們已經(jīng)抓取了兩次數(shù)據(jù)(snapshot1 & snapshot2),并且進(jìn)行比較,得到兩次內(nèi)存快照之間新增的分配數(shù)據(jù)。
比較之后得到如下圖所示的一系列數(shù)據(jù),總結(jié)來說,就是在某個堆棧,分配了某個類型的對象,占用xx內(nèi)存。這樣的數(shù)據(jù)會有成千上萬條(上文所說,代碼中的內(nèi)存分配,是非常細(xì)碎,并且數(shù)量極多的,在這里得到了驗證),并且其中有很多堆棧是重復(fù)的,因為每一次的內(nèi)存分配(即使是同一處位置產(chǎn)生的分配),都會產(chǎn)生一條記錄。無序的數(shù)據(jù)影響了我們對數(shù)據(jù)的處理,這里我們對數(shù)據(jù)做一些分析整理。
我們舉一些簡單的例子來說明處理的過程。
每一條記錄,都是經(jīng)過一系列的函數(shù)調(diào)用(堆棧),最終分配了一些內(nèi)存,用圖形化的方式表示為:
讓我們多加一些數(shù)據(jù):
通過對圖的觀察,我們發(fā)現(xiàn)可以把上述離散的圖整理成一棵樹:
將所有數(shù)據(jù)都做同樣的歸類處理之后,可以得到一棵或多棵這樣的分配樹。這么做的好處是:
1) 根據(jù)函數(shù),可以將內(nèi)存的分配做一個模塊的劃分,快速定位到相關(guān)的模塊。
2) 可以清晰地看到每一層函數(shù)的分配總量(如A函數(shù)總共分配4096+20+4096B),可以根據(jù)占用內(nèi)存的多少決定修復(fù)的優(yōu)先級。
將對比之后的新增項一一清理之后,就可以基本清除Mono內(nèi)存的多余分配和泄漏了。
順藤摸瓜——從Mono中尋找資源引用
在嘗試尋找資源引用,修復(fù)資源泄露之前,我們需要先了解一下如何在Unity中定位資源泄漏。
我們需要使用Unity自帶的Memory Profiler(注意不是上文說的Unity5的新Profiler,是老的殘疾版Profiler)。舉個簡單的例子,在Unity編輯器環(huán)境下運行游戲工程,經(jīng)過“大廳”頁面,進(jìn)入到“單局”。此時打開Unity Profiler,切換到Memory并做一次內(nèi)存采樣(具體請參考https://docs.unity3d.com/Manual/ProfilerMemory.html,不贅述)。 在采樣的結(jié)果中(其中包含采樣時刻內(nèi)存中所有的資源),點開Assets->Texture2D,如果其中可以看到有“大廳”UI使用的貼圖(如下圖),那么我們可以定義這張UI貼圖,屬于資源上的泄漏。
為什么說這種情況就屬于資源泄漏呢,因為這張UI貼圖,是在“大廳”時申請的,但是在“單局”時,它已經(jīng)不被需要了,可是它還在內(nèi)存中。這種在不需要的時候,卻還存在的內(nèi)存占用,就是上文我們定義的內(nèi)存泄漏。
那么在平時項目中,我們?nèi)绾握业竭@些泄漏的資源呢?
最直觀的方法,當(dāng)然也是最笨的方法,就是在每次游戲狀態(tài)切換的時候,做一次內(nèi)存采樣,并且將內(nèi)存中的資源一一點開查看,判斷它是否是當(dāng)前游戲狀態(tài)真正需要的。這種方法最大的問題,就是耗時耗力,資源數(shù)量太多眼睛容易看花看漏。
這里介紹兩種討巧的方法:
1) 通過資源名來識別。即在美術(shù)資源(如貼圖、材質(zhì))命名的時候,就將其所屬的游戲狀態(tài)放在文件名中,如某貼圖叫做BG.png,在大廳中使用,則修改為OG_BG.png(OG = OutGame)。這樣在一坨IG(IG=InGame)資源里面,混入了一個OG,可以很容易地識別出來,也方便利用程序來識別。這么做還有一個好處,可以強(qiáng)化美術(shù)對資源生命周期的認(rèn)識,在制作資源,特別是規(guī)劃UI圖集時,可以有一個指導(dǎo)意義。
2) 通過Unity提供的接口Resources.FindObjectsOfTypeAll()進(jìn)行資源的Dump,可以根據(jù)需求Dump貼圖、材質(zhì)、模型或其他資源類型,只需要將Type作為參數(shù)傳入即可。Dump成功之后我們將結(jié)果保存成一份文本文件,這樣可以用Beyond Compare對多次Dump之后的結(jié)果進(jìn)行比較,找到新增的資源,那么這些資源就是潛在的泄漏對象,需要重點追查。
結(jié)合上述的方法與思路,應(yīng)該可以輕松找到泄漏的資源了。
此時我們再回頭看一下Unity Profiler,其實Unity提供了資源索引的查找功能,只不過該功能是以一個樹形結(jié)構(gòu)的文本來展示的(如下圖)。上文曾提到過,Unity內(nèi)部的引用關(guān)系往往是非常復(fù)雜的,可能需要通過十幾甚至幾十層的引用,才能找到最終的引用者,并且引用關(guān)系錯綜復(fù)雜,形成一張龐大的圖,此時光靠展開樹形結(jié)構(gòu)來查找,幾乎是不可能的事了。
防微杜漸,避免內(nèi)存泄漏
介紹完對于Unity內(nèi)存泄漏的追蹤方法,我還想往下多講一步,只要我們在平時開發(fā)的過程多做思考,防微杜漸,內(nèi)存泄漏是完全可以避免的。相對于等泄漏發(fā)生了再回頭來追查,平時多花點時間清理“垃圾”反而是更加高效的做法。
落地到平時的開發(fā)流程中,在這里提出幾點建議,歡迎各位大牛補(bǔ)充:
1) 在架構(gòu)上,多添加析構(gòu)的abstract接口,提醒團(tuán)隊成員,要注意清理自己產(chǎn)生的“垃圾”。
2) 嚴(yán)格控制static的使用,非必要的地方禁止使用static。
3) 強(qiáng)化生命周期的概念,無論是代碼對象還是資源,都有它存在的生命周期,在生命周期結(jié)束后就要被釋放。如果可能,需要在功能設(shè)計文檔中對生命周期加以描述。
相信大家出門旅游,都有看過下圖類似的標(biāo)語,作為一名合格的程序猿,也應(yīng)該能夠處理好代碼中的“垃圾”,不要讓我們的游戲成為一個“垃圾場”。
為了避免以上手游性能方面對游戲的負(fù)面影響,騰訊WeTest平臺下的Cube工具可以幫助開發(fā)者發(fā)現(xiàn)游戲內(nèi)分類資源的一個占用情況,幫助在游戲開發(fā)過程中不斷改善玩家的體驗。目前功能還在免費開放中。點擊http://wetest.qq.com/cube/立即體驗!