1. 程式人生 > >Android 效能優化之記憶體洩漏的檢測與修復

Android 效能優化之記憶體洩漏的檢測與修復

在 Android 開發中, 記憶體優化是APP效能優化中很重要的一個部分. 而在記憶體優化中, 最重要的就是修復記憶體洩漏問題. 本文就來介紹一下記憶體洩漏的基本概念以及常用的檢測手段.

1. 什麼是記憶體洩漏

簡單來說, 當一個物件不再被使用時, 理應不存在任何強引用指向他從而可以讓垃圾回收器(GC)在未來的某個時間點將其回收的, 但由於某些原因導致有強引用依然指向該物件, 使得該物件無法被垃圾回收器(GC)回收的現象, 我們就稱該物件被洩漏到記憶體中了, 簡稱”記憶體洩漏”. 其實, 在這種情況下, 不僅該物件會洩漏, 而且該物件內部包含的其他引用所指向的物件也將發生洩漏. 也就是一種牽連效應.

概念總是枯燥難懂的, 那就舉個例子吧.

這裡寫圖片描述

假如一個 Activity 的佈局檔案中定義了多個控制元件, 例如: 下面的 DemoActivity

public class DemoActivity extends Activity {
    private TextView mTv;
    private ImageView mIv;
    private Button mBtn;
}

上面是我們通常的寫法, DemoActivity 對其內部定義的所有控制元件都持有強引用 (請自行查閱四種引用的概念和區別). 如果我們在某個時刻需要銷燬該 DemoActivity , 但是實際上因為某些原因導致它沒有銷燬或者沒有及時銷燬(即: 延遲了一段時間才銷燬), 依然存在於記憶體中, 那麼我們就說該 DemoActivity

物件發生了記憶體洩漏, 對於後一種情況, 就是在延遲的那段時間內發生了記憶體洩漏. 同時, 由於該DemoActivity 對其內部定義的各個控制元件都持有了強引用, 導致這些控制元件也沒有銷燬, 也發生了記憶體洩漏. 同理可以推測, 這些控制元件內部包含的一些強引用所指向的物件也將發生記憶體洩漏…… 這一系列洩漏的物件組成了一個強引用的鏈條, 一旦該鏈條中處於頭部的某個物件發生了記憶體洩漏, 那麼這個鏈條中, 被該物件所直接或間接引用的物件也將受牽連而發生記憶體洩漏.

2. 為什麼要檢測並修復記憶體洩漏

為什麼要檢測並修復記憶體洩漏問題呢? 因為記憶體洩漏問題輕則造成 APP 卡頓不流暢, 重則導致 OOM 而讓 APP 崩潰, 所以修復記憶體洩漏問題是非常必要的. 更具體地說, 一款手機的可用記憶體空間是非常有限的, 一個應用程式可申請的最大記憶體空間也是有限的, 如果一個物件發生了記憶體洩漏, 那麼它所直接或間接強引用的那些物件也將發生記憶體洩漏, 可能該物件本身佔用的記憶體空間並不大, 但是由它直接或間接強引用的那些物件中, 就可能存在著佔用記憶體較大的物件. 所以, 一個佔用記憶體不太大的小物件的洩漏, 由上述鏈式連鎖反應所造成的整體洩漏量也可能是非常龐大的. 例如: 我們有一個頁面, 該頁面中包含了一個用於展示一系列圖片的 ListView

GridView 控制元件. 如果用於表示該頁面的 ActivityFragment 物件發生了記憶體洩漏, 那麼, 該頁面中的 ListViewGridView 控制元件也會洩漏, ListViewGridView 的每個條目中所包含的 ImageView 物件也將發生洩漏, 而 ImageView 是用來展示圖片的, 因此, 每一個 ImageView 物件所(強)引用的 Bitmap 物件也會發生洩漏. 而 Bitmap 物件的記憶體洩漏是非常嚴重的. 為什麼這麼說呢? 我們來具體分析一下一個 Bitmap 物件被載入到記憶體中所佔據的記憶體空間大小吧. 例如: 我們要載入一張解析度為 960 * 637 的圖片, 儲存方式採用 RGB565, RGB565 意味著該圖片的每個畫素點在記憶體中佔用2個位元組(5+6+5 = 16bit = 2Byte), 如果我們不對該圖片進行壓縮處理, 而是直接載入原圖的話, 那麼這張圖片載入到記憶體中就會佔據約 1.2MB 的空間 (960 * 637 * 2 = ‭‭1223040 Byte ≈ 1.2MB). 如果它發生了洩漏, APP 就會損失 1.2MB 的可用空間. 而一個包含有圖片的 ListView GridView 通常載入的圖片數量遠遠不止一張, 那麼多個 Bitmap 物件同時洩漏, APP的可用記憶體空間一下子就減少了很大一部分, 洩漏多了就會讓頁面的滑動變得非常卡頓, 甚至會提示 APP 已經崩潰. 所以我們說, Bitmap 物件的記憶體洩漏是非常嚴重的. 上個圖證明一下前邊我們對 Bitmap 物件佔用記憶體大小的計算:

這裡寫圖片描述

看圖中右邊 Watches 視窗中計算的 bitmap 在記憶體中的大小 bitmap.getByteCount()的值, 確實就是我們自己計算的 1223040 Byte.

3. 如何判斷某個頁面是否存在記憶體洩漏

介紹完記憶體洩漏的基本概念及其嚴重性以後, 我們可能會思考, 如何判斷某個頁面是否存在記憶體洩漏? 其實, Android Studio 已經為我們提供了相關的檢測工具, 我們只需利用好這些工具即可進行判斷了. 當然, 這裡提醒一下各位朋友, 這些工具並不會直接告訴我們 “某個地方有洩漏”, “某個地方無洩漏” 等等這麼直白的結論, 而只會提供給我們一些資料和圖表, 至於是否有洩漏, 需要我們自己結合這些資料和圖表去判斷.

