用 LeakCanary 檢測內存泄漏

轉自:用 LeakCanary 檢測內存泄漏

介紹 (0:00)

大家好,我是 Pierre-Yvews Ricau (叫我 PY 就行),現在在 Square 工作。

Square 出了一款名爲:Square Register 的 App, 幫助你用移動設備完成支付。在用這個 App 的時候,用戶先要登陸他的個人賬號。

不幸的是,在簽名頁面有的時候會因爲內存溢出而出現崩潰。老實說,這個崩潰來的太不是時候了 — 用戶和商家都無法確認交易是否完成了,更何況是在和錢打交道的時候。我們也強烈的意識到,我們需要處理下內存溢出或者內存泄露這種事情了。

內存泄漏:非技術講解 (1:40)

我想要聊的內存泄露解決方案是: LeakCanary。 LeakCanary 是一個可以幫助你發現和解決內存泄露的開源工具。但是到底什麼是內存泄露呢?我們從一個非技術角度來開始,先來舉個例子。

假設我的手代表着我們 App 能用的所有內存。我的手裏能放很多東西。比如:鑰匙,Android 玩偶等等。設想我的 Android 玩偶需要揚聲器才能工作,而我的揚聲器也需要依賴 Android 玩偶才能工作,因此他們持有彼此的引用。

我的手裏可以持有如此多的東西。揚聲器依附到 Android 玩偶上會增加總重量,就像引用會佔用內存一樣。一旦我放棄了我的玩偶,把他扔到地上,會有垃圾回收器來回收掉它。一旦所有的東西都進了垃圾桶,我的手又輕便了。

不幸的是,有的時候,一些不好的情況會發生。比如:我的鑰匙沒準和我的 Android 玩偶黏在了一起,阻止我把 Android 玩偶扔到地上。最終的結果就是 Android 玩偶無論如何都不會被回收掉。這就是內存泄露。

有外部的引用(鑰匙,揚聲器)指向了 本不應該再指向的對象(Andorid 玩偶)。類似這樣的小規模的內存泄露堆積以後就會造成大麻煩。

LeakCanary to the Rescue (3:47)

這就是我們爲什麼要開發 LeakCanary。

我現在可能已經清楚了 可被回收的 Android 對象應該及時被銷燬。

但是我還是沒法清楚餓看到這些對象是否已經被回收掉。有了 LeakCanary 以後,我們給可被回收的 Android 對象上打了智能標記。智能標記能知道他們所指向的對象是否被成功釋放掉。如果過一小段時間對象依然沒有被釋放,他就會給內存做個快照。

LeakCanary 隨後會把結果發佈出來,幫助我們看到內存到底怎麼泄露了,清晰的展示無法被釋放的對象的引用鏈。

舉個具體的例子:在我們的 Square App 裏的簽名頁面。用戶準備簽名的時候,App 因爲內存溢出出錯崩潰了。我們不能確認內存錯誤到底出在哪兒了。

簽名頁面持有了一個很大的有用戶簽名的 Bitmap 圖片對象。圖片的大小和用戶手機屏幕大小一致 — 我們猜測這個有可能會造成內存泄露。首先,我們可以配置 Bitmap 爲 alpha 8-bit 來節省內存。這是很常見的一種修復方案,而且效果也不錯。但是並沒有徹底解決問題,只是減少了泄露的內存總量。但是內存泄露依然在哪兒。

最主要的問題是我們 App 的堆滿了,應該要留有足夠的空間給我們的簽名圖片,但是由於很多處的內存泄露疊加在一起佔用了很多內存。

技術講解內存泄漏 (8:06)

假設,我有一個 App,這個 App 點一下就能買一個法棍麪包(哈哈,可能只有法國人需要這樣的App,沒錯,我就是法國人)。

private static Button buyNowButton;

由於某種原因,我把這個 button 設置成了 static 的。問題隨之而來,這個按鈕除非你設置成了null,不然就內存泄露了。

