作者: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)存泄漏及其危害
相信各位程序猿們或多或少都會聽到過內(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í)生活中干跛,銀行為了避免無錢可接子姜,就會把總是借錢不還的人拉入黑名單,不再借他錢楼入;而操作系統(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)存分配需要更高的時(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)存托管了,為什么還會存在內(nèi)存泄漏呢盗蟆?因?yàn)镚C本身并不是萬能的戈二,GC能做的是通過一定的算法找到“垃圾”,并且自動將“垃圾”占用的內(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)存就會被回收。
基于以上的知識宛徊,我們很容易就可以想到為什么在托管內(nèi)存的環(huán)境下佛嬉,還是會出現(xiàn)內(nèi)存泄漏了。這就像現(xiàn)實(shí)生活中的宅男宅女岩调,吃了泡面總是忘記把盒子扔到門外的垃圾箱里巷燥;從計(jì)算機(jī)的角度來說赡盘,則是号枕,在某對象超出其作用域時(shí),我們 “忘記”清除對該無用對象的引用了陨享。
說到這葱淳,有的同學(xué)可能會有疑問:我每次在代碼中申請的內(nèi)存都非常小,少則幾B抛姑,多則幾十K赞厕,現(xiàn)在設(shè)備的內(nèi)存都比較大(幾百M(fèi)還是有的吧),即使泄漏會產(chǎn)生什么大影響么定硝?
首先皿桑,水滴石穿的典故相信大家都知道,實(shí)際代碼中,并非只有顯示調(diào)用new才會分配內(nèi)存诲侮,很多隱式的分配是不容易被發(fā)現(xiàn)的镀虐,例如產(chǎn)生一個(gè)List來存儲數(shù)據(jù),緩存了服務(wù)器下發(fā)的一份配置沟绪,產(chǎn)生一個(gè)字符串等等刮便,這些操作都會產(chǎn)生內(nèi)存的分配。你分配幾十K绽慈,他分配幾十K恨旱,一會兒內(nèi)存就沒了。
其次坝疼,有一點(diǎn)需要說明的是搜贤,在Unity環(huán)境下,Mono堆內(nèi)存的占用钝凶,是只會增加不會減少的入客。具體來說,可以將Mono堆腿椎,理解為一個(gè)內(nèi)存池桌硫,每次Mono內(nèi)存的申請,都會在池內(nèi)進(jìn)行分配啃炸;釋放的時(shí)候铆隘,也是歸還給池,而不會歸還給操作系統(tǒng)南用。如果某次分配膀钠,發(fā)現(xiàn)池內(nèi)內(nèi)存不夠了,則會對池進(jìn)行擴(kuò)建——向操作系統(tǒng)申請更多的內(nèi)存擴(kuò)大池以滿足該次的內(nèi)存分配裹虫。需要注意的是肿嘲,每次對池的擴(kuò)建,都是一次較大的內(nèi)存分配筑公,每次擴(kuò)建雳窟,都會將池?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)存雌澄,將會通過Unity分配在Native堆斋泄;通過System命名空間中的接口分配的內(nèi)存,將會通過Mono Runtime分配在Mono堆镐牺。
了解了分配與管理方式的區(qū)別炫掐,我們再來看看回收的方式。如上文所說睬涧,Mono內(nèi)存是通過GC來回收的募胃,而Unity也提供了一種類似的方式來回收內(nèi)存。不同的是畦浓,Unity的內(nèi)存回收是需要主動觸發(fā)的痹束。就好比說,我們把垃圾扔在門口的垃圾桶里讶请,GC是每天來看一次祷嘶,有垃圾就收走;而Unity則需要你打個(gè)電話給它秽梅,通知它有垃圾要回收抹蚀,它才會來。主動調(diào)用的接口是Resources.UnloadUnusedAssets()企垦。其實(shí)GC也提供了同樣的接口GC.Collect()
用來主動觸發(fā)垃圾回收,這兩個(gè)接口都需要很大的計(jì)算量晒来,我們不建議在游戲運(yùn)行時(shí)時(shí)不時(shí)主動調(diào)用一番钞诡,一般來說,為了避免游戲卡頓,建議在加載環(huán)節(jié)來處理垃圾回收的操作荧降。有一點(diǎn)需要說明的是接箫,Resources.UnloadUnusedAssets()內(nèi)部本身就會調(diào)用GC.Collect()。Unity還提供了另外一個(gè)更加暴力的方式——Resources.UnloadAsset()來卸載資源朵诫,但是這個(gè)接口無論資源是不是“垃圾”辛友,都會直接刪除,是一個(gè)很危險(xiǎn)的接口剪返,建議確定資源不使用的情況下废累,再調(diào)用該接口。
基于上述基礎(chǔ)知識脱盲,我們再來看一下為什么會有資源的泄漏邑滨。首先和代碼側(cè)的泄漏一樣,由于“存在該釋放卻沒有釋放的錯(cuò)誤引用”钱反,導(dǎo)致回收機(jī)制認(rèn)為目標(biāo)對象不是“垃圾”掖看,以至于不能被回收,這也是最常見的一種情況面哥。
針對資源哎壳,還有一種典型的泄漏情況。由于資源卸載是主動觸發(fā)的尚卫,那么清除對資源引用的時(shí)機(jī)就顯得尤為重要《停現(xiàn)在游戲的邏輯趨于復(fù)雜化,同時(shí)如果有新成員加入項(xiàng)目組焕毫,也未必能夠清楚地了解所有資源管理的細(xì)節(jié)蹲坷,如果“在觸發(fā)了資源卸載之后,才清除對資源引用”邑飒,同樣也會出現(xiàn)內(nèi)存泄漏了循签。
還有一種資源上的泄漏,是因?yàn)閁nity的一些接口在調(diào)用時(shí)會產(chǎn)生一份拷貝(例如Renderer.Material參考https://docs.unity3d.com/ScriptReference/Renderer-material.html)疙咸,如果在使用上不注意的話县匠,運(yùn)行時(shí)會產(chǎn)生較多的資源拷貝,造成內(nèi)存的無端浪費(fèi)撒轮。但是此類內(nèi)存拷貝一般量較少乞旦,修復(fù)起來也比較簡單,這里不做大篇幅的介紹题山。
修復(fù)內(nèi)存泄漏
根據(jù)上文描述兰粉,我們知道只要在回收到來之前,將引用解開就可以避免內(nèi)存泄漏了顶瞳,似乎是個(gè)很簡單的問題玖姑。但是由于實(shí)際項(xiàng)目的邏輯復(fù)雜度往往超出想象愕秫,引用關(guān)系也不是簡單的一層兩層(有時(shí)候往往會多達(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)。一般會在游戲進(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平臺上針對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ù)會有成千上萬條(上文所說州邢,代碼中的內(nèi)存分配儡陨,是非常細(xì)碎,并且數(shù)量極多的量淌,在這里得到了驗(yàn)證)骗村,并且其中有很多堆棧是重復(fù)的,因?yàn)槊恳淮蔚膬?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)存的分配做一個(gè)模塊的劃分,快速定位到相關(guān)的模塊未玻。
2) 可以清晰地看到每一層函數(shù)的分配總量(如A函數(shù)總共分配4096+20+4096B)灾而,可以根據(jù)占用內(nèi)存的多少決定修復(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ù)資源(如貼圖江咳、材質(zhì))命名的時(shí)候,就將其所屬的游戲狀態(tài)放在文件名中哥放,如某貼圖叫做BG.png歼指,在大廳中使用,則修改為OG_BG.png(OG = OutGame)甥雕。這樣在一坨IG(IG=InGame)資源里面踩身,混入了一個(gè)OG,可以很容易地識別出來社露,也方便利用程序來識別挟阻。這么做還有一個(gè)好處,可以強(qiáng)化美術(shù)對資源生命周期的認(rèn)識峭弟,在制作資源附鸽,特別是規(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平臺下的Cube工具可以幫助開發(fā)者發(fā)現(xiàn)游戲內(nèi)分類資源的一個(gè)占用情況,幫助在游戲開發(fā)過程中不斷改善玩家的體驗(yàn)亏娜。目前功能還在免費(fèi)開放中焕窝。點(diǎn)擊http://wetest.qq.com/cube/立即體驗(yàn)!