Android Studio和 MAT 內存泄漏分析

和你一起終身學習,這裏是程序員 Android

經典好文推薦,通過閱讀本文,您將收穫以下知識點:

一、Java內存分配策略
二、堆與棧的區別
三、Java管理內存的機制
四、Java中的內存泄漏
五、Android中常見的內存泄漏
六、Android中內存泄漏的排查與分析
七、總結

一、Java內存分配策略

Java 程序運行時的內存分配策略有三種:靜態分配、棧式分配和堆式分配。對應的存儲區域如下:

  • 靜態存儲區(方法區):主要存放靜態數據、全局 static 數據和常量。這塊內存在程序編譯時就已經分配好,並且在程序整個運行期間都存在。

  • 棧區 :方法體內的局部變量都在棧上創建,並在方法執行結束時這些局部變量所持有的內存將會自動被釋放。

  • 堆區 : 又稱動態內存分配,通常就是指在程序運行時直接 new 出來的內存。這部分內存在不使用時將會由 Java 垃圾回收器來負責回收。

二、堆與棧的區別

棧內存:在方法體內定義的局部變量(一些基本類型的變量和對象的引用變量)都是在方法的棧內存中分配的。當在一段方法塊中定義一個變量時,Java 就會在棧中爲該變量分配內存空間,當超過該變量的作用域後,分配給它的內存空間也將被釋放掉,該內存空間可以被重新使用。

堆內存:用來存放所有由 new 創建的對象(包括該對象其中的所有成員變量)和數組。在堆中分配的內存,將由 Java 垃圾回收器來自動管理。在堆中產生了一個數組或者對象後,還可以在棧中定義一個特殊的變量,這個變量的取值等於數組或者對象在堆內存中的首地址,這個特殊的變量就是我們上面說的引用變量。我們可以通過這個引用變量來訪問堆中的對象或者數組。

例子:

public class A {

    int a = 0;

    B b = new B();

    public void test(){
        int a1 = 1;
        B b1 = new B();
    }

}

A object = new A();

  • A類內的局部變量都存在於棧中,包括基本數據類型a1和引用變量b1,b1指向的B對象實體存在於堆中

  • 引用變量object存在於棧中,而object指向的對象實體存在於堆中,包括這個對象的所有成員變量a和b,而引用變量b指向的B類對象實體存在於堆中

三、Java管理內存的機制

Java的內存管理就是對象的分配和釋放問題。內存的分配是由程序員來完成,內存的釋放由GC(垃圾回收機制)完成。GC 爲了能夠正確釋放對象,必須監控每一個對象的運行狀態,包括對象的申請、引用、被引用、賦值等。這是Java程序運行較慢的原因之一。

釋放對象的原則:該對象不再被引用。

GC的工作原理:
將對象考慮爲有向圖的頂點,將引用關係考慮爲有向圖的有向邊,有向邊從引用者指向被引對象。另外,每個線程對象可以作爲一個圖的起始頂點,例如大多程序從 main 進程開始執行,那麼該圖就是以 main 進程爲頂點開始的一棵根樹。在有向圖中,根頂點可達的對象都是有效對象,GC將不回收這些對象。如果某個對象與這個根頂點不可達,那麼我們認爲這個對象不再被引用,可以被 GC 回收。

下面舉一個例子說明如何用有向圖表示內存管理。對於程序的每一個時刻,我們都有一個有向圖表示JVM的內存分配情況。以下右圖,就是左邊程序運行到第6行的示意圖。

另外,Java使用有向圖的方式進行內存管理,可以消除引用循環的問題,例如有三個對象相互引用,但只要它們和根進程不可達,那麼GC也是可以回收它們的。當然,除了有向圖的方式,還有一些別的內存管理技術,不同的內存管理技術各有優缺點,在這裏就不詳細展開了。

四、Java中的內存泄漏

如果一個對象滿足以下兩個條件:

(1)這些對象是可達的,即在有向圖中,存在通路可以與其相連
(2)這些對象是無用的,即程序以後不會再使用這些對象

就可以判定爲Java中的內存泄漏,這些對象不會被GC所回收,繼續佔用着內存。

