4年前 (2021-01-08)  jvm |   抢沙发  1017 
文章评分 0 次,平均分 0.0

我们都曾被内存泄漏所困扰,在某些时候,甚至在生产过程中,内存泄漏会导致OutOfMemoryError崩溃。Square的Pierre-Yves-Ricau通过构建LeakCanary解决了这个问题,LeakCanary是一个在内存泄漏发布之前检测并帮助您修复内存泄漏的工具。在Droidcon 2015纽约演讲中,Pierre教授了一些技巧,可以显著减少OutOfMemoryError崩溃,并轻松修复引用泄漏,使应用程序更稳定、更可用。

内存泄漏:非技术性解释

我想谈谈解决内存泄漏的方法:LeakCanary。LeakCanary是一个开源库,可以帮助阻止内存泄漏。但究竟什么是内存泄漏?让我们从一个非技术性的示例开始。

想象一下,我张开的手代表应用程序可用的内存量。我的手可以拿很多东西-钥匙,安卓的收藏品,等等。想象一下,我的Android收藏品需要Square阅读器才能正常工作。另外,为了进行类比,假设Square Readers必须通过一些细线程连接到Android collectible,其中线程表示引用。

我的手只能承受这么大的重量。Android收藏品附带的方形阅读器增加了总重量,就像引用占用内存一样。一旦我完成了Android收藏品,我就可以把它扔在地板上和垃圾收集器上过来取的。不知怎么的,所有的东西都进了垃圾桶,我的手又轻了。

不幸的是,有时会有不好的事情发生。我的钥匙可能会附在我的安卓收藏品上,以防止它被扔到地板上。因此,Android collectible永远不会被垃圾收集。那就是内存泄漏

有额外的引用(键、方形读取器)指向一个对象(Android collectible),而该对象不应再指向该对象。像这样的小内存泄漏会累积并成为一个大问题。这些细线增加了我手的重量,直到它们变得太重,我的手再也抓不住了。

LeakCanary排查内存泄露

例如,我知道这些Android收藏品需要很快销毁和垃圾收集,但我无法确定它们是否被垃圾收集。使用LeakCanary,我们将智能pin连接到我的Android收藏品之类的东西上。智能pin知道它们是否已经被垃圾收集,过了一段时间,如果智能pin意识到它仍然不在垃圾箱中,它就会拍摄内存的照片——一个堆转储到dump文件系统中。

LeakCanary然后发布结果,以便我们可以看到内存泄漏。我们可以查看将对象保存在内存中并防止其被垃圾收集的引用链。

举一个具体的例子,以我们在Square Reader应用程序上的签名屏幕为例。客户去签名,但应用程序崩溃,由于内存不足的错误。我们不能马上确定内存错误的原因。

屏幕是一个巨大的位图bitmap,上面有客户的签名。位图是屏幕的大小-它可能导致内存泄漏,因此我们尝试排除故障。首先,我们可以将位图的配置切换到alpha-8以节省内存。这是一个常见的修复方法,可以正常工作,但它不能解决问题;它只会减少泄漏的内存总量。内存泄漏仍然存在。

实际的问题是我们的堆快满了。我们的签名位图应该有足够的空间,但是没有,因为所有这些小漏洞加起来占用了所有的内存。

内存泄漏:技术说明

想象一下,我有一个应用程序,让你买一个法式面包在一个点击。(我想只有法国人会需要这个应用程序,但是嘿,我是法国人!)

private static Button buyNowButton;

不管出于什么原因,我把我的按钮放在一个静态字段中。除非我把这个字段设置为空,否则它永远不会被收集。

你可以说,“这只是一个小按钮,谁在乎呢?“问题是,我的按钮有一个名为mContext的字段,它是对活动的引用,而活动又是对窗口的引用,窗口有整个视图层次结构,包括内存中一个巨大的面包图片。总的来说,即使在活动被销毁之后,静态按钮仍会占用数兆字节的空间。