你也許會說:“只是一個按鈕而已,沒啥大不了”。問題是這個按鈕還有一個成員變量:叫 “mContext”,這個東西指向了一個 Acitvity,Acitivty 又指向了一個 Window,Window 有擁有整個 View 繼承樹。算下來,那可是一大段的內存空間。

靜態的變量是 GC root 類型的一種。垃圾回收器會嘗試回收所有非 GC root 的對象,或者某些被 GC root 持有的對象。所以如果你創建一個對象,並且移除了這個對象的所有指向,他就會被回收掉。但是一旦你將一個對象設置成 GC root,那他就不會被回收掉。

當你看到類似“法棍按鈕”的時候,很顯然這個按鈕持有了一個 Activity 的引用,所以我們必須清理掉它。當你沉浸在你的代碼的時候,你肯定很難發現這個問題。你可能只看到了引出的引用。你可以知道 Activity 引用了一個 Window,但是誰引用了 Activity?

你可以用像 IntelliJ 這樣的工具做些分析,但是它並不會告訴你所有的東西。通常,你可以把這些 Object 的引用關係組織成圖,但是是個單向圖。

分析堆 (10:16)

我們能做些什麼呢?我們來做個快照。我們拿出所有的內存然後導出到文件裏,這個文件會被用來分析和解析堆結構。其中一個工具叫做 Memory Analyzer,也叫 MAT。它會通過 dump 的內存,然後分析所有存活在內存中的對象和類。你可以用 SQL 對他做些查詢,類似如下:

SELECT * FROM INSTANCEOF android.app.Activity a WHERE a.mDestroyed = true

這條語句會返回所有的狀態爲 destroyed 的實例。一旦你發現了泄露的 Activity,你可以執行 merge_shortest_paths 的操作來計算出最短的 GC root 路徑。從而找出阻止你 Acitivty 釋放的那個對象。

之所以說要 “最短路徑”,是因爲通常從一個 GC root 到 Acitivty,有很多條路徑可以到達。比如說:我的按鈕的 parent view,同樣也持有一個 mContext 對象。

當我們看到內存泄露的時候,我們通常不需要去查看所有的這些路徑。我們只需要最短的一條。那樣的話,我們就排除了噪音,很快的找到問題所在。

LeakCanary 救你於水火之中 (12:04)

有 MAT 這樣一個幫我們發現內存泄露的工具是個很棒的事情。但是在一個正在運行的 App 的上下文中,我們很難像我們的用戶發現泄露那樣發現問題所在。我們不能要求他們在做一遍相同操作,然後留言描述,再把 70多 MB 的文件發回給我們。我們可以在後臺做這個,但是並不 Cool。我們期望的是,我們能夠儘早的發現泄露。比如在我們開發的時候就發現這些問題。這也是 LeakCanary 誕生的意義。

一個 Activity 有自己生命週期。你瞭解它是如何被創建的,如何被銷燬的,你期望他會在 onDestroy() 函數調用後,回收掉你所有的空閒內存。如果你有一個能夠檢測一個對象是否被正常的回收掉了的工具,那麼你就會很驚訝的喊出:“這個可能造成內存泄露!仍然沒有被垃圾回收掉,它本該被回收掉的!”

Activity 無處不在。很多人都把 Activity 當做神級 Object 一般的存在,因爲它可以操作 Services,文件系統等等。經常會發生對象泄漏的情況,如果泄漏對象還持有 context 對象,那 context 也就跟着泄漏了。

public class MyActivity extends Activity {

  @Override protected void onDestroy() {
    super.onDestroy();
    // instance should be GCed soon.
  }
}
Resources resources = context.getResources();
LayoutInflater inflater = LayoutInflater.from(context);
File filesDir = context.getFilesDir();
InputMethodManager inputMethodManager =
  (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE);

LeakCanary API Walkthrough (13:32)

我們回過頭來再看看智能標記(smart pin),我們希望知道的是當生命後期結束後,發生了什麼。幸運的時,LearkCanary有一個很簡單的 API。

第一步:創建 RefWatcher。給 RefWatcher 傳入一個對象的實例,它會檢測這個對象是否被成功釋放掉。

public class ExampleApplication extends Application {