在C++中,內存泄漏的範圍更大一些。有些對象被分配了內存空間,然後卻不可達,由於C++中沒有GC,這些內存將永遠收不回來。在Java中,這些不可達的對象都由GC負責回收,因此程序員不需要考慮這部分的內存泄漏。


五、Android中常見的內存泄漏

(1)單例造成的內存泄漏


這是一個普通的單例模式,當創建這個單例的時候,由於需要傳入一個Context,所以這個Context的生命週期的長短至關重要:

1.如果此時傳入的是 Application 的 Context,因爲 Application 的生命週期就是整個應用的生命週期,所以沒有任何問題。

2.如果此時傳入的是 Activity 的 Context,當這個 Context 所對應的 Activity 退出時,由於該 Context 的引用被單例對象所持有,其生命週期等於整個應用程序的生命週期,所以當前 Activity 退出時它的內存並不會被回收,這就造成泄漏了。

當然,Application 的 context 不是萬能的,所以也不能隨便亂用,例如Dialog必須使用 Activity 的 Context。對於這部分有興趣的讀者可以自行搜索相關資料。

(2)非靜態內部類創建靜態實例造成的內存泄漏

public class MainActivity extends AppCompatActivity {

  private static TestResource mResource = null;

  @Override
  protected void onCreate(Bundle savedInstanceState) {
      super.onCreate(savedInstanceState);
      setContentView(R.layout.activity_main);
      if(mManager == null){
          mManager = new TestResource();
       }//...
  }

  class TestResource {//...
  }
}

非靜態內部類默認會持有外部類的引用,而該非靜態內部類又創建了一個靜態的實例,該實例的生命週期和應用的一樣長,這就導致了該靜態實例一直會持有該Activity的引用,導致Activity的內存資源不能正常回收。

(3)匿名內部類造成的內存泄漏

匿名內部類默認也會持有外部類的引用。如果在Activity/Fragment中使用了匿名類,並被異步線程持有,如果沒有任何措施這樣一定會導致泄漏。

ref1和ref2的區別是,ref2使用了匿名內部類。我們來看看運行時這兩個引用的內存:

可以看到,ref1沒什麼特別的。但ref2這個匿名類的實現對象裏面多了一個引用:
this$0這個引用指向MainActivity.this,也就是說當前的MainActivity實例會被ref2持有,如果將這個引用再傳入一個異步線程,此線程和此Acitivity生命週期不一致的時候,就會造成Activity的泄漏。

例子:Handler造成的內存泄漏


在該 MainActivity 中聲明瞭一個延遲10分鐘執行的消息 Message,mHandler 將其 push 進了消息隊列 MessageQueue 裏。當該 Activity 被 finish() 掉時,延遲執行任務的 Message 還會繼續存在於主線程中,它持有該 Activity 的 Handler 引用,然後又因 爲 Handler 爲匿名內部類,它會持有外部類的引用(在這裏就是指 MainActivity),所以此時 finish() 掉的 Activity 就不會被回收了,從而造成內存泄漏。

修復方法:在 Activity 中避免使用非靜態內部類或匿名內部類,比如將 Handler 聲明爲靜態的,則其存活期跟 Activity 的生命週期就無關了。如果需要用到Activity,就通過弱引用的方式引入 Activity,避免直接將 Activity 作爲 context 傳進去。另外, Looper 線程的消息隊列中還是可能會有待處理的消息,所以我們在 Activity 的 Destroy 時或者 Stop 時應該移除消息隊列 MessageQueue 中的消息。見下面代碼:


(4)資源未關閉造成的內存泄漏

對於使用了BraodcastReceiver,ContentObserver,File, Cursor,Stream,Bitmap等資源的使用,應該在Activity銷燬時及時關閉或者註銷,否則這些資源將不會被回收,造成內存泄漏。

(5)一些不良代碼造成的內存壓力

有些代碼並不造成內存泄漏,但是它們,或是對沒使用的內存沒進行有效及時的釋放,或是沒有有效的利用已有的對象而是頻繁的申請新內存。比如,Adapter裏沒有複用convertView等。

六、Android中內存泄漏的排查與分析

(1)利用Android Studio的Memory Monitor來檢測內存情況

先來看一下Android Studio 的 Memory Monitor界面:


最原始的內存泄漏排查方式如下:

