1. 程式人生 > >Android記憶體洩漏檢測與MAT使用

Android記憶體洩漏檢測與MAT使用

公司相關專案需要進行記憶體優化,所以整理了一些分析記憶體洩漏的知識以及工作分析過程。
本文中不會刻意的編寫一個記憶體洩漏的程式,然後利用工具去分析它。而是通過介紹相關概念,來分析如何尋找記憶體洩漏,並附上自己的專案實戰過程。
撰寫過程中,本人深感JVM、作業系統相關知識瞭解不夠深刻,不足之處非常歡迎指正說明。

記憶體洩漏基本概念

記憶體檢測這部分,相關的知識有JVM虛擬機器垃圾收集機制,類載入機制,記憶體模型,以及作業系統的基礎知識(所以不要說JVM有啥用,作業系統有啥用啦 :) )。
編寫沒有記憶體洩漏的程式,對提高程式穩定性,提高使用者體驗具有重要的意義;同時,也是java程式設計師進階的重要內容。利用java編寫程式的時候,要特別注意記憶體洩漏相關的問題。雖然JVM提供了自動垃圾回收機制,但是還是有很多情況會導致記憶體洩漏。
記憶體洩漏主要原因就是一個生命週期長的物件,持有了一個生命週期短的物件的引用。這樣,會導致短的物件在該回收時候無法被回收。Android中比較典型的有:
1、靜態變數持有Activity的context。
2、或者Handler持有某個元件的context,同時如果Looper的訊息佇列中有針對該Handler的訊息沒有被處理,那麼會被作為target持有強引用,最終的導致context無法釋放,導致相應元件在退出時無法被記憶體回收。
3、非靜態內部類預設持有外部類的引用。有時候為了方便,我們會在Activity中定義一個Thread內部類,同時直接通過new Thread的方式去執行執行緒,那麼線上程執行結束之前,執行緒都會持有Activity的引用,從而導致Activity無法被釋放。

記憶體檢測工具

LeakCananry

使用步驟

LeakCanary,主要監測的是使用過程中Activity,Fragment等元件是否沒被記憶體回收。使用方法也十分簡單,相當於裝了一個監聽器,然後通過正常 操作去尋找記憶體洩漏,發生記憶體洩漏的時候會有Toast,同時可以在相應程式檢視哪裡發生記憶體洩漏。
方法比較簡單,具體步驟可以查閱官方github。新增leakcanary依賴以後,新建一個Application入口,在Oncreate方法中安裝Leakcanary即可。
這裡寫圖片描述
當發生記憶體洩漏時,螢幕會出現Toast,同時開啟桌面上的Leaks程式,顯示洩漏的記憶體,如下圖:
這裡寫圖片描述

整體流程

LeakCananry實現步驟大致是:
實現大致步驟是:
1、自動把activity加入到KeyedWeakReference
2、在background執行緒中,檢查onDestroy後reference是否被清除,且沒有觸發gc
3、如果reference沒有被清除,則dump heap到一個hprof檔案並儲存到app檔案系統中
4、在一個單獨程序中啟動HeapAnalyzerService,HeapAnalyzer使用HAHA來分析heap dump。
5、HeapAnalyzer在heap dump中根據reference key找到KeyedWeakReference。
6、HeapAnalyzer計算出到GC Roots的最短強引用路徑來判斷是否存在洩露,然後build出造成這個洩露的引用鏈。
7、結果被傳回來app程序的DisplayLeakService,並展示一個洩露的notification。

結論

方法的優點是簡單易行,但是隻能檢測Activity、Fragment是否發生記憶體洩漏。 對於一些專案比如sdk開發,很可能整個程式沒有一個Activity,所以這種方式就不是很實用。

觀看整體記憶體使用情況

dumpsys meminfo <包名> [-引數]

可以檢視應用不同部分記憶體分配情況。比如Java heap,Native heap等
輸出是目前具體應用的記憶體分配,單位是kilobytes
因為程式涉及jni,經常會分配本地記憶體,所以會使用adb shell 的方式去檢視native heap的分配情況。

結果如下:

這裡寫圖片描述