  public static RefWatcher getRefWatcher(Context context) {
    ExampleApplication application = (exampleApplication) context.getApplicationContest();
    return application.refWatcher;
  }

  private RefWatcher refWatcher;

  @Override public void onCreate () {
    super.onCreate();
    // Using LeakCanary
    refWatcher = LeakCanary.install(this);
  }
}

第二步:監聽 Activity 生命週期。然後,當 onDestroy 被調用的時候,我們傳入 Activity。

public ActivityRefWatcher(Application application, final RefWatcher refWatcher) {
  this.application = checkNotNull(application, "application");
  checkNotNull(refWatcher, "androidLeakWatcher");
  lifecycleCallbacks = new ActivityLifecycleCallbacksAdapter() {
    @Override public void onActivityDestroyed(Activity activity) {
      refWatcher.watch(activity);
    }
  };
}

public void watchActivities() {
  // Make sure you don’t get installed twice.
  stopWatchingActivities();
  application.registerActivityLifecycleCallbacks(lifecycleCallbacks);
}

What are Weak References (14:17)

想要了解這個是怎麼工作的,我得先跟大家聊聊弱引用(weak reference)。我剛纔提到過靜態域的變量會持有Activity 的引用。所以剛纔說的“下單”按鈕就會持有 mContext 對象,導致 Activity 無法被釋放掉。這個被稱作強引用(strong reference)。在垃圾回收過程中,你可以對一個對象有很多的強引用。當這些強引用的個數總和爲零的時候,垃圾回收器就會釋放掉它。

弱引用,就是一種不增加引用總數的持有引用方式。垃圾回收期是否決定要回收一個對象,只取決於它是否還存在強引用。所以說,如果我們將我們的 Activity 持有爲弱引用,一旦我們發現弱引用持有的對象已經被銷燬了,那麼這個 Activity 就已經被垃圾回收器回收了。否則,那可以大概確定這個 Activity 已經被泄露了。

private static Button buyNowButton;

Context mContext;
WeakReference<T>

/** Treated specially by GC. */
T referent;
public class Baguette Activity
  extends Activity {

  @Override protected void onCreate(Bundle state) {
    super.onCreate(state);
    setContentView(R.layout.activity_main);
  }
}

弱引用的主要目的是爲了做 Cache,而且非常有用。主要就是告訴 GC,儘管我持有了這個對象,但是如果一旦沒有對象在用這個對象的時候,GC 就可以在需要的時候銷燬掉。

在下面的例子中,我們繼承了 WeakReference:

final class KeyedWeakReference extends WeakReference<Object> {
  public final String key; // (1) Unique identifier
  public final String name;

  KeyedWeakReference(Object referent, String key, String name, ReferenceQueue<Object> referenceQueue) {
    super(checkNotNull(referent, "referent"), checkNotNull(referenceQueue, "referenceQueue"));
    this.key = checkNotNull(key, "key");
    this.name = checkNotNull(name, "name");
  }
}

你可以看到,我們給弱引用添加了一個 Key,這個 Key 是一個唯一字符串。想法是這樣的:當我們解析一個 heap dump 文件的時候,我們可以詢問所有的 KeyedWeakReference 實例,然後找到對應的 Key。

首先,我們創建一個 weakReference,然後我們寫入『一會兒,我需要檢查弱引用』。(儘管一會兒可能就是幾秒後)。當我們調用 watch 函數的時候,其實就是發生了這些事情。

public void watch(Object watchedReference, String referenceName) {
  checkNotNull(watchedReference, "watchedReference");
  checkNotNull(referenceName, "referenceName");
  if (debuggerControl.isDebuggerAttached()) {
    return;
  }
  final long watchStartNanoTime = System.nanoTime();
  String key = UUID.randomUUID().toString();
  retainedKeys.add(key);
  final KeyedWeakReference reference =
    new KeyedWeakReference(watchedReference, key, referenceName, queue);

  watchExecutor.execute(() → { ensureGone(reference, watchStartNanoTime); });
}