這些檢測工具在哪裡呢? 我們看下邊這張圖, Android Monitor 標籤中有 logcat 和 Monitors 兩個子標籤:

這裡寫圖片描述

其中, logcat 是我們非常熟悉的用於檢視 log 的地方. 而我們判斷記憶體洩漏, 則需要用到另外一個標籤, 即: Monitors. 點選 Monitors 標籤, 會看到下圖:

這裡寫圖片描述

可以看到, Monitors 標籤可以展示記憶體(Memory), 網路(Network), CPU, GPU這些重要指標的實時數值以及變化曲線圖. 我們要判斷記憶體洩漏, 只需關注最上方的記憶體(Memory)即可. 上圖中有幾個重要的地方需要大家瞭解一下:

  • Initial GC 按鈕(這裡寫圖片描述): 點選此按鈕可手動觸發 GC 進行垃圾回收. 每次垃圾回收都能回收掉記憶體中弱引用指向的物件.
  • Dump Java Heap 按鈕(這裡寫圖片描述): 點選此按鈕可儲存這一瞬間該程序在 Java 堆記憶體中物件分配情況的快照 (“快照”可以理解為 “截圖”的意思). 這裡請大家稍微留意一下, “Dump Java Heap” 是個動詞, 意思是獲取 Java 堆記憶體的快照, 後文將要提到的 “Heap Dump”是個名詞, 意思是 Java 堆記憶體的快照. 總之, 只要見到這幾個單詞的組合, 表示的含義就基本相同.
  • Allocated 數值(這裡寫圖片描述): 當前實時分配的堆記憶體數值.

我們要判斷 APP 的某個頁面是否存在記憶體洩漏, 只需在進入該頁面前, 先點選幾次 Initial GC 按鈕(這裡寫圖片描述), 讓垃圾回收器先進行幾次回收, 然後記錄 Allocated 的瞬時最小數值(一定要記錄點選GC後的最小值, 因為只有最小值才是 GC 執行後的真實結果. 但如果你點選GC後過了幾秒才去記錄, 那麼在這幾秒內, 當前頁面可能又會為某些弱引用所引用的物件再次分配記憶體空間從而使 Allocated 的數值又增長了上去, 那麼這時的 Allocated 的數值就不準確了, 因為我們要記錄的是無法被GC回收的記憶體大小), 記錄完 Allocated 值以後, 進入該頁面再退出回到先前的頁面, 然後再次點選幾次 Initial GC 按鈕(這裡寫圖片描述), 並再次記錄點選GC以後那一瞬間幾個 Allocated 數值中的最小值. 然後將前後兩次記錄的 Allocated 數值進行對比, 如果發現後一個數值要大於或者遠大於前一個數值, 就說明該頁面可能存在記憶體洩漏. 注意: 一定要在執行完GC後再記錄資料, 因為執行完 GC 後的數值表示無法被回收的物件大小, 如果被測試的頁面不存在記憶體洩漏或者只存在輕微以至於可以忽略不計的洩漏, 那麼關閉該頁面後再執行 GC 操作, 此時所佔用的記憶體空間理應和進入該頁面前所佔據的記憶體空間大小基本相等. 而如果數值增長了較多, 就說明該頁面可能存在記憶體洩漏.

下面用一個例子來演示一下我們剛才所介紹的判斷過程. 假如我們的 APP 有如下兩個頁面:

這裡寫圖片描述 這裡寫圖片描述

點選第一個頁面中紅框標註的文字, 會跳轉到第二個頁面. 程式碼都是常規的控制元件和常規的寫法, 這裡就只貼出第二個頁面的程式碼:

public class StaticLeakActivity extends Activity {

    private GridView mGv;
    private ImageAdapter mAdapter;

    private static Context sContext;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_static_leak);
        sContext = this;
        loadImg();
    }

    private void loadImg() {
        mGv = (GridView) findViewById(R.id.gv);
        mAdapter = new ImageAdapter(this);
        mGv.setAdapter(mAdapter);
        mAdapter.addDatas(Arrays.asList(ImgUtils.URLS));
    }
}

如要檢視該 demo 的完整程式碼, 請點選這裡下載. 為了模擬記憶體洩漏的情況, 我在上述程式碼中定義了一個靜態變數 sContext, 並讓其指向當前頁面物件. 如下:

這裡寫圖片描述

常規開發中, 估計大家都不會這麼寫, 這裡只是為了演示記憶體洩漏的效果, 以及接下來要講到的定位記憶體洩漏的方法而刻意這樣寫的. 讀者朋友不必太糾結於上述程式碼的寫法, 只需關注後邊要講到的定位記憶體洩漏的步驟和方法. 好了, 我們現在就可以利用前邊提到的判斷方法來判斷一下第二個頁面是否存在記憶體洩漏吧.

  1. 在第一個頁面時, 多次點選 Initial GC 按鈕(這裡寫圖片描述), 然後記錄這幾次點選GC後的瞬間 Allocated 數值中的最小值.

    這裡寫圖片描述

    這裡我點選了 3次 Initial GC 按鈕(這裡寫圖片描述), 發現每次在點選後的瞬間, Allocated 的最小值基本上維持在 0.98MB 左右.

  2. 進入第二個頁面, 待該頁面上的圖片基本載入完畢後, 退出該頁面, 回到第一個頁面, 多次點選 Initial GC 按鈕(這裡寫圖片描述), 然後記錄這幾次點選GC後的瞬間 Allocated 數值中的最小值.

    這裡寫圖片描述

    這裡我點選了 4次 Initial GC 按鈕(這裡寫圖片描述), 發現每次在點選後的瞬間, Allocated 的最小值基本上維持在 3.82MB 左右.

  3. 將前後兩次記錄的數值進行對比, 發現後一個數值遠大於前一個數值 (3.82MB > 0.98MB), 說明第二個頁面可能存在記憶體洩漏. (另外補充計算一下, 從 0.98MB 增長到 3.82MB, 增長了 2.84MB)