分析各個引數:
Private Clean/Dirty RAM:
這部分記憶體是app的私有記憶體,當app銷燬是作業系統可以回收到全部的記憶體。其中private dirty只能被你的程序使用,同時只能存在在記憶體當中,當記憶體不夠,也不能通過分頁技術儲存到硬碟(作業系統相關知識),dalvik和native heap上的分配都是private dirty RAM。 Dirty RAM是記憶體中被修改過的頁面,而Clean RAM是從持久檔案(比如程式碼執行檔案)映射出的記憶體。

PSS Total:
我們知道,程序之間彼此通訊底層通過Binder Driver,通過操控一塊共享記憶體進行讀寫來相互通訊。這樣一來,為了程序間通訊,Binder會為每個程序在共享記憶體中開闢一塊空間。
PSS的部分,包含了每個程序的共享記憶體。例如,一個記憶體頁面被兩個程序共享,那麼頁面大小的一半會被加到兩個程序各自的PSS中。
通過累加全部程序的PSS,我們可以檢視整個系統的記憶體使用情況。事實上,PSS是衡量 (實際)使用記憶體的重要標準。

Dalvik Heap:
該欄位衡量的是Dalvik虛擬機器上堆分配情況,也就是我們在Java中使用new生命物件分配的記憶體。
列中PSS Total包括了和其它Zygote程序共享的記憶體(全部app程序都是從Zygote中fork出來的,都有一部分記憶體共享)。而Private Dirty則是app程序本身所使用的的記憶體。

.so mmap / .dex mmap
這部分主要指的是原生代碼(so)和Davlik 虛擬機器程式碼(dex)的程式碼大小。PSS Total列中指的是包含android平臺的程式碼,而private clean僅僅是程式本身執行的程式碼。

上面引數很多,理解相關知識需要掌握作業系統記憶體部分。我們在測試的使用,一般情況下,我們關注private Dirty或者pss Total就可以檢視app記憶體整體趨勢。

DDMS

使用流程

  1. 啟動eclipse後,切換到DDMS透檢視,並確認Devices檢視、Heap檢視都是開啟的;
  2. 將手機通過USB連結至電腦,連結時需要確認手機是處於“USB除錯”模式,而不是作為“MassStorage”;
  3. 連結成功後,在DDMS的Devices檢視中將會顯示手機裝置的序列號,以及裝置中正在執行的部分程序資訊;
  4. 點選選中想要監測的程序,比如system_process程序;
  5. 點選選中Devices檢視介面中最上方一排圖示中的“Update Heap”圖示;
  6. 點選Heap檢視中的“Cause GC”按鈕;
  7. 此時在Heap檢視中就會看到當前選中的程序的記憶體使用量的詳細情況。

如何檢測記憶體洩漏?

Heap檢視中部有一個Type叫做dataobject,即資料物件,也就是我們的程式中例項化的物件。在data object一行中有一列是“Total Size”,其值就是當前程序中所有Java資料物件的記憶體總量,一般情況下,這個值的大小決定了是否會有記憶體洩漏。
正常情況下Total Size值都會穩定在一個有限的範圍內,也就是說沒有造成物件不被垃圾回收的情況,所以說雖然我們不斷的操作會不斷的生成很多物件,而在虛擬機器不斷的進行GC的過程中,這些物件都被回收了,記憶體佔用量會會落到一個穩定的水平。如果程式碼中存在沒有釋放物件引用的情況,則dataobject的Total Size值在每次GC後不會有明顯的回落,隨著操作次數的增多Total Size的值會越來越大

通過DDMS方式,DataObject 的totalSize如果穩定在一個大概範圍內,則可以確定沒有發生記憶體洩漏。

MAT

然而,並不是所有的記憶體洩漏都十分明顯,並且會最終導致OOM。有時候只有幾個物件被洩漏,雖然影響不大,但是無疑浪費了記憶體。
要發現這種比較隱蔽的記憶體洩漏,我們需要使用MAT工具。
在瞭解MAT具體使用之前,要先了解一些相關概念。

支配樹

支配樹體現了物件例項間的支配關係,在物件引用圖中,所有指向物件B的路徑都經過物件A,則認為物件A支配物件B。
這裡寫圖片描述
在這張圖裡,左邊是物件引用關係,對於A和B,要抵達這兩個點必須經過GC root。而對於C可以從A也可以從B抵達,但都必須經過GC root,所以最近的支配點同樣也是GC root。
對於點D,不管是從C->D還是C->D->F->D,都必須經過的最近的點是C,所以C是D的支配點。同理可得EFHG在支配樹中的位置。