重複多次操作關鍵的可疑的路徑,從內存監控工具中觀察內存曲線,看是否存在不斷上升的趨勢,且退出一個界面後,程序內存遲遲不降低的話,可能就發生了嚴重的內存泄漏。

這種方式可以發現最基本,也是最明顯的內存泄漏問題,對用戶價值最大,操作難度小,性價比極高。

下面就開始用一個簡單的例子來說明一下如何排查內存泄漏。

首先,創建了一個TestActivity類,裏面的測試代碼如下:

@Override
   protected void processBiz() {
       mHandler = new Handler();
       mHandler.postDelayed(new Runnable() {
           @Override
           public void run() {
               MLog.d("------postDelayed------");
           }
       }, 800000L);
   }

運行項目,並執行以下操作:進入TestActivity,然後退出,再重新進入,如此操作幾次後,最後最終退出TestActivity。這時發現,內存持續增高,如圖所示:


好了,這時我們可以假設,這裏可能出現了內存泄漏的情況。那麼,如何繼續定位到內存泄漏的地址呢?這時候就得點擊“Dump java heap”按鈕來收集具體的信息了。

(2)使用Android Studio生成Java Heap文件來分析內存情況

注意,在點擊 Dump java heap 按鈕之前,一定要先點擊Initate GC按鈕強制GC,建議點擊後等待幾秒後再次點擊,嘗試多次,讓GC更加充分。然後再點擊Dump Java Heap按鈕。

這時候會生成一個Java heap文件並在新的窗口打開:

這時候,點擊右上角的“Analyzer Task”,再點擊出現的綠色按鈕,讓Android studio幫我們自動分析出有可能潛在的內存泄漏的地方:

如上圖所示,Android studio提示有3個TestActivity對象可能出現了內存泄漏。而且左邊的Reference Tree(引用樹),也大概列出了該實體類被引用的路徑。如果是一些比較簡單的內存泄漏情況,僅僅看這裏就大概能猜到是哪裏導致了內存泄漏。
但如果是比較複雜的情況,還是推薦使用MAT工具(Memory Analyzer)來繼續分析比較好。

(3)使用Memory Analyzer(MAT)來分析內存泄漏

MAT是Eclipse出品的一個插件,當然也有獨立的版本。下載鏈接:MAT下載地址

在這裏先提醒一下:MAT並不會準確地告訴我們哪裏發生了內存泄漏,而是會提供一大堆的數據和線索,我們需要根據自己的實際代碼和業務邏輯去分析這些數據,判斷到底是不是真的發生了內存泄漏。

MAT支持對標準格式的hprof文件進行內存分析,所以,我們要先在Android Studio裏先把Java heap文件轉成標準格式的hprof文件,具體步驟如下:

點擊左側的capture,選擇對應的文件,並右鍵選擇“Export to standard .hprof”導出標準的hprof文件:


導出標準的hprof文件後,在MAT工具裏導入,則看到以下界面:

MAT中提供了非常多的功能,這裏我們只要學習幾個最常用的就可以了。上圖那個餅狀圖展示了最大的幾個對象所佔內存的比例,這張圖中提供的內容並不多,我們可以忽略它。在這個餅狀圖的下方就有幾個非常有用的工具了。

Histogram:直方圖,可以列出內存中每個對象的名字、數量以及大小。

Dominator Tree:會將所有內存中的對象按大小進行排序,並且我們可以分析對象之間的引用結構。

1)Dominator Tree

從上圖可以看到右邊存在着3個參數。Retained Heap表示這個對象以及它所持有的其它引用(包括直接和間接)所佔的總內存,因此從上圖中看,前兩行的Retained Heap是最大的,分析內存泄漏時,內存最大的對象也是最應該去懷疑的。

另外大家應該可以注意到,在每一行的最左邊都有一個文件型的圖標,這些圖標有的左下角帶有一個紅色的點,有的則沒有。帶有紅點的對象就表示是可以被GC Roots訪問到的,
可以被GC Root訪問到的對象都是無法被回收的。那麼這就可以說明所有帶紅色的對象都是泄漏的對象嗎?當然不是,因爲有些對象系統需要一直使用,本來就不應該被回收。
如果發現有的對象右邊有寫着System Class,那麼說明這是一個由系統管理的對象,並不是由我們自己創建並導致內存泄漏的對象。