4. 如何定位記憶體洩漏

如何定位, 也就是說, 如何找到導致記憶體洩漏問題的那一行或幾行程式碼. 這裡介紹三種方法.

(1) 使用 Android Studio 內部整合的工具進行定位

其實 Android Studio 內部已經集成了分析記憶體洩漏的工具, 我們就以前邊的例子來演示用這種方法定位記憶體洩漏吧.

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

這裡寫圖片描述

根據上圖圖示的步驟進行操作, 即可知道, 第二個頁面, 也就是 StaticLeakActivity 確實發生了記憶體洩漏, 並且在步驟⑧可以看出, 該洩漏是由該 Activity 中的 sContext 變數引起的. 在關閉了該頁面後, 由於該頁面中定義的變數 sContext 無法被 GC 回收, 而它又引用著該 Activity 物件, 導致該 Activity 物件發生洩漏. 為什麼關閉該頁面後, 變數 sContext 無法被回收呢? 因為它是靜態變數, 只要該 APP 所在的程序還在, 那麼靜態變數就不會被銷燬. 這樣, 我們就知道了問題的根源了. 那麼, 修復起來也容易, 把該變數改為非靜態的, 或者將它不要引用到 Activity 物件即可. 當然如果有某個地方確實需要使用到靜態變數, 並且它也需要引用一個 Context 物件作為引數, 如果允許的話, 也可以改為使用 ApplicationContext 來代替直接使用 Activity 物件作為該靜態變數引用的引數. 我們將原先程式碼中靜態變數 sContext 引用當前 Activity 物件的那句程式碼註釋掉:

// sContext = this;

然後, 我們再次看看兩次 Allocated 數值的對比:

  1. 第一個頁面, 多次點選 Initial GC 按鈕(這裡寫圖片描述), 記錄點選 Initial GC 按鈕(這裡寫圖片描述)後的 Allocated 瞬時值中的最小值, 0.98MB, 如下圖:

    這裡寫圖片描述

  2. 進入第二個頁面, 然後退出回到第一個頁面, 多次點選 Initial GC 按鈕(這裡寫圖片描述), 記錄點選 Initial GC 按鈕(這裡寫圖片描述)後 Allocated 瞬時值中的最小值, 1.80MB, 如下圖:

    這裡寫圖片描述

  3. 對比兩次記錄的數值, 發現依然有增長, 從 0.98MB 增長到 1.80MB, 漲幅 0.82MB, 說明依然有少量記憶體洩漏, 不過這也正常, 因為 Android Studio 這個分析記憶體洩漏的工具只能分析 Activity 的洩漏, 不能分析其他物件的洩漏, 要想徹底分析所有物件的洩漏, 還需藉助於後文將要介紹的第(3)種檢測工具 —— MAT. 雖然仍有洩漏, 但和先前的程式碼相比, 洩漏量明顯減少了許多, 先前從 0.98MB 增長到 3.82MB, 漲幅為 2.84MB, 但現在的程式碼只洩漏了 0.82MB, 減少了約 2MB 的洩漏量. 而且, 我們修復記憶體洩漏的原則是, 先修復洩漏量最大的問題, 最後有空餘時間了再去修復洩漏量較小的問題. 另外, Android 系統框架自身, 以及常見的第三方優秀開源框架也都多多少少存在著記憶體洩漏問題, 這些都是我們無法控制和避免的, 我們只要處理好我們自己寫的程式碼, 修復由我們自己程式碼所引起的記憶體洩漏問題就夠了. 系統和第三方框架的記憶體洩漏如果量不大, 就忽略吧, 畢竟優秀框架是不可避免要用的, 而且現在手機配置也越來越高了, 可分配的記憶體空間也更大了, 所以少量洩漏影響不大.

具體到我們前邊的這個例子, 該如何使用該庫進行檢測呢? 首先, 我們需要把如下程式碼取消註釋, 營造一個記憶體洩漏的大環境.

// sContext = this;

然後按照官方使用說明, 整合該庫到我們的程式碼中, 包括新增依賴以及在自定義 Application 類中對該庫進行註冊.
app module 的 build.gradle:

dependencies {
    // ... 省略其他依賴
    debugCompile 'com.squareup.leakcanary:leakcanary-android:1.4-beta2'
    releaseCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.4-beta2'
    testCompile 'com.squareup.leakcanary:leakcanary-android-no-op:1.4-beta2'
}

自定義 Application 類 (另外別忘了在 Manifest 中為 Application 標籤新增 name 屬性).

public class App extends Application {
    @Override
    public void onCreate() {
        super.onCreate();
        LeakCanary.install(this);
        // ... 其他程式碼省略
    }
}

這樣就配置好了, 接下來我們就開始操作吧.

這裡寫圖片描述

上圖就是使用 LeakCanary 進行記憶體洩漏檢測的全過程. 如果我們懷疑某個頁面有記憶體洩漏, 那麼就進入該頁面再退出, 然後等待幾秒鐘時間, LeakCanary 就開始捕獲這一進一出前後兩次的記憶體快照進行對比, 捕獲記憶體快照時還會給個下圖這樣的提示:

這裡寫圖片描述

對比前後兩次記憶體快照後, 如果發現有記憶體洩漏, 就會推送一條訊息, 並在頂部通知欄顯示一個圖示 這裡寫圖片描述, 點開推送的訊息, 即可進入到如下圖所示的記憶體洩漏詳情頁面:

