1、內存泄漏的定義
Android是基于Java的,眾所周知Java語言的內存管理是其一大特點,不用像C語言那樣處理對象的內存分配到回收的全部過程。在Java中我們只需要簡單地新建對象就可以了,Java垃圾回收器會負責回收釋放對象內存。這么看的話,垃圾回收器會管理內存又怎么還會發生內存泄漏呢?
其實Java中的內存泄漏的定義是:對象不再被程序所使用,但是由于這些對象被引用著導致GC(GarbageCollector)不能回收它們。
下面這張圖可以幫助我們更好地理解對象的狀態,以及內存泄漏的情況
左邊未引用的對象是會被GC回收的,右邊被引用的對象不會被GC回收,但是未使用的對象中除了未引用的對象,還包括已被引用的一部分對象,那么內存泄漏久發生這部分已被引用但未使用的對象。
接下來還有一個疑問:未使用的對象被誰引用會讓GC無法回收呢?
現在主流的程序語言的主流實現中,是通過可達性分析(ReachabilityAnalysis)來判斷對象是否存活的。這個算法的基本思路是:通過一系列的稱為“GCRoots”的對象作為起點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈,當一個對象到GCRoots沒有任何引用鏈時,說明此對象不可用,可以被回收了。
可以作為GCRoots的對象包括下面幾種:
·虛擬機棧中引用的對象,一般是當前在使用中局部變量
·方法區中類靜態屬性引用的對象,就是靜態變量對應的對象
·方法區中常量引用的對象
·本地方法棧中JNI(即一般說的Native方法)引用的對象
MAT分析內存泄漏的時候,也是查看對象到GCRoots的引用鏈,來定位泄漏代碼的位置。
所以未使用的對象直接或間接地被GCRoots引用時會讓GC無法回收,從而產生內存泄漏。
2、Android的內存管理
了解了Java的內存泄漏的起因,接下來大致了解Android中的內存管理機制。
Google在Android的官網上有這樣一篇文章,初步介紹了Android是如何管理應用的進程與內存分配:http://developer.android.com/training/articles/memory.html。Android系統的Dalvik虛擬機扮演了常規的內存垃圾自動回收的角色,Android系統沒有為內存提供交換區,它使用paging與memory-mapping(mmapping)的機制來管理內存,下面簡要概述一些Android系統中重要的內存管理基礎概念。
分配與回收內存
每一個進程的Dalvikheap都反映了使用內存的占用范圍。這就是通常邏輯意義上提到的DalvikHeapSize,它可以隨著需要進行增長,但是增長行為會有一個系統為它設定的上限。
邏輯上講的HeapSize和實際物理意義上使用的內存大小是不對等的,ProportionalSetSize(PSS)記錄了應用程序自身占用以及和其他進程進行共享的內存。
Android系統并不會對Heap中空閑內存區域做碎片整理。系統僅僅會在新的內存分配之前判斷Heap的尾端剩余空間是否足夠,如果空間不夠會觸發gc操作,從而騰出更多空閑的內存空間。在Android的高級系統版本里面針對Heap空間有一個GenerationalHeapMemory的模型,最近分配的對象會存放在YoungGeneration區域,當這個對象在這個區域停留的時間達到一定程度,它會被移動到OldGeneration,最后累積一定時間再移動到PermanentGeneration區域。系統會根據內存中不同的內存數據類型分別執行不同的gc操作。例如,剛分配到YoungGeneration區域的對象通常更容易被銷毀回收,同時在YoungGeneration區域的gc操作速度會比OldGeneration區域的gc操作速度更快。如下圖所示:
每一個Generation的內存區域都有固定的大小,隨著新的對象陸續被分配到此區域,當這些對象總的大小快達到這一級別內存區域的閥值時,會觸發GC的操作,以便騰出空間來存放其他新的對象。如下圖所示:
通常情況下,GC發生的時候,所有的線程都是會被暫停的。執行GC所占用的時間和它發生在哪一個Generation也有關系,Young Generation中的每次GC操作時間是最短的,Old Generation其次,Permanent Generation最長。執行時間的長短也和當前Generation中的對象數量有關,遍歷樹結構查找20000個對象比起遍歷50個對象自然是要慢很多的。
為什么通常情況下,GC發生的時候,所有的線程都會被暫停?
因為每次GC的時候,需要先找到可作為GC Roots的對象,然后以此搜索引用鏈,這個過程需要在一致性的內存快照中進行。這個“一致性”表示在整個過程中不能出現對象引用關系不斷變化的情況,所以需要暫停所有的執行線程。
限制應用的內存
為了整個Android系統的內存控制需要,Android系統為每一個應用程序都設置了一個硬性的Dalvik Heap Size最大限制閾值,這個閾值在不同的設備上會因為RAM大小不同而各有差異。如果你的應用占用內存空間已經接近這個閾值,此時再嘗試分配內存的話,很容易引起OutOfMemoryError的錯誤。
ActivityManager.getMemoryClass()可以用來查詢當前應用的Heap Size閾值,這個方法會返回一個整數,表明你的應用的Heap Size閾值是多少Mb(megabates)。
還有一個用adb命令查詢的方法:
adb shell getprop dalvik.vm.heapgrowthlimit
3、案例
JOOX是IBG一個核心產品,2014年發布以來已經成為5個國家和地區排名第一的音樂App。東南亞是JOOX的主要發行地區,實際上這些地區還是有很多的低端機型,對App的進行內存優化勢在必行。
上面介紹了Android系統內存分配和回收機制,同時也列舉了常見的內存問題,但是當我們接到一個內存優化的任務時,我們應該從何開始?下面是一次內存優化的分享。
1. 首先是解決大部分內存泄露。
不管目前App內存占用怎樣,理論上不需要的東西最好回收,避免浪費用戶內存,減少OOM。實際上自JOOX接入LeakCanary后,每個版本都會做內存泄露檢測,經過幾個版本的迭代,JOOX已經修復了幾十處內存泄露。
2. 通過MAT查看內存占用,優化占用內存較大的地方。
JOOX修復了一系列內存泄露后,內存占用還是居高不下,只能通過MAT查看到底是哪里占用了內存。關于MAT的使用,網上教程無數,簡單推薦兩篇MAT使用教程,MAT - Memory Analyzer Tool 使用進階。
點擊Android Studio這里可以dump當前的內存快照,因為直接通過Android Sutdio dump出來的hprof文件與標準hprof文件有些差異,我們需要手動進行轉換,利用sdk目錄/platform-tools/hprof-conv.exe可以直接進行轉換,用法:hprof-conv 原文件.hprof 新文件.hprof。只需要輸入原文件名還有目標文件名就可以進行轉換,轉換完就可以直接用MAT打開。
下面就是JOOX打開App,手動進行多次gc的hprof文件。
這里我們看的是Dominator Tree(即內存里占用內存最多的對象列表)。
Shallo Heap:對象本身占用內存的大小,不包含其引用的對象內存。
Retained Heap: Retained heap值的計算方式是將retained set中的所有對象大小疊加。或者說,由于X被釋放,導致其它所有被釋放對象(包括被遞歸釋放的)所占的heap大小。
第一眼看去 居然有3個8M的對象,加起來就是24M啊 這到底是什么鬼?
我們通過List objects-》with incoming references查看(這里with incoming references表示查看誰引用了這個對象,with outgoing references表示這個對象引用了誰)
通過這個方式我們看到這三張圖分別是閃屏,App主背景,App抽屜背景。
這里其實有兩個問題:
這幾張圖原圖實際都是1280x720,而在1080p手機上實測這幾張圖都縮放到了1920x1080
閃屏頁面,其實這張圖在閃屏顯示過后應該可以回收,但是因為歷史原因(和JOOX的退出機制有關),這張圖被常駐在后臺,導致無謂的內存占用。
優化方式:我們通過將這三張圖從xhdpi挪動到xxhdpi(當然這里需要看下圖片顯示效果有沒很大的影響),以及在閃屏顯示過后回收閃屏圖片。
優化結果:
從原來的8.29x3=24.87M 到 3.68x2=7.36M 優化了17M(有沒一種萬馬奔騰的感覺。。可能有時費大力氣優化很多代碼也優化不了幾百K,所以很多情況下內存優化時優化圖片還是比較立竿見影的)。
同樣方式我們發現對于一些默認圖,實際要求的顯示要求并不高(圖片相對簡單,同時大部分情況下圖片加載會成功),比如下面這張banner的背景圖:
優化前1.6M左右,優化后700K左右。
同時我們也發現了默認圖片一個其他問題,因為歷史原因,我們使用的圖片加載庫,設置默認圖片的接口是需要一個bitmap,導致我們原來幾乎每個adapter都用BitmapFactory decode了一個bitmap,對同一張默認圖片,不但沒有復用,還保存了多份,不僅會造成內存浪費,而且導致滑動偶爾會卡頓。這里我們也對默認圖片使用全局的bitmap緩存池,App全局只要使用同一張bitmap,都復用了同一份。
另外對于從MAT里看到的圖片,有時候因為看不到在項目里面對應的ID,會比較難確認到底是哪一張圖,這里stackoverflow上有一種方法,直接用原始數據通過GIM還原這張圖片。
這里其實也看到JOOX比較吃虧一個地方,JOOX不少地方都是使用比較復雜的圖片,同時有些地方還需要模糊,動畫這些都是比較耗內存的操作,Material Design出來后,很多App都遵循MD設計進行改版,通常默認背景,默認圖片一般都是純色,不僅App看起來比較明亮輕快,實際上也省了很多的內存,對此,JOOX后面對低端機型做了對應的優化。
3. 我們也對Bugly上的OOM進行了分析,發現其實有些OOM是可以避免的。
下面這個crash就是上面提到的在LsitView的adapter里不停創建bitmap,這個地方是我們的首頁banner位,理論上App一打開就會緩存這張默認背景圖片了,而實際在使用過一段時間后,才因為為了解碼這張背景圖而OOM, 改為用全局緩存解決。
下面這個就是傳說中的內存抖動
實際代碼如下,因為打Log而進行了字符串拼接,一旦這個函數被比較頻繁地調用,那么就很有可能會發生內存抖動。這里我們新版本已經改為使用stringbuilder進行優化。
還有一些比較奇怪的情況,這里是我們掃描歌曲文件頭的時候發生的,有些文件頭居然有幾百M大,導致一次申請了過大的內存,直接OOM,這里暫時也無法修復,直接catch住out of memory error。
4. 同時我們對一些邏輯代碼進行調整,比如我們的App主頁的第三個tab(Live tab)進行了數據延遲加載,和定時回收。
這里因為這個頁面除了有大圖還有輪播banner,實際強引用的圖片會有多張,如果這個時候切到其他頁面進行聽歌等行為,這個頁面一直在后臺緩存,實際是很浪費耗內存的,同時為優化體驗,我們又不能直接通過設置主頁的viewpager的緩存頁數,因為這樣經常都會回收,導致影響體驗,所以我們在頁面不可見后過一段時間,清理掉adapter數據(只是清空adapter里的數據,實際從網絡加載回來的數據還在,這里只是為了去掉界面對圖片的引用),當頁面再次顯示時再用已經加載的數據顯示,即減少了很多情況下圖片的引用,也不影響體驗。
5. 最后我們也遇到一個比較奇葩的問題,在我們的Bugly上報上有這樣一條上報
我們在stackoverflow上看到了相關的討論,大致意思是有些情況下比如息屏,或者一些省電模式下,頻繁地調System.gc()可能會因為內核狀態切換超時的異常。這個問題貌似沒有比較好的解決方法,只能是優化內存,盡量減少手動調用System.gc()
優化結果
我們通過啟動App后,切換到我的音樂界面,停留1分鐘,多次gc后,獲取App內存占用
優化前:
優化后:
多次試驗結果都差不多,這里只截取了其中一次,有28M的優化效果。
當然不同的場景內存占用不同,同時上面試驗結果是通過多次手動觸發gc穩定后的結果。對于使用其他第三方工具不手動gc的情況下,試驗結果可能會差異比較大。
對于上面提到的JOOX里各種圖片背景等問題,我們做了動態的優化,對不同的機型進行優化,對特別低端的機型設置為純色背景等方式,最終優化效果如下:
平均內存降低41M。
本次總結主要還是從圖片方面下手,還有一點邏輯優化,已經基本達到優化目標。
評論