静态字段称为GC路由。垃圾收集器尝试收集所有不是GC路由或通过GC路由引用保存的内容。因此,如果您创建一个对象并删除对它的所有引用,它将被垃圾收集,但是如果您将它像静态字段一样放入GC路由中,它将不会被垃圾收集。

当你看到类似这个长棍面包按钮的东西时,很明显这个按钮引用了活动,所以我们需要清除它。但是,当您在代码中时,您并不知道这一点;您只看到向前的引用。您可以知道活动引用了窗口,但是谁引用了活动?

你可以用像IntelliJ这样的东西来做聪明的事情,但它不会告诉你一切。基本上,您可以将对象及其引用看作一个图,但它是一个有向图:它只朝一个方向流动。

分析堆

我们该怎么办?我们拍照。我们把所有的内存转储到一个dump文件中,然后用一个工具打开这个文件来分析和解析堆转储。其中一个工具是内存分析器,也称为MAT。它基本上拥有当您进行堆转储时内存中的所有活动对象和类。它有一种称为SQL的查询语言,因此您可以编写如下内容:

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

这将给你所有的实例销毁是真的。一旦找到泄漏的活动,就可以执行称为merge_shortest_path的操作,该操作计算到GC路由的最短路径。这将找到阻止您的活动被垃圾收集的对象的反向路径。

我之所以特别提到“最短路径”,是因为从许多GC路由到任何活动或对象都有多条路径。例如,“我的按钮”的父视图,其中还包含对mContext字段的引用。

当我们查看内存泄漏时,我们不需要查看所有路径;我们只需要最短的路径。这样,噪音更小,更容易发现问题。

LeakCanary定位内存泄露

很好,我们有一个工具来发现漏洞,但在一个实时应用程序的上下文中,我们不能很好地为我们的用户找到漏洞。我们不能要求他们做AGB,做所有这些评论,然后给我们回一个70MB的文件。我们可以在后台做(有些人做),但那不酷。相反,在开发应用程序时,我们可以关注如何在开发过程中发现漏洞。那就是LeakCanary进来的地方。

一个活动有一个生命周期:你知道它什么时候被创建,什么时候被销毁,并且你期望在onDestroy()被调用之后,它很快就会被垃圾收集。如果你有一种方法来检测它是否真的被垃圾收集了,那么你就有一个触发器可以喊“嘿!这东西可能漏了!垃圾还没收呢!”

活动也很好,因为它到处都在使用。每个人都使用它,因为它是一个可以访问大量服务和文件系统的上帝对象。如果某个对象正在泄漏,则该对象很有可能引用了上下文,从而泄漏了上下文。

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 演练

回到我们的智能pin,我们想知道生命周期何时结束,并跟踪从那时起会发生什么。幸运的是,LeakCanary有一个简单的API。

第一步:构建一个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);
  }
}

第二步:倾听活动生命周期。然后,当调用onDestroy()时,我们传递活动并将其扩展到此refWatcher。

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);
}

什么是Weak References弱引用

为了解释这一切是如何运作的,我必须谈谈弱引用。我提到了一个静态字段,它保存了对baguette活动的引用;“Buy Now”按钮有一个mContext字段,它保存了对活动的引用。这被称为强引用。在垃圾收集中,您可以有任意多个对对象的强引用,当该数目为零时(即没有人声明引用),垃圾收集器就可以声明它。

弱引用是在不增加对该对象的引用数的情况下访问该对象的一种方法。如果该对象不再有强引用,弱引用也将被清除。因此,如果我们将活动设为弱引用,并且在某个时刻我们意识到弱引用已被清除,则意味着该活动已被垃圾收集。然而,如果它不被清除,我们很可能有一个泄漏,值得调查。

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);
  }
}

弱引用的主要用途是缓存,它们非常有用。它们是告诉GC在内存中保留某些内容的一种方法,但是如果没有其他人在使用它们,它可以清除它们。