這裡寫圖片描述

該頁面詳細描述了發生洩漏的物件所在的包名, 引用鏈, 以及洩漏的記憶體大小. 上圖中, 我們可以很清楚地看到, 我們這個例子中, StaticLeakActivity 物件發生了洩漏, 並且就是由於 sContext 造成的. 另外, 我們留意一下右上角的記憶體洩漏大小, 2.0MB, 這個數字我們很熟悉呀. 我們在介紹方法(1)時, 最後總結到, 經過修復後, 記憶體雖仍有少量洩漏, 但和之前相比節省了 2MB空間, 這個 2MB 不正是上圖右上角的 2.0MB嘛, 看來 LeakCanary 也太智慧了, 再也無需自己手動操作那一大堆按鈕了, 也無需再自己手動 Dump Java Heap 了.

另外, 我們點選返回按鈕, 可以進入到 LeakCanary 的首頁, 如下:

這裡寫圖片描述

首頁還會列出該包名下的所有洩漏情況 (包括昨天, 前天…的歷史洩漏情況), 每個洩漏還會列出被洩漏的物件, 洩漏量, 捕獲記憶體快照的日期時間. 當然最下方還提供了可以清空這些記錄的 “DELETE ALL” 按鈕.

另外, 經過整合 LeakCanary 庫的程式碼在執行後, 會額外生成一個名為 leaks 的圖示:
這裡寫圖片描述

如果我們將導致記憶體洩漏的那句程式碼遮蔽掉, 再次按照上述步驟執行, 等待一段時間後, 我們發現這次 LeakCanary 並不會向我們推送訊息, 說明該頁面沒有記憶體洩漏, 我們的修復奏效了.

MAT (Memory Analyzer Tool) 是一個專門用於分析 Java 記憶體洩漏的工具, 既有 Eclipse 的外掛, 也有不依賴 Eclipse 可獨立執行的軟體. 如果要使用 Android Studio 結合 MAT 進行記憶體分析, 就需要使用後者. 可在 MAT 的官方網站 https://www.eclipse.org/mat/ 進行下載. 我們這裡要介紹的是使用 Android Studio + MAT 進行記憶體洩漏分析. 我們還是用上文中的哪個例子來做介紹吧. 既然我們已經分析出了第二個頁面, 也就是 StaticLeakActivity 可能存在記憶體洩漏問題了, 那麼, 我們如何用 MAT 來定位到具體程式碼呢? 其實, 使用 MAT 分析記憶體洩漏的核心思想, 就是對比前後兩次分別經過GC處理後的記憶體快照, 找出二者的差異之處, 這些差異就可能是發生記憶體洩漏的物件. 所以, 我們需要匯出兩份記憶體快照, 一份是在第一個頁面時的快照, 另一份是進入第二個頁面然後又退出回到第一個頁面後的快照, 注意匯出記憶體快照前, 都需要手動觸發GC, 否則可能會對我們的判斷造成干擾. 另外還需注意的一點是, 點選 Android Studio 中的 Dump Java Heap 按鈕 (這裡寫圖片描述) 所匯出的 .hprof檔案不是標準的快照檔案, 如需提供給 MAT 使用, 還需轉換為標準的快照檔案. 下面我們還是用貼圖的方式介紹操作步驟吧.

首先, 在第一個頁面, 如下圖, 先手動觸發幾次 GC操作(也就是點選下圖中的按鈕①), 邊點選邊觀察, 當發現每次觸發 GC 後的瞬間 Allocated 數值都基本維持在某一個數值附近時, 就可以在觸發了 GC 後立即點選按鈕②, 這時就會匯出這一瞬間的記憶體快照:

這裡寫圖片描述

記憶體快照匯出完畢後, 會生成一個 .hprof 檔案, 儲存在 Captures 標籤中 (上文已做介紹), 由於該 .hprof 檔案不是標準的 .hprof 檔案, 無法被 MAT 識別, 所以需要將其轉換為標準檔案, 轉換方式如下圖④所示. 當然也可以使用 hprof-conv 命令進行轉換(這裡不做介紹, 請自行搜尋).
這裡寫圖片描述

將轉換後的標準檔案命名為 1.hprof 並儲存, 命名可隨意. 然後, 我們進入第二個頁面再退出回到第一個頁面, 再使用相同的方式匯出記憶體快照並轉換為標準檔案, 命名為 2.hprof.

開啟 MAT 軟體, 同時開啟剛才儲存的兩個 .hprof檔案. 開啟方式是:
進入 MAT 後, 選擇 File– Open Heap Dump…—同時選擇剛才儲存的 1.hprof 和 2.hprof 檔案並開啟, 開啟後的效果是這樣的:

這裡寫圖片描述

要對比這兩個檔案, 可以通過拖拽將這兩個檔案左右擺放, 各自佔據一半的空間, 也就是下面的效果:

這裡寫圖片描述

我們看到, MAT 開啟 .hprof 檔案後, 會有多個不同的選項可供點選檢視, 他們要麼提供了各種圖表, 要麼提供了各種資料. 而我們使用 MAT 進行記憶體洩漏分析, 常用的是 “Histogram” 和 “Dominator Tree” 這兩個選項. 我們對比兩個 .hprof 檔案, 通常來說也就是對比這兩個選項中的資料.

這裡寫圖片描述