SHALLOWHEAP和RETAINED HEAP

Shallow heap表示物件本身所佔記憶體大小,一個記憶體大小100bytes的物件Shallow heap就是100bytes。
Retained heap表示通過回收這一個物件總共能回收的記憶體,比方說一個100bytes的物件還直接或者間接地持有了另外3個100bytes的物件引用,回收這個物件的時候如果另外3個物件沒有其他引用也能被回收掉的時候,Retained heap就是400bytes。
在使用mat進行分析時,我們常常接觸到的資料就是shallow size和retained size:
Shallow Size
物件自身佔用的記憶體大小,不包括它引用的物件。
針對非陣列型別的物件,它的大小就是物件與它所有的成員變數大小的總和。當然這裡面還會包括一些java語言特性的資料儲存單元。
針對陣列型別的物件,它的大小是陣列元素物件的大小總和。
Retained Size
Retained Size=當前物件大小+當前物件可直接或間接引用到的物件的大小總和。(間接引用的含義:A->B->C, C就是間接引用)
換句話說,Retained Size就是當前物件被GC後,從Heap上總共能釋放掉的記憶體。
不過,釋放的時候還要排除被GC Roots直接或間接引用的物件。他們暫時不會被回收。如下圖:
這裡寫圖片描述

A物件的Retained Size=A物件的Shallow Size
B物件的Retained Size=B物件的Shallow Size + C物件的Shallow Size
因為B物件被釋放時,C同時被釋放,而D由於被GC roots直接引用所以不會被釋放。而Retained Size就是當前物件被GC後,從Heap上總共能釋放掉的記憶體。

以上概念,都是在使用MAT進行記憶體分析經常使用的。
我們在分析記憶體洩漏的時候,著重會檢視retained heap,也就是這個物件沒有被釋放前,retained heap中的相關記憶體不會被釋放。
然後,在分析某個物件為何沒被釋放的時候,會檢視引用關係或者支撐樹。因為引用樹父子關係可能比較雜亂,而支撐樹更加清晰。

在使用MAT分析記憶體洩漏的過程中,主要流程就是:
1、分析retained heap,找一個使很多物件無法被釋放的記憶體。
2、正常情況下,該釋放這個物件,所以通過支撐樹,或者檢視GC 路徑,分析為什麼這個物件沒有被釋放。

MAT的下載與使用

下載地址:https://eclipse.org/mat/downloads.php
這裡沒有作為eclipse外掛的方式下載mat,而是通過下載單獨的軟體客戶端。
首先,在DDMS中選擇要檢測的程序並dump HPROF file,如下圖:
這裡寫圖片描述

HPROF中儲存的是當前記憶體的快照,因此,在dump快照之前先點選cause GC手動觸發一次垃圾回收,這樣可以避免軟引用、弱引用等不必要的物件保留在記憶體中影響我們的分析。

轉儲出來的hprof檔案,還有使用sdk自帶工具進行一下格式轉化,工具在sdk路徑下的platform-tools下,名稱為hprof-conv。

使用方法:
/.hprof-conv.exe a.hprof b.hprof
a 是輸入hprof檔名,b是輸出檔名。
然後將b.hprof在eclipse memory Analyzer中開啟,注意要轉換格式,不然無法成功開啟。
如下:

這裡寫圖片描述

利用MAT分析記憶體洩漏

分析過程中,主要使用的是Histogram直方圖,和Dominater tree支配樹。

在Histogram檢視中查詢retained heap值最大的項,並分析這裡是否發生記憶體洩漏。

這裡寫圖片描述

上圖中一坨一坨的,其實就是Class的名稱。這樣分類比較清晰,後面會說到如何檢視Class宣告的物件。
在最上面class Name下有輸入過濾的地方,需要注意是,如果要檢視com包下的類,那麼要輸入com. ,這裡的正則中’*’貌似不會去匹配’.’,所以就要我們自己輸入啦。 一般情況下,我們忽略會java、android系統自帶的類,而著重分析我們自己程式中編寫的物件記憶體使用情況。