在我们的示例中,我们扩展了弱引用:

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");
  }
}

您可以看到,我们为弱引用(1)添加了一个键。那个键将是一个唯一的字符串。其思想是,在解析堆转储时,我们可以请求keyedwakreference的所有实例,然后找到具有相应密钥的实例。

首先,我们创建weakReference,然后写“稍后,我需要检查弱引用”(尽管“稍后”实际上只是几秒钟)。这就是我们叫守望的时候发生的事。

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); });
}

在引擎盖下,我们正在做系统.CG,然而,这是一种说“嘿垃圾收集器,现在是清除所有引用的好时机”的方式,然后我们再次检查。如果它仍然没有被清除,我们可能有问题,所以我们触发堆转储。

我们能用垃圾堆做什么真是太神奇了!当我最初处理这些问题时,花了很多时间和精力。每次我都在做同样的事情:下载堆转储dump文件,在内存分析器MAT中打开它,找到实例,并计算最短传递。但我很懒,我不想每次都这么做。(我们都很懒,对吧?我们是开发者!)

LeakCanary实现

我们有我们的库来解析堆转储,幸运的是实现它非常容易。我们打开堆转储,加载它,解析它,然后根据我们拥有的密钥找到我们的引用。我们得到那个实例,然后我们只需要解析对象图,向后工作,并找到泄漏的引用。

所有这些工作实际上都是在Android设备上进行的。当LeakCanary检测到某个活动已被销毁但尚未被垃圾收集时,它将强制在文件系统上进行堆转储。然后,它在一个单独的进程中启动一个服务,该进程将分析堆转储并发布结果。如果您在同一进程中,您可能会在尝试分析堆转储时耗尽内存。这是一种奇怪的递归情况。

最后,您会收到一个通知,可以单击该通知来显示泄漏的详细引用链。它还显示内存泄漏的大小,这样您就可以知道如果修复了泄漏,可以回收多少内存。

API也是可扩展的,因此可以有钩子和回调,这意味着您可以将所有这些信息上传到服务器。对于Square,我们只是使用slack api将其上传到Slack通道,因为这只在开发和测试期间使用。

@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非常简单。我使用Refrofit创建了一个界面并添加了一堆注释。在我们的Slack通道中,我们得到了所有关于内存泄漏的信息。

这个进程的崩溃率比我们低了94%!效果很好。

调试真实世界的示例

下面是我们在Android源代码AOSB中发现的内存泄漏的一个例子。假设我们有一个带有撤销条的应用程序。我们有一个活动,在某个时候你点击一个按钮,你想删除撤销栏。

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) {...}
}

我们得到了所有的视图,为了好玩,我们设置了一个布局转换(1)。我们还向撤销栏添加了一个视图,主要是因为我们可以(2)。然后,我们从其父布局(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) {...}
}

此时,不再有任何内容引用undo栏,因此应该很快对其进行垃圾收集。如果没有,那我们就有麻烦了。

我们使用LeakCanary API说,“嘿,这个撤销条应该很快就会被垃圾收集,请在几秒钟内检查(1)。”所以我们这样做,我们运行应用程序:

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)
  }
}

我们移除了撤销栏,然后…哦,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有一个对当前根视图mCurRootView的静态引用,该视图当前处于活动状态,包含所有内容。但是,根视图有一个名为TreeObserver的东西,它是一个可以向其中添加侦听器的对象,因此可以对视图层次结构中的更改做出反应,每个视图层次结构都是唯一的。因此,TreeObserver有一堆预先绘制的侦听器。这意味着您添加了一个PreDrawListener,然后将在绘制视图层次结构之前被回调。

到目前为止,这看起来还不错,但是您可以看到ViewTreeObserver有一堆预先绘制的侦听器。其中一个predrawListenerLayoutTransition$1,这是一种编写匿名类名称的Java方法。这意味着这是LayoutTransition类中的第一个defineAnonymous类。然后您会看到它有一个名为val$parent的字段,它引用了我们泄漏的撤销栏。val$parent基本上是匿名类(称为parent)之外的最终局部变量。这就是编译器如何将语法糖更改为与Java1兼容的东西。