無論是使用 Histogram 還是 Dominator Tree 進行記憶體洩漏分析, 都會遇到與記憶體相關的一些專用術語, 只有瞭解了他們的具體含義, 才能更好地分析記憶體問題. 下面我們就先來介紹一下這些術語吧.

  • Heap Dump:
    前邊已經介紹過, 它表示某個程序在某一時刻所佔用的堆記憶體的快照. 我們匯入到 MAT 中的 .hprof 檔案儲存的就是記憶體快照的資料. 由於快照只是一瞬間的事情,所以 Heap Dump 中無法包含一個物件在何時、何地(哪個方法中)被分配這樣的資訊.

  • Shallow Heap 和 Retained Heap:
    一個物件的 Shallow Heap, 指的是該物件自身佔用記憶體的大小.
    一個物件的 Retained Heap, 指的是當該物件被GC回收時, 所釋放掉的記憶體大小. 由於該物件先前可能直接或間接持有對其他多個物件的引用, 那麼當它自己被回收時, 被它所引用的其他物件有些也可能會被回收, 所以這種情況下, 該物件的 Retained Heap 既包括他自身佔用記憶體的大小, 也包括所有被它直接或間接引用的某些物件佔用記憶體的大小. 注意, 並不是所有被引用的物件所佔用的記憶體大小都算作該物件的 Retained Heap. 例如: 物件B被物件A直接或間接引用, 但是當物件A被回收時, 如果物件B不會被回收, 那麼物件B所佔用的記憶體大小就不能算作物件A的 Retained Heap 的一部分. 再來看下圖的例子:

    這裡寫圖片描述

    如果 E 被回收, 那麼 G 也會被回收, 所以 E 的 Retained Heap 就是 E, G 佔用記憶體大小的總和.
    如果 C 被回收, 那麼 D,E,F,G,H 也會被回收, 所以 C 的 Retained Heap 就是 C,D,E,F,G,H 佔用記憶體大小的總和.
    如果 A 被回收, 那麼 B,C,D,E,F,G,H 也會被回收, 所以 A 的 Retained Heap 就是 A,B,C,D,E,F,G,H 佔用記憶體大小的總和.
    另外還有個與 Retained Heap 類似的但屬於另一個維度的概念, Retained Set, 意思是上述這些 Retained Heap 所對應的物件的集合.

  • Garbage Collection Root (簡稱 GC Root):
    MAT 官方對於 GC root 的解釋是 A garbage collection root is an object that is accessible from outside the heap. 翻譯過來就是, GC root 是一個可以從堆記憶體以外的地方訪問到的物件. 這句話不太好理解, 其實簡單來說, GC root 就是我們上文提到的引用鏈中位於鏈條起點的那個物件. 例如: 沒有其他物件引用 A, 而 A 引用 B, B 引用 C…., C 引用 D, 這樣就形成了一條引用鏈: A–>B–>C–>D…, 其中 A 就是這條引用鏈的 GC root. 此外, MAT 官方還介紹了 GC root 的常見型別, 例如: System Class, Java Local(區域性變數)等, 有興趣的朋友可以點選這裡查閱. 注意: GC執行垃圾回收時, 是不會回收 GC root 的物件.

  • Dominator Tree:
    如果物件A引用著物件B, 我們就可以說 A 控制(dominate)著 B. 我們可以將眾多引用關係繪製成一張樹狀圖, 那麼這個表示物件之間引用關係的樹狀圖就稱作 Dominator Tree (控制樹). 嚴格來說, Dominator Tree 和我們上文所說的引用鏈不完全相同, Dominator Tree 中的所有物件都屬於根節點物件的 Retained Set (這個概念在上文介紹 Shallow Heap 和 Retained Heap 時曾經介紹過).