在這一切的背後,我們調用了 System.GC — 免責聲明— 我們本不應該去做這件事情。然而,這是一種告訴垃圾回收器:『Hey,垃圾回收器,現在是一個不錯的清理垃圾的時機。』,然後我們再檢查一遍,如果發現有些對象依然存活着,那麼可能就有問題了。我們就要觸發 heap dump 操作了。

HAHA! (16:55)

親手做 heap dump 是件超酷的事情。當我親手做這些的時候,花了很多時間和功夫。我每次都是做相同的操作:下載 heap dump 文件,在內存分析工具裏打開它,找到實例,然後計算最短路徑。但是我很懶,我根本不想一次次的做這個。(我們都很懶對吧,因爲我們是開發者啊!)

我本可以爲內存分析器寫一個 Eclipse 插件,但是 Eclipse 插件機制太糟糕了。 後來我靈機一動,我其實可以把某個 Eclipse 的插件,移除 UI,利用它的代碼。

HAHA 是一個無UI Android 內存分析器。基本上就是把另一個人寫的代碼重新打包。開始的時候,我就是 fork 了一份別的代碼然後移除了UI部分。兩年前,有人重新fork了我的代碼,然後添加了 Android 支持。又過了兩年,我才發現這個人的倉儲,然後我又重新打包上傳到了 maven center。

我最近根據 Android Studio 修改了代碼實現。代碼還說的過去,還會繼續維護。

LeakCanary 的實現 (19:19)

我們有自己的庫去解析 heap dump 文件,而且實現的很容易。我們打開 heap dump,加載進來,然後解析。然後我們根據 key 找到我們的引用。然後我們根據已有的 Key 去查看擁有的引用。我們拿到實例,然後得到對象圖,再反向推導發現泄漏的引用。

所有的工作實際上都發生在 Android 設備上。當 LeakCanary 探測到一個 Activity 已經被銷燬掉,而沒有被垃圾回收器回收掉的時候,它就會強制導出一份 heap dump 文件存在磁盤上。然後開啓另外一個進程去分析這個文件得到內存泄漏的結果。如果在同一進程做這件事的話,可能會在嘗試分析堆內存結構的時候而發生內存不足的問題。

最後,你會得到一個通知,點擊一下就會展示出詳細的內存泄漏鏈。而且還會展示出內存泄漏的大小,你也會很明確自己解決掉這個內存泄漏後到底能夠解救多少內存出來。

LeakCanary 也是支持 API 的,這樣你就可以掛載內存泄漏的回調,比方說可以把內存泄漏問題傳到服務器上。在 Square 我們用了 Slack 的 API,在測試階段出現內存泄漏的時候,它就會通知我們。

@Override protected void onLeakDetected(HeapDump heapDump, AnalysisResult result) {
  String name = classSimpleName(result.className);
  String title = name + " has leaked";
  slackUploader.uploadHeapDumpBlocking(heapDump.headDumpFile, title, result.leakTrace.toString(),
    MEMORY_LEAK_CHANNEL);
}

用上 API 以後,我們的程序崩潰率降低了 94%!簡直棒呆!

Debug 一個真實的例子 (22:12)

這裏有個例子,是來自 AOSB 的一個內存泄漏的代碼。假設我們一個 App,包含了一個 undobar,你在點擊某些按鈕的時候 undobar 會消失掉。

public class MyActivity extends Activity {

  @Override protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstatnceState);
    setContentView(R.layout.activity_main);

    find ViewbyID(R.id.button).setOnClidkListener(new View.OnClickListener() {
      @Override public void onClick(View v) {
        removeUndoBar();
      }
    });
  }

  private void removeUndoBar() {...}

  private void checkUndoBarGCed(ViewGroup undoBar) {...}
}

我們把所有的 View 都存起來,然後設置了一個 Layout 的 Transition 動畫(1)。我們還給 undobar 增加了一個 View 進去(2)。然後我們從 parent layout 移除了 undobar(3)。

public class MyActivity extends Activity {

  @Override protected void onCreate(Bundle savedInstanceState) {...}