让我们看看发生了什么:

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类。这里的观察者是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);

getViewTreeObvserver返回视图层次结构ViewTreeObserver(如果已附加)(1)。如果视图未附加,它将返回一个临时ViewTreeObvserver,如果视图重新附加,它将把其中的所有内容合并到实时ViewTreeObserver中(2)。

问题是如果我的视图被分离,我会得到一个假的ViewTreeObserver,而不是一个实时的ViewTreeObserver。如果您注意到,(3)我们设置了LayoutTransition,然后添加了一个视图(4)。这将在布局转换中触发addOnPreDrawListener。现在,我们已经在ViewTreeObserver上注册了PreDrawListener。但是,(5)然后我们移除了undo条,这意味着undo条被分离,不再具有对ViewTreeObserve的访问权限。

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,它不能从中删除自己,因为它不在那个假的ViewTreeObserver中,它在真实的ViewTreeObserver中。

这一改变是四年前为了防止一个不同的错误而做出的,但是它导致了不可避免的内存泄漏。它已经在Android中修复了,但是我们不知道它什么时候可以使用。我真的很想如何绕过这个内存泄漏,但没有办法绕过它。就在那里。

忽略Android SDK崩溃

一般来说,有些内存泄漏我们无法修复。为了我们的目的,我们不想看到这些。在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: http://code.google.com/p/android/issues/detail?id=171830
    excluded.instanceField("android.animation.LayoutTransition$1", "val$parent");
  }
}

我想重申,这只是一个开发工具-您不想在生产中发布它。它显示了LeakCanary的大图,然后它显示了一个通知。你的用户不想看到。LeakCanary最擅长的是尽早检测内存泄漏。

我们仍然会出现“内存不足”的错误。即使这样,我可以告诉你,我们的泄漏数量仍然大于零。我们还能做些什么来改变这一切吗?

LeakCanary的未来

如果我们不是在开发过程中进行所有的泄密调查,而是先发布一个应用程序呢?然后,当出现崩溃时,我们可以查看它并使用相同的过程进行分析:获取堆转储并在单独的过程中开始分析它。在那一点上,我们不再寻找一个泄漏。我们实际上是在寻找发生了什么的线索。

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. 你可以设置,委托给默认的一个,这将崩溃的应用程序。但在此之前,您可以进行堆转储,然后启动另一个进程。

有了它,我们可以做一些事情,比如列出所有被破坏的活动,然后找出它们为什么仍在内存中,然后列出所有分离的视图。因为我们知道存储在内存中的所有东西的大小,所以我们可以用这个来划分优先级,然后说,“嘿,这个比那个更重要。”你可以想象一个像Crashlytics这样的工具对这类东西有一个扩展。

问答

Q: 你知道LeakCanary是否能与基于Kotlin的应用程序协同工作吗?

我不知道,但我不明白为什么不。最后,都是字节码,Kotlin也有引用。你应该能让它和金丝雀一起工作。

Q: 您是否在调试构建时一直激活LeakCanary,或者更确切地说,您是否为某些构建启用LeakCanary只是为了测试它?

不同的人采取不同的方法。我们要做的就是一直启用它。获取堆转储的问题是它会冻结VM。当你尝试QA应用程序时,中间有漏洞,你想知道漏洞,有时你想一个人呆着。禁用它非常容易,事实上我们在一些构建中也这么做。一般来说,一旦你开始提供禁用它的功能,人们就会禁用它。很快,没人再看它了,然后它就没用了。我认为这是一种平衡。我们尽我们所能去做,修复重要的漏洞。

 

除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/1246.html

关于

发表评论

表情 格式

暂无评论

登录

忘记密码 ?

切换登录

注册