Retained heap表示因為這個物件,會導致多少物件無法回收。

右擊相應類,list objects->with incoming references。表明引用這個類的某個例項的其它類,也就是它在引用樹中的父節點。通過分析該物件被誰引用,來判斷為何沒被垃圾回收。
outcoming reference就是子節點,檢視一些當前物件引用著的物件。

此外看,Merge shortest path to gc root,可以找到一條到GC root的最短路徑,來看為什麼當前物件無法被回收。

實戰分析

下面記錄了本人對一個專案的具體分析過程,以及各個工具的使用方法。

1、使用DDMS檢視記憶體

使用DDMS的過程中,針對應用分別進行了多次檢測,主要檢視程式執行前的記憶體使用情況和程式執行後的記憶體使用情況:
使用前:

這裡寫圖片描述

使用後:
這裡寫圖片描述

通過上述資料可以看到,在程式執行前data object也就是在堆上分配的資料是180KB左右,而執行後記憶體大概在300KB上下浮動,沒有呈現一個明顯的一直上升的情況,故而沒有明顯的記憶體洩漏,基本沒有導致OOM的可能。

但是,可以發現,程式執行一次以後,放置一段時間,即便手動觸發GC,堆上的記憶體雖然回落,但是仍然是288KB,與執行前的180KB相差較大,說明有一些物件被GC roots引用,無法完成釋放。

下面採用MAT工具進行進一步分析。在上面的過程中,轉出了三個hprof檔案,將hprof檔案利用Android sdk tools下的工具進行格式轉換,進行對比分析:

這裡寫圖片描述

2、使用MAT分析記憶體轉儲

前面分析記憶體使用發現,使用前和使用後有一個100KB左右的差值,同時即便放置一段時間仍然無法使用。將before和after的直方圖加入對比欄,在MAT中進行對比:
這裡寫圖片描述

點選右上角的紅色歎號:

這裡寫圖片描述

這裡寫圖片描述

對比發現兩個shallow heap大小基本相同,多出的部分是UpdatePartResultThread,系統類而不是我們自己編寫程式造成的。
再看一下使用前後直方圖中的retained heap:

這裡寫圖片描述

可以看出,程式執行後,newActivity強引用了一些物件,在newAcitivity沒有推出前,retainedheap部分記憶體無法被回收。這也就是我們在DDMS中發現堆記憶體差異的主要原因。
右擊直方圖中的NewActivity,可以看見如下選項:

這裡寫圖片描述

用的比較多的是List objects和Merger shortest Paths to GC Roots。
List objects:
Outgoing reference是支配樹中當前物件的子節點,也就是當前物件持有哪些引用。
Incoming reference是父節點,即當前物件被誰引用,為什麼沒被回收。

Merger shortest Paths to GC Roots:找到當前無法被釋放的物件到GC roots的最短路徑。即排查當前物件被誰引用,為什麼沒有被釋放。這裡因為我們的物件是一個Activity,當它顯示在前臺的時候,不會被垃圾回收,所以不是我們分析的點。

在這裡,我們檢視outgoing reference,檢視當前物件擁有哪些強引用:

這裡寫圖片描述

排除系統的物件,還是主要分析我們編寫的程式。

這裡寫圖片描述

最後發現,我們在之前使用LeakCanary時,註冊的相應監聽器沒有回收,發現了記憶體洩漏 :)。

去掉LeakCanary,再次測試發現data object的值確實下降了不少。

繼續分析,發現newActivity引用了一個

這裡寫圖片描述

致使一部分記憶體無法被釋放。這個問題屬於客戶端實現問題,不在記憶體洩漏的範圍內。
接下來,在直方圖中過濾出服務端的類:
這裡寫圖片描述
可以看到,服務端的類大部分shallow heap都為0,也就是已經被垃圾回收。

結論

在使用MAT分析記憶體時,最關鍵的就是找引用關係。如果一個應該被釋放的物件沒有被釋放,那麼我們往往要檢視它的incoming reference,看看是誰持有了它的強引用。同時利用Merger shortest GC roots找到到GC root的最短路徑,確定是由於被誰引用而導致無法GC。