內存泄漏與排查流程——安卓性能優化
前言
內存泄漏可以說是安卓開發中常遇到的問題,追溯和排查其問題根源是進階的程序猿必須具備的一項技能。小盆友今天便與大家分享一下這方面的一些見解,如有理解錯誤或是不同見解,可以於評論區留言我們進行討論,如果喜歡給個贊鼓勵下吧。
篇幅較長,可以通過目錄尋找自己所需了解的吧
目錄
1、JAVA內存解析
2、JAVA回收機制
3、四種引用
4、小結
5、安卓內存泄漏排查工具
6、內存泄漏檢查與解決流程
7、常見的內存泄漏原因
1、JAVA內存解析
要想知道內存泄漏,需要先了解java中運行時內存是怎麼構成的,才能知道是哪個地方導致。話不多說,先上圖
請點擊此處輸入圖片描述
java內存模型
運行時的java內存分為兩大塊:線程私有(藍色區域)、共享數據區(黃色區域)
線程私有:主要用於存儲各個線程私有的一些信息,包括:程序計數器、虛擬機棧、本地方法棧
共享數據區:主要用於存儲公用的一些信息,包括:方法區(內含常量池)、堆
程序計數器:讓程序中各個線程知道自己接下來需要執行哪一行。在java中多線程為搶佔式(因為cpu在某一時刻只會執行一條線程),當線程切換時,需要繼續哪一行便由程序計數器告知。
舉個例子:A、B兩條線程,此時CPU執行從A切換至B,過了段時間從B切換回A,此時A需要從上次暫停的地方繼續執行,此時從哪一行執行就是由程序計數器來提供。
值得一提:
(1)若執行java函數時,程序計數器記錄的是虛擬機位元組碼的地址;
(2)若執行native方法時,程序計數器便置為了null。
(3)在java虛擬機規範中,程序計數器是唯一沒有定義OutOfMemoryError。
虛擬機棧:描述的是java方法的內存模型,平時說的「棧」其實就是虛擬機棧,其生命周期與線程相同。每個方法(不包含native方法)執行的同時都會創建一個棧幀用於存儲局部變數表、操作數棧、動態鏈接、方法出口等信息。
值得一提:在java虛擬機規範中,此處定義了兩個異常
(1)StackOverFlowError (在遞歸中常看到,遞歸層級過深)
(2)OutOfMemoryError
本地方法棧:是為虛擬機使用到的Native方法提供內存空間。有些虛擬機的實現直接把本地方法棧和虛擬機棧合二為一,比如主流的HotSpot虛擬機。
值得一提:在java虛擬機規範中,此處定義了兩個異常
(1)StackOverFlowError (在遞歸中常看到,遞歸層級過深)
(2)OutOfMemoryError
方法區:主要存儲已載入是類信息(由ClassLoader載入)、常量、靜態變數、編譯後的代碼的一些信息。GC在這裡比較少出現在這塊區域。
堆:存放的是幾乎所有的對象實例和數組數據。是虛擬機管理的最大的一塊內存,是GC的主戰場,所以也叫「GC堆」、「垃圾堆」 。
值得一提:在java虛擬機規範中,此處定義了一個異常
(1)OutOfMemoryError
運行時常量池:屬於「方法區」的一部分,用於存放編譯器生成的各種字面量和符號引用。
字面量:與Java語言層面的常量概念相近,包含文本字元串、聲明為final的常量值等。
符號引用:編譯語言層面的概念,包括以下3類:
(1) 類和介面的全限定名
(2)欄位的名稱和描述符
(3)方法的名稱和描述符
2、JAVA回收機制
java中是通過GC(Garbage Collection)來進行回收內存,那jvm是如何確定一個對象能否被回收的呢?這裡就需講到其回收使用的演算法
引用計數演算法
引用計數是垃圾收集器中的早期策略。在這種方法中,堆中每個對象實例都有一個引用計數。當一個對象被創建時,且將該對象實例分配給一個變數,該變數計數設置為1。當任何其它變數被賦值為這個對象的引用時,計數加1(a = b,則b引用的對象實例的計數器+1),當一個對象實例的某個引用超過了生命周期或者被設置為一個新值時,對象實例的引用計數器減1。任何引用計數器為0的對象實例可以被當作垃圾收集。當一個對象實例被垃圾收集時,它引用的任何對象實例的引用計數器減1。
優點:
引用計數收集器可以很快的執行,交織在程序運行中。對程序需要不被長時間打斷的實時環境比較有利。
缺點:
無法檢測出循環引用。如父對象有一個對子對象的引用,子對象反過來引用父對象。這樣,他們的引用計數永遠不可能為0。例如下面代碼片段中,最後的Object實例已經不在我們的代碼可控範圍內,但其引用仍為1,此時內存便產生泄漏。
/**舉個例子**/
Object o1 = new Object() //Object的引用+1,此時計數器為1
Object o2;
o2.o = o1; //Object的引用+1,此時計數器為2
o2 = null;
o1 = null; //Object的引用-1,此時計數器為1
可達性分析演算法
請點擊此處輸入圖片描述
可達性分析演算法
可達性分析演算法是現在java的主流方法,通過一系列的GC ROOT為起始點,從一個GC ROOT開始,尋找對應的引用節點,找到這個節點以後,繼續尋找這個節點的引用節點,當所有的引用節點尋找完畢之後,剩餘的節點則被認為是沒有被引用到的節點,即無用的節點(即圖中的ObjD、ObjE、ObjF)。由此可知,即時引用成環也不會導致泄漏。
java中可作為GC Root的對象有:
1、方法區中靜態屬性引用的對象
2、方法區中常量引用的對象
3、本地方法棧JNI中引用的對象(Native對象)
4、虛擬機棧(本地變數表)中正在運行使用的引用
但是,可達性分析演算法中不可達的對象,也並非一定要被回收。當GC第一次掃過這些對象的時候,他們處於「死緩」的階段。要真正執行死刑,至少需要經過兩次標記過程。如果對象經過可達性分析之後發現沒有與GC Roots相關聯的引用鏈,那他會被第一次標記,並經歷一次篩選,這個對象的finalize方法會被執行。如果對象沒有覆蓋finalize或者已經被執行過了。虛擬機也不會去執行finalize方法。Finalize是對象逃獄的最後一次機會。
3、四種引用
說到底,內存泄漏是因為引用的處理不正當導致的。所以,我們接下來需要老生常談一下java中四種引用,即:強軟弱虛(引用強度依次減弱)。
(1)強引用(Strong reference):一般我們使用的都是強引用,例如:Object o = new Object();只要強引用還在,垃圾收集器就不會回收被引用的對象。
(2)軟引用(Soft Reference):用來定義一些還有用但並非必須的對象。對於軟引用關聯著的對象,在系統將要內存溢出之前,會將這些對象列入回收範圍進行第二次回收,如果回收後還是內存不足,才會拋出內存溢出。(即在內存緊張時,會對其軟引用回收)
(3)弱引用(Weak Reference):用來描述非必須對象。被弱引用關聯的對象只能生存到下一次垃圾收集發生之前。當垃圾收集器回收時,無論內存是否足夠,都會回收掉被弱引用關聯的對象。(即GC掃過時,便將弱引用帶走)
(4)虛引用(Phantom Reference):也稱為幽靈引用或者幻影引用,是最弱的引用關係。一個對象的虛引用根本不影響其生存時間,也不能通過虛引用獲得一個對象實例。虛引用的唯一作用就是這個對象被GC時可以收到一條系統通知。
軟引用與弱引用的抉擇
如果只是想避免OutOfMemory異常的發生,則可以使用軟引用。如果對於應用的性能更在意,想儘快回收一些佔用內存比較大的對象,則可以使用弱引用。另外可以根據對象是否經常使用來判斷選擇軟引用還是弱引用。如果該對象可能會經常使用的,就盡量用軟引用。如果該對象不被使用的可能性更大些,就可以用弱引用。
4、小結
至此,我們知道內存泄漏是因為堆內存中的長生命周期的對象持有短生命周期對象的引用,儘管短生命周期對象已經不再需要,但是因為長生命周期對象持有它的引用而導致不能被回收。
5、安卓內存泄漏排查工具
所謂工欲善其事必先利其器,這一小節先簡述下所需借用到的內存泄漏排查工具,如果已經熟悉的話可以跳過。
(1)Android Profiler
這一工具是Android Studio自帶,可以查看cpu、內存使用、網路使用情況,Android Studio3.0中用於替代Android Monitor
請點擊此處輸入圖片描述
Android Profiler功能簡介
① 強制執行垃圾收集事件的按鈕。
② 捕獲堆轉儲的按鈕。
③ 記錄內存分配的按鈕。
④ 放大時間線的按鈕。
⑤ 跳轉到實時內存數據的按鈕。
⑥ 事件時間線顯示活動狀態、用戶輸入事件和屏幕旋轉事件。
⑦ 內存使用時間表,其中包括以下內容:
● 每個內存類別使用多少內存的堆棧圖,如左邊的y軸和頂部的顏色鍵所示。
● 虛線表示已分配對象的數量,如右側y軸所示。
● 每個垃圾收集事件的圖標。
(2)MAT(Memory Analyzer Tool)
MAT用於鎖定哪裡泄漏。因為從Android Profiler中,知道了泄漏,但比較難鎖定具體哪個地方導致了泄漏,所以藉助MAT來鎖定,具體使用待會會藉助一個例子配合Android Profiler來介紹,稍安勿躁。
下載地址:http://www.eclipse.org/mat/downloads.php
6、內存泄漏檢查與解決流程
經過前面的一段理論,可能很多小夥伴都有些不耐煩了,現在便來真正的操作。
溫馨提示:理論是進階中必要的支持,否則只是知其然而不知其所以然。
(1)第一步:對待檢測功能掃雷式操作
當我們需要檢查一塊模塊,或是整個app哪個地方有內存泄漏時,有時會比較茫然,有些大海撈針的感覺,畢竟泄漏不是每個頁面都會有,而且有時是一個功能才會導致泄漏,所以我們可以採取「掃雷式操作」,也就是在需要檢查的頁面和功能中隨便先使用一番,舉個例子:假設檢查MainActivity泄漏情況,可以登錄進入後,此時來到了MainActivity,後又登出,再次登錄進入MainActivity。
(2)第二步:藉助 Android Profiler獲得內存快照
使用Android Profiler的GC功能,強制進行垃圾回收,再dump下內存("Android Profiler功能簡介"圖的②按鈕)。然後等待一段時間,會出現圖中紅色框部分:
請點擊此處輸入圖片描述
在這裡得到的頁面,其實比較難直觀獲得內存分析的數據,最多只是選擇「Arrange by package」按照包進行排序,然後進到自己的包下,查看應用內的activity的引用數是否正常,來判斷其是否有正常回收
請點擊此處輸入圖片描述
圖中列的說明
Alloc Cout : 對象數
Shallow Size : 對象佔用內存大小
Retained Set : 對象引用組佔用內存大小(包含了這個對象引用的其他對象)
(3)第三步:藉助Android Studio分析
至此,我們還是沒得到直觀的內存分析數據,我們需要藉助更專業的工具。我們現將通過下圖中紅框內的按鈕,將剛才的內存快照保存為hprof文件。
請點擊此處輸入圖片描述
將保存好的hprof文件拖進AS中,勾選「Detect Leaked Activities」,然後點擊綠色按鈕進行分析。
請點擊此處輸入圖片描述
如果有內存泄漏的話,會出現如下圖的情況。圖中很清晰的可以看到,這裡出現了MainActivity的泄漏。並且觀察到這個MainActivity可能不止一個對象存在,可能是我們上次退出程序的時候發生了泄漏,導致它不能回收。而在此打開app,系統會創建新的MainActivity。但至此我們只是知道MainActivity泄漏了,不知具體是哪裡導致了MainActivity泄漏,所以需要藉助MAT來進一步分析。
請點擊此處輸入圖片描述
(4)第四步:hprof文件轉換
在使用MAT打開hprof文件前先要對剛才保存的hprof文件進行轉換。通過終端,藉助轉換工具hprof-conv(在sdk/platform-tools/hprof-conv),使用命令行:
hprof-conv -z src dst
-z:排除不是app的內存,比如Zygote
src:需要進行轉換的hprof的文件路徑
dst:轉換後的文件路徑(文件後綴還是.hprof)
(5)第五步:通過MAT進行具體分析
在MAT中打開轉換了的hprof文件,如下圖
請點擊此處輸入圖片描述
打開後會看到如下圖
請點擊此處輸入圖片描述
我們需要進入到"Histogram"來分析,點擊下圖中的按鈕
請點擊此處輸入圖片描述
打開"Histogram"後,會看到下圖,在紅框中輸入在AS中觀察到的泄漏的類,例如上面得知的MainActivity
請點擊此處輸入圖片描述
然後將搜索得到的結果進行合併,排除「軟」、「弱」、「虛」引用對象,右鍵點擊搜索到的結果,選擇如下圖的選項
請點擊此處輸入圖片描述
得到合併結果如下
請點擊此處輸入圖片描述
從分析結果可知,MainActivity是因為com.netease.nimlib.g.e中的一個hashMap持有導致,這裡的e類是第三方庫的類,顯然已被混淆,造成泄漏無非兩種可能,一種是第三方庫的bug,一種是自己使用不當,例如忘記解綁操作等。具體的打斷這個持有需要按照自己的代碼進行分析,實例中的問題是因為使用第三方庫註冊後,在退出頁面沒有進行註銷導致的。
當我們解決完後,可以再次進行一輪內存快照,直到沒有內存泄漏,過程會比較枯燥,但一點點的解決泄漏最終會給app一個質的飛躍。
7、常見的內存泄漏原因
(1)集合類
集合類如果僅僅有添加元素的方法,而沒有相應的刪除機制,導致內存被佔用。如果這個集合類是全局性的變數 (比如類中的靜態屬性,全局性的 map 等即有靜態引用或 final 一直指向它),那麼沒有相應的刪除機制,很可能導致集合所佔用的內存只增不減。
(2)單例模式
不正確使用單例模式是引起內存泄露的一個常見問題,單例對象在被初始化後將在 JVM 的整個生命周期中存在(以靜態變數的方式),如果單例對象持有外部對象的引用,那麼這個外部對象將不能被 JVM 正常回收,導致內存泄露。
public class SingleTest{
private static SingleTest instance;
private Context context;
private SingleTest(Context context){
this.context = context;
}
public static SingleTest getInstance(Context context){
if(instance != null){
instance = new SingleTest(context);
}
return instance;
}
}
這裡如果傳遞Activity作為Context來獲得單例對象,那麼單例持有Activity的引用,導致Activity不能被釋放。
不要直接對 Activity 進行直接引用作為成員變數,如果允許可以使用Application。如果不得不需要Activity作為Context,可以使用弱引用WeakReference,相同的,對於Service 等其他有自己生命周期的對象來說,直接引用都需要謹慎考慮是否會存在內存泄露的可能。
(3)未關閉或釋放資源
BroadcastReceiver,ContentObserver,FileObserver,Cursor,Callback等在 Activity onDestroy 或者某類生命周期結束之後一定要 unregister 或者 close 掉,否則這個 Activity 類會被 system 強引用,不會被內存回收。值得注意的是,關閉的語句必須在finally中進行關閉,否則有可能因為異常未關閉資源,致使activity泄漏
(4)Handler
只要 Handler 發送的 Message 尚未被處理,則該 Message 及發送它的 Handler 對象將被線程 MessageQueue 一直持有。特別是handler執行延遲任務。所以,Handler 的使用要尤為小心,否則將很容易導致內存泄露的發生。
請點擊此處輸入圖片描述
這種創建Handler的方式會造成內存泄漏,由於mHandler是Handler的非靜態匿名內部類的實例,所以它持有外部類Activity的引用,我們知道消息隊列是在一個Looper線程中不斷輪詢處理消息,那麼當這個Activity退出時消息隊列中還有未處理的消息或者正在處理消息,而消息隊列中的Message持有mHandler實例的引用,mHandler又持有Activity的引用,所以導致該Activity的內存資源無法及時回收,引發內存泄漏,所以另外一種做法為:
請點擊此處輸入圖片描述
創建一個靜態Handler內部類,然後對Handler持有的對象使用弱引用,這樣在回收時也可以回收Handler持有的對象,這樣雖然避免了Activity泄漏,不過Looper線程的消息隊列中還是可能會有待處理的消息,所以我們在Activity的Destroy時或者Stop時應該移除消息隊列中的消息,
請點擊此處輸入圖片描述
使用mHandler.removeCallbacksAndMessages(null);是移除消息隊列中所有消息和所有的Runnable。當然也可以使用mHandler.removeCallbacks();或mHandler.removeMessages();來移除指定的Runnable和Message。
(5)Thread
和handler一樣,線程也是造成內存泄露的一個重要的源頭。線程產生內存泄露的主要原因在於線程生命周期的不可控。比如線程是 Activity 的內部類,則線程對象中保存了 Activity 的一個引用,當線程的 run 函數耗時較長沒有結束時,線程對象是不會被銷毀的,因此它所引用的老的 Activity 也不會被銷毀,因此就出現了內存泄露的問題。
(6)系統bug
比如InputMethodManager,會持有activity而沒釋放,導致泄漏,需要通過反射進行打斷。
更多關於測試方面的文章,請前往51Testing軟體測試網-中國軟體測試人的精神家園學習哦~
推薦閱讀: