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