  private void removeUndoBar() {
    ViewGroup rootLayout = (ViewGroup) findViewById(R.id.root);
    ViewGroup undoBar = (ViewGroup) findViewById(R.id.undo_bar);

    undoBar.setLayoutTransition(new LayoutTransition()); // (1)

    // (2)
    View someView = new View(this);
    undoBar.addView(someView);

    rootLayout.removeView(undoBar); // (3)

    checkUndoBarGCed(undoBar);
  }

  private void checkUndoBarGCed(ViewGroup undoBar) {...}
}

現在看來,沒有任何對象指向 undobar 了,所以說它應該被回收掉。否則的話…我們可能就遇到麻煩了。

我們用 LeakCanary 的 API 告訴系統:『Hey,現在該回收掉 undobar 了,幾秒後檢查下 undobar 是否被回收掉了』:

public class MyActivity extends Activity {

  @Override protected void onCreate(Bundle savedInstanceState) {...}

  private void removeUndoBar() {...}

  private void checkUndoBarGCed(ViewGroup undoBar) {
    RefWatcher watcher = MyApplication.from(this).getWatcher();
    watcher.watch(undoBar); // (1)
  }
}

我們刪除了 undobar,但是… LeakCanary 好像不高興了,它發現了內存泄露,返回的報告如下:

static InputMethodManager.sInstance

references
InputMethodManager.mCurRootView

references
PhoneWindow$DecorView.mAttachInfo

references View$AttachInfo.mTreeObserver

references
ViewTreeObserver.mOnPreDrawListeners

references
ViewTreeObserver$CopyOnWriteArray.mData

references LayoutTransition$1.val$parent

leaks FrameLayout instance

不難發現,靜態的InputMethodManager有一個引用指向了當前的 root view:mCurRootView。mCurRootView 是當前窗口所有 View 的父容器。Root view 有一個叫做 TreeObserver 的對象。TreeObserver 是用在佈局改變時候的做回調,通知監聽佈局改變的 listener 的,一個 View 關係樹上只有一個 TreeObserver,所以你會看到它有很多的 PreDrawListener。這就意味着你每添加一個 PreDrawLisener,當佈局改變的時候就會發起一次回調。

到現在爲止看起來沒有什麼異常。但是不難發現某個 PreDrawListener持有一個 LayoutTransition1JavaLayoutTransitionval parent 的變量指向了我們泄露的 undobar。val$parent 意思就是這是在匿名類外聲明的 final 類型的臨時變量,變量名爲 parent。

我們繼續來看:

android.animation.LayoutTransition#runChangeTransition

// This is the cleanup step. When we get this rendering event, we know that all of
// the appropriate animations have been set up and run. Now we can clear out the
// layout listeners.
observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
  public boolean onPreDraw () {
    parent.getViewTreeObserver().removeOnPreDrawListener(this);
    // ... More code
    return true;
  }
});

這是 Android 源碼的一部分 — LayoutTransition 是一個 Android 的類。這裏的 Observer 是 ViewTreeObserver。一切都看起來沒啥問題,註冊了一個 ViewTreeObserver,然後馬上在第一個回調裏解除掉註冊。這個按理說不應該出問題,但是到底發生了什麼?我們來看看 getViewTreeObserver。

public ViewTreeObserver getViewTreeObserver() {
  if (mAttachInfo != null) {
    return mAttachInfo.mTreeObserver; // (1)
  }
  if (mFloatingTreeObserver == null) {
    mFloatingTreeObserver = new ViewTreeObserver();
  }
  return mFloatingTreeObserver; // (2)
}
undoBar.setLayoutTransition(new LayoutTransition()); // (3)

View someView = new View(this);
undoBar.addView(someView); // (4)

rootLayout.removeView(undoBar); // (5)

getViewTreeObserver 函數首先判斷 attached狀態,如果是,則返回一個 view 樹,即 ViewTreeObserver。否則,返回一個臨時的 ViewTreObserver。