介紹完這些概念, 我們就可以介紹使用 Histogram 和 Dominator Tree 來進行記憶體洩漏分析了.

  • 使用 Histogram 分析記憶體洩漏

    下面是開啟 Histogram 後的效果圖, 該圖會列舉出該記憶體快照中, 各個物件的數量以及各自佔用的 Shallow Heap 的大小.

    這裡寫圖片描述

    我們可以設定該列表中物件歸類顯示的具體方式, 例如: 根據包名進行歸類顯示, 這樣具有相同包名的物件就會被放置在一起進行顯示. 如下圖所示. 如果我們只關心我們自己開發的程式碼中各個物件在記憶體中的數量, 那麼選擇 “Group by package”的選項 (根據包名歸類顯示) 即可.

    這裡寫圖片描述

    既然我們要檢測第二個頁面是否存在記憶體洩漏, 那麼我們可以在搜尋框中輸入該頁面的 Activity 名稱, 檢視記憶體中該 Activity 物件的數量. 如果 Activity 物件沒有洩漏, 那麼在 2.hprof 檔案中搜索出來的該 Activity 數量就應該為0, 如果搜尋結果大於0, 那麼就表示該 Activity 物件有洩漏. 我們對比一下兩個快照檔案的搜尋結果:

    這裡寫圖片描述

    可以看到, 1.hprof 中, 第二個頁面(即: StaticLeakActivity)的物件數量為0, 而 2.hprof 中其數量為1, 說明在進入該頁面再退出後, 即使執行了 GC操作, 該頁面的 Activity 物件也依然無法被GC回收而存在於記憶體中, 說明該頁面確實發生了記憶體洩漏, 有記憶體洩漏就說明該 Activity 一直被其他物件引用著. 被誰引用呢? 我們可以對該行點右鍵—>List objects—>with incoming references 檢視.

    這裡寫圖片描述

    下圖是所有持有對該 Activity 引用的其他物件.

    這裡寫圖片描述

    因為每個控制元件建立時都會傳一個 Context 引數, 所以圖中的 ImageView, GridView 物件引用著該 Activity也就很容易理解了, 同理還有我們定義的 ImageAdapter 等. 不過注意觀察圖中用紅框選中的那一行, 這是該 Activity 內部定義的一個靜態變數 sContext, 它也引用著該Activity物件, 這很正常啊. 但是請觀察該行最左邊有個小黃點, 小黃點表示該變數屬於 GC root, 是無法被GC回收的, 關閉該頁面時, 由於該變數不能被回收, 就導致它所引用的該 Activity 物件也不能被回收, 發生洩漏. 我們再觀察一下紅框中最右邊的 System Class, 說明該變數是在系統啟動時就被載入進記憶體了, 而不是該頁面啟動後才載入的, 這不正是靜態變數的本意嗎? 至於圖中其他行, 由於他們不是 GC root, 所以在關閉該頁面並執行 GC 操作後, 他們都會被回收. 分析到這裡, 我們也就知道了, 該 Activity 的洩漏, 是由於靜態變數 sContext 的緣故, 修復起來也就簡單了.

    最後要提示一句, 上圖列出的只是直接引用到該 Activity 的物件列表, 而不能檢視間接引用它的物件列表. 有時候某個物件被 GC root 物件間接引用, 也會導致該物件發生洩漏, 那麼如果是這種間接引用, 那麼我們又該如何檢視呢? 其實可以在上圖的基礎上, 對著該物件點右鍵 —>Path To GC Roots —> exclude weak references, 即可檢視到是哪些 GC root 物件間接引用著該物件了. 操作步驟見下圖:

    這裡寫圖片描述

    使用這種方法, 我們再次找到了導致該 Activity 物件發生洩漏的元凶. 而之所以要排除 weak reference (弱引用), 是因為弱引用的特點決定了他們是一定能被 GC 垃圾回收的, 雖然我們在匯出記憶體快照前執行過一次 GC 操作, 已經清理掉大部分軟引用的物件, 但是在執行完 GC 操作到我們點選 Dump Java Heap 按鈕之間的一小段時間可能又會生成少量這些物件, 所以在這裡將他們排除掉可以避免干擾.

    這裡寫圖片描述

    前邊介紹的是通過搜尋來對比被搜尋物件前後數量的變化, 當然, 我們也可以將這些物件按照各自所屬的包進行歸類擺放, 然後找出我們自己程式碼所屬的包, 然後逐一進行數量的對比, 這樣的檢測也會更完整一些. 點選下圖紅框標註的按鈕即可讓物件列表按照包來歸類.

    這裡寫圖片描述

    我們只關注我們自己的包 com.exampel.memoryleakdemo 中的物件在前後數量上的對比, 如下圖所示:

    這裡寫圖片描述

    經過對比, 可以發現, 2.hprof 檔案中使用紅框標註的3個物件 (ImageAdapter$ViewHolder, ImageAdapter 和 StaticLeakActivity), 在數量上都比各自相應在 1.hprof 檔案中的數量要多, 說明這3個變數都發生了洩漏. 而且我們還能看到他們各自被洩漏出去的具體數量. 我們也可以使用前邊提到的檢視 Path To GC Roots 的方法來檢視是哪些 GC root 物件分別引用著他們. 查詢結果如下:

    這裡寫圖片描述

    這裡寫圖片描述

    這裡寫圖片描述

    由上述3張圖可知, 被洩漏的這三種物件都是由於被 sContext 引用而導致洩漏的.

    最後提示一個小技巧, 我們不是要肉眼觀察對比兩個檔案相同物件的數量差異嗎? 何必這麼麻煩呢! MAT 已經集成了自動對比的功能, 我們只需點選一個按鈕即可實現, 這個按鈕只有在開啟 Histogram 介面後才會顯示出來. 如下圖所示:

    這裡寫圖片描述

    點選上圖中任意一個檔案中用紅框標註的按鈕, 選擇將要與該檔案進行對比的另一個檔案, 然後就會顯示出同一物件在當前檔案和另一檔案中的數量差了. 如果增加了, 就顯示正數, 減少了就顯示負數. 具體操作如下圖所示:

    這裡寫圖片描述

  • 使用Dominator Tree 分析記憶體洩漏

    下面是開啟 Dominator Tree 後的效果圖, 該圖會列舉出該記憶體快照中, 各個物件佔用的 Shallow Heap 和 Retained Heap 的大小以及百分比, 如下圖所示.

    這裡寫圖片描述

    其實, 使用 Dominator Tree 分析記憶體洩漏, 和使用 Histogram 進行分析, 結果是相同的, 只是二者站的角度不同而已. 上文我們介紹過, Dominator Tree 的含義其實就是引用鏈, 這裡的 Dominator Tree 標籤中所展示的是從 GC root 物件開始的一系列引用鏈的列表. 如果某個物件被 GC root 直接引用著, 那麼不僅該物件自身無法被 GC 回收, 而且被該物件強引用的其他物件也將無法回收, 這些物件都會發生洩漏, 那麼由於該物件的原因而造成的記憶體洩漏總量就稱作該物件的 Retained Heap. Retained Heap 數值越大, 就表示因該物件而導致的洩漏量就越大. 所以我們可以將上圖中兩個檔案的 Dominator Tree 列表按照 Retained Heap 從大到小排列. 然後逐一對比, 同樣的物件, 如果在 2.hprof 中的數量要多於 1.hprof, 就表示該物件可能發生了洩漏. 我們之所以按照從大到小的順序排列, 是因為 Retained Heap 數值越大, 就越有可能表示洩漏量越大, 我們總是要優先修復洩漏量最大的那些問題, 這些問題也是影響最大的, 緊急程度最高的. 那麼我們就來張對比圖吧:

    這裡寫圖片描述

    按照 Retained Heap 從大到小的順序排列後, 對比兩個檔案, 發現至少圖中兩個標註了紅框的地方是 2.hprof 檔案比 1.hprof 檔案多出的部分, 而且差異約靠前的地方, 洩漏量就越大. 我們先看看第一個紅框中的物件, 這是圖片載入框架 Glide 的一個 LruResourceCache 物件發生了洩漏. 我們可以對它點右鍵 —> Path To GC Roots —> exclude weak references, 來檢視從 GC root 到該物件除去弱引用後的引用鏈, 如下圖所示:

    這裡寫圖片描述

    原來是被 Glide 框架內部的某個變數引用所導致的. 檢視原始碼:

    這裡寫圖片描述

    原來這個 glide 也是個靜態變數啊, 又是靜態變數造成的記憶體洩漏. 不過這個洩漏不是由我們自己寫的程式碼所造成的, 而是由第三方優秀的圖片載入框架 Glide 的程式碼造成的. 其實當前各個優秀的第三方圖片載入框架都或多或少存在著一些記憶體洩漏問題, 不過洩漏應該都不會太嚴重, 否則就不可能稱作優秀的框架了. 就連 Android SDK 也會存在著一些洩漏. 所以這些洩漏我們先忽略掉, 優先修復由我們自己寫的程式碼造成的洩漏. 所以我們再來看看上圖中另外一個紅框標註的內容, 這是 14 個 Bitmap 物件發生了洩漏. 還是像上邊那樣操作, 對任意一個 Bitmap, 找到除去弱引用後的 Path To GC Roots, 如下:

    這裡寫圖片描述

    原來, 這個 Bitmap 物件就是由我們自己寫的程式碼中的 sContext 變數所一直引用而造成的洩漏. 分析其他 Bitmap 物件的洩漏, 會發現也是由這個sContext 變數所造成的. 每個 Bitmap 的洩漏量佔比 1.26%, 那麼修復了這14個洩漏, 就相當於修復了 17% 的洩漏, 況且, 靠前的幾行還不一定就是洩漏呢. 這樣, 我們就修復了由我們自己程式碼所造成的最嚴重的洩漏問題了.

    另外提示一下, 上圖中有很多 Bitmap 物件洩漏, 有時候我們還想檢視某個 Bitmap 物件到底對應的是哪張圖片, 這又該如何操作呢? 其實這也不難, 只需下載一個名為 GIMP 的軟體即可檢視 (下載地址: http://www.gimp.org/downloads/). 具體的操作步驟如下圖所示, 注意該過程中需要把 Bitmap 物件內的 byte[] 陣列中的資料儲存為 .data 檔案.

    這裡寫圖片描述

5. 關於上述幾種定位記憶體洩漏方法的對比

上文介紹了3種定位記憶體洩漏問題的方法. 通過介紹以及例項演示, 我們應該能對每種方式都有直觀的認識, 那麼我們就總結一下什麼情況下該用哪種方法吧. 總結如下:

  1. 使用 Android Studio 內建的分析工具, 和使用 LeakCanary 庫的方式, 操作都相對較簡單, 但是這2種方式只告訴我們哪些物件發生了洩漏以及造成該洩漏的 GC root 物件, 但卻不會告訴我們其他更詳細的細節資訊. 二者比較起來, LeakCanary 的操作更簡單而且更加智慧, 它能夠直接告訴我們哪個物件發生了洩漏, 而無需我們自己去分析記憶體快照了.
  2. 使用 MAT 進行分析, 能夠分析出更加詳細的細節資訊, 不僅能看到有哪些物件被洩漏了, 還能看到這些物件洩漏的數量. 我們甚至還能結合 GIMP 軟體檢視某個洩漏的 Bitmap 物件對應的圖片內容等, 所以功能顯然是三種分析方法中最強大的, 但是這些強大的功能需要我們事先掌握相關的知識才能合理地運用, 而且 MAT 也不會直接告訴我們哪些物件發生了洩漏, 所以這隻能由我們自己去分析, 過程也相對更加繁瑣, 需要前後匯出兩份 .hprof 檔案並分別轉換為標準格式.

6. 總結

本文講述了記憶體洩漏的概念以及分析定位的幾種常用手段, 至於當我們定位到造成洩漏的根原始碼後, 該如何修改這段程式碼, 才能保證既不影響原有功能, 又能消除記憶體洩漏, 這需要長期實踐經驗的積累和總結. 但是有幾種常見的容易引起記憶體洩漏的不良寫法, 大家可以上網搜尋並留意一下, 然後在今後開發過程中有意識地避免那樣寫就好了. 如果定位出來的問題程式碼塊並沒有這些常見的不良寫法, 那就需要仔細分析業務需求和程式碼本身了, 這種情況通常也需要依賴平時的經驗積累. 所以我們平時遇到難題要多思考, 多交流, 解決問題後也要多總結, 這樣才能不斷積累經驗. 最後提示一句, 本文講解時所用的 demo 程式碼, 可以在這裡下載.

7. 參考資料:

相關推薦

Android 效能優化記憶體洩漏檢測以及記憶體優化(中)

Android 記憶體洩漏檢測   通過上篇部落格我們瞭解了 Android JVM/ART 記憶體的相關知識和洩漏的原因,再來歸類一下記憶體洩漏的源頭,這裡我們簡單將其歸為一下三類:自身編碼引起由專案開發人員自身的編碼造成;第三方程式碼引起這裡的第三

Android 效能優化記憶體洩漏檢測以及記憶體優化(下)

Android 記憶體優化   上篇部落格描述瞭如何檢測和處理記憶體洩漏,這種問題從某種意義上講是由於程式碼的錯誤導致的,但是也有一些是程式碼沒有錯誤,但是我們可以通過很多方式去降低記憶體的佔用,使得應用的整體記憶體處於一個健康的水平,下面總結一下記憶

Android 效能優化記憶體洩漏檢測修復

在 Android 開發中, 記憶體優化是APP效能優化中很重要的一個部分. 而在記憶體優化中, 最重要的就是修復記憶體洩漏問題. 本文就來介紹一下記憶體洩漏的基本概念以及常用的檢測手段. 1. 什麼是記憶體洩漏 簡單來說, 當一個物件不再被使用時,

Android進階——效能優化記憶體洩漏記憶體抖動的檢測優化措施總結(七)

上一篇Android進階——效能優化之記憶體管理機制和垃圾回收機制(六)簡述了Java記憶體管理模型、記憶體分配、記憶體回收的機制

Android 效能優化記憶體檢測、卡頓優化、耗電優化、APK瘦身

導語 自2008年智慧時代開始,Android作業系統一路高歌,10年智慧機發展之路,如今 Android 9.0 代號P  都發布了,Android系統性能已經非常流暢了。但是,到了各大廠商手裡,改原始碼自定系統,使得Android原生系統變得魚龍混雜。另外,到了不同層次的

Android效能優化記憶體

Google近期在Udacity上釋出了Android效能優化的線上課程,分別從渲染,運算與記憶體,電量幾個方面介紹瞭如何去優化效能,這些課程是Google之前在Youtube上釋出的Android效能優化典範專題課程的細化與補充。 下面是記憶體篇章的學習筆記,部分內容與前面的效能優化典範有重合,歡

Android 效能優化記憶體優化

在移動作業系統上,通常實體記憶體有限,儘管 Android 的 Dalvik 虛擬機器扮演了常規的垃圾回收的角色,但這並不意味著我們可以忽略 APP 的記憶體分配與釋放,為了 GC 能夠從 APP 中及時回收記憶體,我們在日常的開發中就需要時刻注意記憶體洩露,並在合適的時候來

[Android 效能優化系列]記憶體基礎篇--Android如何管理記憶體

轉載請標明出處(http://blog.csdn.net/kifile),再次感謝 在接下來的一段時間裡,我會每天翻譯一部分關於效能提升的Android官方文件給大家 下面是本次的正文: ################ 隨機訪問儲存器(Ram) 不管在哪種軟體開發

基於Android Studio的記憶體洩漏檢測解決全攻略

自從Google在2013年釋出了Android Studio後,Android Studio憑藉著自己良好的記憶體優化,酷炫的UI主題,強大的自動補全提示以及Gradle的編譯支援正逐步取代Eclipse,成為主流的Android開發IDE。Android Studio在

Android效能優化handler的正確使用解析

1.什麼是Handler    是Android訊息機制的上層介面,是一種更新ui的機制。   (Android是執行緒不安全的,所以能在子執行緒更新ui,只能執行耗時操作 ,所以要通過handler傳送訊息更新)2.Handler實現原理ThreadLocal:通過不同的執

Android記憶體優化——記憶體洩漏

原文地址:http://blog.csdn.net/ys408973279/article/details/50389200 在Android開發中,我們經常會使用到static來修飾我們的成員變數,其本意是為了讓多個物件共用一份空間,節省記憶體,或者是使用單例模式,

Android記憶體洩漏檢測MAT使用

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

效能優化記憶體優化

效能優化之記憶體優化 計算 APP 獲得的最大記憶體分配值 Runtime rt=Runtime.getRuntime(); long maxMemory=rt.maxMemory(); Log.i("maxMemory:",Long.toString(max

Android——效能優化SparseArray

相信大家都用過HashMap用來存放鍵值對,最近在專案中使用HashMap的時候發現,有時候 IDE 會提示我這裡的HashMap可以用SparseArray或者SparseIntArray等等來代替。 SparseArray(稀疏陣列).它是Android內部特有的api,標準的jdk是沒有這

Android效能優化較精確的獲取影象顯示到螢幕上的時間

轉載自:http://blog.desmondyao.com/android-show-time/ 這兩天我的包工頭歪龍木·靈魂架構師·王半仙·Yrom給我派了一個活:統計App冷啟動時間。這個任務看上去不難,但是要求統計出來的時間要準,要特別準。 意思就是,我必須要按Activity繪製到

安卓專案實戰記憶體洩漏檢測神器LeakCanary

為什麼會產生記憶體洩漏? Java記憶體洩漏指的是程序中某些物件(垃圾物件)已經沒有使用價值了,但有另外一個正在使用的物件持有它的引用,從而導致它不能回收停留在堆記憶體中,這就產生了記憶體洩漏。無用的物件佔據著記憶體空間,使得實際可使用記憶體變小,形象地說法就是記憶體洩漏了。 記憶體

Android效能優化圖片壓縮優化

1 分類Android圖片壓縮結合多種壓縮方式,常用的有尺寸壓縮、質量壓縮、取樣率壓縮以及通過JNI呼叫libjpeg庫來進行壓縮。 參考此方法:Android-BitherCompress 備註:對於資源圖片直接使用:tiny壓縮 2 質量壓縮(1)原理:保持畫素的前提下改變圖片的位深及透明度,(即:通

Android應用優化記憶體概念

導語 現在的Android智慧手機發展資訊萬變,從一開始的HTC到小米價格戰到現在高階市場份額戰,在軟硬體都發生了翻天覆地的變化。在硬體上記憶體從一開始的一兩百M到現在4G。從軟體上我們從一開始為了實現需求而寫程式碼到現在為了程式碼更健壯、更漂亮而進行不斷優化程式碼。這些都是Andr

Android效能優化apk瘦身技巧

隨著專案迭代,新功能的增加。回導致apk越大。那麼在下載安裝過程中。使用者耗費的流量越多。 安裝等待的時間也會越長。這就意味著下載轉化率會越低。那麼如何apk瘦身呢? 理解APK結構 在討論怎麼減小Apk體積之前,理解一個應用的APK結構是非常有幫助的。一個ap

Android效能優化佈局優化

          佈局優化可以通過減少佈局層級來提高,儘量減少使用效能低的佈局,LineaLayout的效率最高,在可以使用LinearLayout或者RelativeLayout時,選擇LinearLayout。因為RelativeLayout測量較為複雜,需要測量水平和