根據我們在Android studio的Java heap文件的提示,TestActivity對象有可能發生了內存泄漏,於是我們直接在上面搜TestActivity(這個搜索功能也是很強大的):

左邊的inspector可以查看對象內部的各種信息:


當然,如果你覺得按照默認的排序方式來查看不方便,你可以自行設置排序的方式:

  • Group by class
  • Group by class loader
  • Group by package

從上圖可以看出,我們搜出了3個TestActivity的對象,一般在退出某個activity後,就結束了一個activity的生命週期,應該會被GC正常回收纔對的。通常情況下,一個activity應該只有1個實例對象,但是現在居然有3個TestActivity對象存在,說明之前的操作,產生了3個TestActivity對象,並且無法被系統回收掉。

接下來繼續查看引用路徑。

對着TestActivity對象點擊右鍵 -> Merge Shortest Paths to GC Roots(當然,這裏也可以選擇Path To GC Roots) -> exclude all phantom/weak/soft etc. references

爲什麼選擇exclude all phantom/weak/soft etc. references呢?因爲弱引用等是不會阻止對象被垃圾回收器回收的,所以我們這裏直接把它排除掉

接下來就能看到引用路徑關係圖了:


從上圖可以看出,TestActivity是被this0所引用的,它實際上是匿名類對當前類的引用。this0又被callback所引用,接着它又被Message中一串的next所引用...到這裏,我們就已經分析出內存泄漏的原因了,接下來就是去改善存在問題的代碼了。

2)Histogram


這裏是把當前應用程序中所有的對象的名字、數量和大小全部都列出來了,那麼Shallow Heap又是什麼意思呢?就是當前對象自己所佔內存的大小,不包含引用關係的。

上圖當中,byte[]對象的Shallow Heap最高,說明我們應用程序中用了很多byte[]類型的數據,比如說圖片。可以通過右鍵 -> List objects -> with incoming references來查看具體是誰在使用這些byte[]。

當然,除了一般的對象,我們還可以專門查看線程對象的信息:


Histogram中是可以顯示對象的數量的,比如說我們現在懷疑TestActivity中有可能存在內存泄漏,就可以在第一行的正則表達式框中搜索“TestActivity”,如下所示:

接下來對着TestActivity右鍵 -> List objects -> with outgoing references查看具體TestActivity實例

注:
List objects -> with outgoing references :表示該對象的出節點(被該對象引用的對象)
List objects -> with incoming references:表示該對象的入節點(引用到該對象的對象)

如果想要查看內存泄漏的具體原因,可以對着任意一個TestActivity的實例右鍵 -> Merge Shortest Paths to GC Roots(當然,這裏也可以選擇Path To GC Roots) -> exclude all phantom/weak/soft etc. references,如下圖所示:


從這裏可以看出,Histogram和Dominator Tree兩種方式下操作都是差不多的,只是兩種統計圖展示的側重點不太一樣,實際操作中,根據需求選擇不同的方式即可。

3)兩個hprof文件的對比
爲了排查內存泄漏,經常會需要做一些前後的對比。下面簡單說一下兩種對比方式:

1.直接對比

工具欄最右邊有個“Compare to another heap dump”的按鈕,只要點擊,就可以生成對比後的結果。(注意,要先在MAT中打開要對比的hprof文件,才能選擇對比的文件):

2.添加到campare basket裏對比

在window菜單下面選擇compare basket:


在文件的Histogram view模式下,在navigation history下選擇add to compare basket:

然後就可以通過 Compare Tables 來進行對比了:


七、總結

最後,還是要再次提醒一下,工具是死的,人是活的,MAT也沒有辦法保證一定可以將內存泄漏的原因找出來,還是需要我們對程序的代碼有足夠多的瞭解,知道有哪些對象是存活的,以及它們存活的原因,然後再結合MAT給出的數據來進行具體的分析,這樣纔有可能把一些隱藏得很深的問題原因給找出來。

參考鏈接:https://zhuanlan.zhihu.com/p/27593816

至此,本篇已結束。轉載網絡的文章,小編覺得很優秀,歡迎點擊閱讀原文,支持原創作者,如有侵權,懇請聯繫小編刪除,歡迎您的建議與指正。同時期待您的關注,感謝您的閱讀,謝謝!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章