問題是如果我的 View 被 detach 掉了,我將會得到一個假的 ViewTreeObserver,而非一個真實的 ViewTreeObserver。你會發現,我們設置了 LayoutTransition(3),然後增加了一個 view (4)。這個觸發了 addOnPreDrawListener 監聽器。然後我們移除掉了 undo-bar,這就意味着 undobar 無法再訪問 ViewTreeObserver了。

final ViewTreeObserver observer = parent.getViewTreeObserver(); // used for later cleanup
if (!observer.isAlive()) {
  // If the observer’s not in a good state, skip the transition
  return;

}

public void onAnimationEnd(Animator animator) {
...
// layout listeners.

observer.addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
  public boolean onPreDraw() {
    observer.removeOnPreDrawListener(this);
    parent.getViewTreeObserver().removeOnPreDrawListener(this);

它得到了一個假的 ViewTreeObserver,並且無法移除自己,因爲他並不在這個假的 ViewTreeObserver 裏。

這個是 Android 4年前的一次代碼修改留下的問題,當時是爲了修復另一個 bug,然而帶來了無法避免的內存泄漏。我們也不知道何時能被修復。

忽略 Android SDK Crashes (28:10)

通常來說,總是有些內存泄漏是你無法修復的。我們某些時候需要忽略掉這些無法修復的內存泄漏提醒。在 LeakCanary 裏,有內置的方法去忽略無法修復的問題。

LAYOUT_TRANSITION(SDK_INT >= ICE_CREAM_SANDWICH && SDK_INT <= LOLLIPOP_MR1) {
  @Override void add (ExcludedRefs.Builder excluded) {
    // LayoutTransition leaks parent ViewGroup through ViewTreeObserver.OnPreDrawListener
    // When triggered, this leak stays until the window is destroyed.
    // Tracked here: https://code.google.com/p/android/issues/detail?id=171830
    excluded.instanceField("android.animation.LayoutTransition$1", "val$parent");
  }
}

我想要重申一下,LeakCanary 只是一個開發工具。不要將它用到生產環境中。一旦有內存泄漏,就會展示一個通知給用戶,這一定不是用戶想看到的。

我們即便用上了 LeakCanary 依然有內存溢出的錯誤出現。我們的內存泄露依然有多個。有沒有辦法改變這些呢?

LeakCanary 的未來 (29:14)

public class OomExceptionHandler implements Thread.UncaughtExceptionHandler {
  private final Thread.UncaughtExceptionHandler defaultHandler;
  private final Context context;

  public OomExceptionHandler(Thread.UncaughtExceptionHandler defaultHandler, Context context) {...}

  @Override public void UncaughtException(Thread thread, Throwable ex) {
    if (containsOom(ex)) {
      File heapDumpFile = new File(context.getFilesDir(), "out-of-memory.hprof");
      try {
        Debug.dumpHprofData(heapDumpFile.getAbsolutePath());
      } catch (Throwable ignored) {
      }
    }
    defaultHandler.uncaughtException(thread, ex);
  }

  private boolean containsOom(Throwable ex) {...}
}

這是一個 Thread.UncaughtExceptionHandler,你可以將線程崩潰委託給它,它會導出 heap dump 文件,並且在另一個進程裏分析內存泄漏情況。

有了這個以後,我們就能做一些好玩兒的事情了,比如:列出所有的應該被銷燬卻依然在內存裏存活的 Activity,然後列出所有的 Detached View。我們可以依此來爲泄漏的內存按重要性排序。

我實際上已經有一個很簡單的 Demo 了,是我在飛機上寫的。還沒有發佈,因爲還有些問題,最嚴重的問題是沒有足夠的內存去解析 heap dump 文件。想要修復這個問題,得想想別的辦法。比如採用 stream 的方法去加載文件等等。

Q&A (31:50)

Q: LeakCanary 能用於 Kotlin 開發的 App?

PY: 我不知道,但是應該是可以的,畢竟到最後他們都是字節碼,而且 Kotlin 也有引用。

Q:你們是在 Debug 版本一直開啓 LeakCanary 麼?還是隻在最後的某些版本開啓做做測試

PY: 不同的人有不同的方法,我們通常是一直都開着的。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章