記憶體洩漏與排查流程——安卓效能優化

猛猛的小盆友發表於2019-01-07

前言

記憶體洩漏可以說是安卓開發中常遇到的問題,追溯和排查其問題根源是進階的程式猿必須具備的一項技能。小盆友今天便與大家分享一下這方面的一些見解,如有理解錯誤或是不同見解,可以於評論區留言我們進行討論,如果喜歡給個贊鼓勵下吧。

篇幅較長,可以通過目錄尋找自己所需瞭解的吧

目錄

1、JAVA記憶體解析
2、JAVA回收機制
3、四種引用
4、小結
5、安卓記憶體洩漏排查工具
6、記憶體洩漏檢查與解決流程
7、常見的記憶體洩漏原因

1、JAVA記憶體解析

要想知道記憶體洩漏,需要先了解java中執行時記憶體是怎麼構成的,才能知道是哪個地方導致。話不多說,先上圖

java記憶體模型
執行時的java記憶體分為兩大塊:執行緒私有(藍色區域)、共享資料區(黃色區域)
執行緒私有:主要用於儲存各個執行緒私有的一些資訊,包括:程式計數器、虛擬機器棧、本地方法棧
共享資料區:主要用於儲存公用的一些資訊,包括:方法區(內含常量池)、堆

  1. 程式計數器:讓程式中各個執行緒知道自己接下來需要執行哪一行。在java中多執行緒為搶佔式(因為cpu在某一時刻只會執行一條執行緒),當執行緒切換時,需要繼續哪一行便由程式計數器告知。

    舉個例子:A、B兩條執行緒,此時CPU執行從A切換至B,過了段時間從B切換回A,此時A需要從上次暫停的地方繼續執行,此時從哪一行執行就是由程式計數器來提供。

    值得一提
    (1)若執行java函式時,程式計數器記錄的是虛擬機器位元組碼的地址;
    (2)若執行native方法時,程式計數器便置為了null。
    (3)在java虛擬機器規範中,程式計數器是唯一沒有定義OutOfMemoryError。

  2. 虛擬機器棧:描述的是java方法的記憶體模型,平時說的“棧”其實就是虛擬機器棧,其生命週期與執行緒相同。每個方法(不包含native方法)執行的同時都會建立一個棧幀用於儲存區域性變數表、運算元棧、動態連結、方法出口等資訊。

    值得一提:在java虛擬機器規範中,此處定義了兩個異常
    (1)StackOverFlowError (在遞迴中常看到,遞迴層級過深)
    (2)OutOfMemoryError

  3. 本地方法棧:是為虛擬機器使用到的Native方法提供記憶體空間。 有些虛擬機器的實現直接把本地方法棧和虛擬機器棧合二為一,比如主流的HotSpot虛擬機器。

    值得一提:在java虛擬機器規範中,此處定義了兩個異常
    (1)StackOverFlowError (在遞迴中常看到,遞迴層級過深)
    (2)OutOfMemoryError

  4. 方法區:主要儲存已載入是類資訊(由ClassLoader載入)、常量、靜態變數、編譯後的程式碼的一些資訊。 GC在這裡比較少出現在這塊區域。

  5. 堆:存放的是幾乎所有的物件例項和陣列資料。 是虛擬機器管理的最大的一塊記憶體,是GC的主戰場,所以也叫“GC堆”、“垃圾堆” 。

    值得一提:在java虛擬機器規範中,此處定義了一個異常
    (1)OutOfMemoryError

  6. 執行時常量池:屬於“方法區”的一部分,用於存放編譯器生成的各種字面量和符號引用。
    字面量:與Java語言層面的常量概念相近,包含文字字串、宣告為final的常量值等。
    符號引用:編譯語言層面的概念,包括以下3類:
    (1) 類和介面的全限定名
    (2)欄位的名稱和描述符
    (3)方法的名稱和描述符

2、JAVA回收機制

java中是通過GC(Garbage Collection)來進行回收記憶體,那jvm是如何確定一個物件能否被回收的呢?這裡就需講到其回收使用的演算法

(1) 引用計數演算法

引用計數是垃圾收集器中的早期策略。在這種方法中,堆中每個物件例項都有一個引用計數。當一個物件被建立時,且將該物件例項分配給一個變數,該變數計數設定為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
複製程式碼

(2) 可達性分析演算法

可達性分析演算法

可達性分析演算法是現在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來介紹,稍安勿躁。

下載地址:www.eclipse.org/mat/downloa…

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 的使用要尤為小心,否則將很容易導致記憶體洩露的發生。

public class MainActivity extends AppCompatActivity {
    private Handler mHandler = new Handler(){
        @Override
        public void handleMessage(Message msg) {
            //do something
        }
    };
    private void loadData(){
        //do request
        Message message = Message.obtain();
        mHandler.sendMessage(message);
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        loadData();
    }
}
複製程式碼

這種建立Handler的方式會造成記憶體洩漏,由於mHandler是Handler的非靜態匿名內部類的例項,所以它持有外部類Activity的引用,我們知道訊息佇列是在一個Looper執行緒中不斷輪詢處理訊息,那麼當這個Activity退出時訊息佇列中還有未處理的訊息或者正在處理訊息,而訊息佇列中的Message持有mHandler例項的引用,mHandler又持有Activity的引用,所以導致該Activity的記憶體資源無法及時回收,引發記憶體洩漏,所以另外一種做法為:

public class MainActivity extends AppCompatActivity {
    private MyHandler mHandler = new MyHandler(this);
    private void loadData() {
        //do request
        Message message = Message.obtain();
        mHandler.sendMessage(message);
    }
    private static class MyHandler extends Handler {
        private WeakReference<Context> reference;
        public MyHandler(Context context) {
            reference = new WeakReference<Context>(context);
        }
        @Override
        public void handleMessage(Message msg) {
            super.handleMessage(msg);
            MainActivity mainActivity = (MainActivity) reference.get();
            if (mainActivity != null) {
                //do something to update UI via mainActivity
            }
        }
    }
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        loadData();
    }
}
複製程式碼

建立一個靜態Handler內部類,然後對Handler持有的物件使用弱引用,這樣在回收時也可以回收Handler持有的物件,這樣雖然避免了Activity洩漏,不過Looper執行緒的訊息佇列中還是可能會有待處理的訊息,所以我們在Activity的Destroy時或者Stop時應該移除訊息佇列中的訊息,

@Override
protected void onDestroy() {
    super.onDestroy();
    mHandler.removeCallbacksAndMessages(null);
}
複製程式碼

使用mHandler.removeCallbacksAndMessages(null);是移除訊息佇列中所有訊息和所有的Runnable。當然也可以使用mHandler.removeCallbacks();或mHandler.removeMessages();來移除指定的Runnable和Message。

(5)Thread

和handler一樣,執行緒也是造成記憶體洩露的一個重要的源頭。執行緒產生記憶體洩露的主要原因在於執行緒生命週期的不可控。比如執行緒是 Activity 的內部類,則執行緒物件中儲存了 Activity 的一個引用,當執行緒的 run 函式耗時較長沒有結束時,執行緒物件是不會被銷燬的,因此它所引用的老的 Activity 也不會被銷燬,因此就出現了記憶體洩露的問題。

(6)系統bug

比如InputMethodManager,會持有activity而沒釋放,導致洩漏,需要通過反射進行打斷。

如果需要更多的交流與探討,可以通過以下微信二維碼加小盆友好友。

記憶體洩漏與排查流程——安卓效能優化

相關文章