今天看啥  ›  专栏  ›  Android开发实践

Android 内存泄漏 - 做一个有“洁癖”的开发者

Android开发实践  · 掘金  ·  · 2018-11-14 02:34

本文主要介绍以下两个主题:

内存泄露的检测方法:通过LeakCanary&MAT检测应用中潜在的内存泄漏。
内存泄露的解决方法:常见内存泄漏场景以及解决方案,如何避免写出泄漏的代码。

篇幅较长,各位可以根据自己的需求选择阅读。

你应该管理好应用的内存


Random-access memory(随机存取存储器RAM)在任何软件开发环境中都是宝贵的资源,而对于物理内存经常受到限制的移动操作系统来说,它就更具价值了。 尽管Android Runtime(ART)和Dalvik虚拟机都会执行常规的垃圾收集(GC),但这并不意味着你可以忽略你的应用分配和释放内存的时间和位置。你仍然需要避免引入内存泄漏。

内存溢出(OOM)和内存泄漏(Leak)


内存溢出(OutOfMemoryError)

为了允许多进程,Android为每个应用程序分配的堆大小设置了硬性限制。 确切的堆大小限制根据设备有多少内存总量而有所不同。 如果你的应用程序使用的内存已达到该限制并尝试分配更多内存时,系统就会抛出OutOfMemoryError。

内存泄漏(Memory Leak)

是指应用在申请内存后,无法释放已申请的内存空间,是对内存资源的浪费。最坏的情况下,内存泄漏会最终导致内存溢出。

内存泄漏的危害


一次内存泄漏危害并不大,但也不能放任不管,最坏的情况下,你的 APP 可能会由于大量的内存泄漏而内存耗尽,进而闪退,但它并不总是这样。相反,内存泄漏会消耗大量的内存,但却不至于内存耗尽,这时,APP 会由于内存不够分配而频繁触发GC。而GC是非常耗时的操作,会导致严重的卡顿。另外,当你的应用处于LRU列表中(即切换到后台,变为后台进程)时,由于内存泄漏而消耗了更多内存,当系统资源不足而需要回收一部分缓存进程时,你的应用被系统杀死的可能性就更大了。

Tips:应用进程在整个LRU列表中消耗的内存越少,保留在列表中并且能够快速恢复的机会就越大。

LeakCanary


LeakCanary是大家所熟知的内存泄漏检测工具,它简单易用,集成以后能在应用发生泄漏时发出警告,并显示发生泄漏的堆栈信息,新版本还会显示具体泄漏的内存大小,作为被动监控泄漏的工具非常有效,但LeakCanary功能有限,不能提供更详细的内存快照数据,并且需要嵌入到工程中,会在一定程度上污染代码,所以一般都只在build version中集成,release version中则应该去掉。

本文的重点并不是LeakCanary,所以这里不做详细讲述,但仍然强烈推荐大家看看以下博客,这是LeakCanary的研发人员写的LeakCanary的由来,并简单诙谐的道出了LeakCanary的实现原理:

用 LeakCanary 检测内存泄漏

LeakCanary的原理

虽然本文重点不是LeakCanary,但是笔者还是很好奇它是如何工作的。在此,我们简单概括一下LeakCanary的原理:

  1. 监听Activity生命周期,当onDestroy被调用时,调用RefWatcher.watch(activity)检查泄漏。
  2. RefWatcher.watch() 会创建一个 KeyedWeakReference 到要被监控的对象。
    KeyedWeakReference是WeakReference的子类,只不过附加了一个key和name作为成员变量,方便后续查找这个KeyedWeakReference对象。这一步创建KeyedWeakReference时使用了WeakReference的一个构造方法WeakReference(T referent, ReferenceQueue<? super T> q),这个构造方法很关键,下一步会用到。
  3. 然后在后台线程检查引用是否被清除,如果没有,调用GC。
    究竟如何检查?这就要得益于上一步构造KeyedWeakReference对象时传入的ReferenceQueue了,关于这个类,有兴趣的可以直接看Reference的源码。我们这里需要知道的是,每次WeakReference所指向的对象被GC后,这个弱引用都会被放入这个与之相关联的ReferenceQueue队列中。所以此时我们去检查ReferenceQueue,如果其中没有这个KeyedWeakReference,那么它所指向的这个对象很可能存在泄漏,不过为了防止误报,LeakCanary会进行二次GC确认,也就是主动触发一次GC。
  4. 如果引用还是未被清除,把 heap 内存 dump 到 APP 对应的文件系统中的一个 .hprof 文件中。
  5. 在另外一个进程中的 HeapAnalyzerService 有一个 HeapAnalyzer 使用HAHA 解析这个文件。
  6. 得益于唯一的 reference key, HeapAnalyzer 找到 KeyedWeakReference,定位内存泄漏。
  7. HeapAnalyzer 计算 到 GC roots 的最短强引用路径,并确定是否是泄漏。如果是的话,建立导致泄漏的引用链。
  8. 引用链传递到 APP 进程中的 DisplayLeakService, 并以通知的形式展示出来。

更详细的LeakCanary源码分析,可以参考以下博客:

LeakCanary 内存泄漏监测原理研究

当然LeakCanary还有更多高级用法,比如可以添加忽略(一些第三方库甚至android sdk本身的泄漏你可能无法解决,但又不想LC总是报警)、可以定制ReferenceWatcher以监控特定的类等等,这些可以参考其GitHub文档:

LeakCanary on GitHub

笔者习惯使用LeakCanary作为监控工具,再结合MAT作为分析工具。MAT相对LeakCanary功能更加强大,当然用法也更复杂一些,它能提供详尽的内存分析数据,并且不需要嵌入工程中。下面就来介绍一下MAT的使用方法。

MAT简介


MAT(Memory Analyzer Tool),一个基于Eclipse的内存分析工具,是一个快速、功能丰富的JAVA heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗。使用内存分析工具从众多的对象中进行分析,快速的计算出在内存中对象的占用大小,看看是谁阻止了垃圾收集器的回收工作,并可以通过报表直观的查看到可能造成这种结果的对象。

除了Eclipse插件版,MAT也有独立的不依赖Eclipse的版本,只不过这个版本在调试Android内存的时候,需要将DDMS生成的文件进行转换,才可以在独立版本的MAT上打开。因为DDMS生成的是Android格式的HPROF(堆转储)文件,而MAT只能识别JAVA格式的HPROF文件。不过Android SDK中已经提供了这个Tools,所以使用起来也是很方便的。

要调试内存,首先需要获取HPROF文件,HPROF文件存储的是特定时间点,java进程的内存快照。有不同的格式来存储这些数据,总的来说包含了快照被触发时java对象和类在heap中的情况。由于快照只是一瞬间的事情,所以heap dump中无法包含一个对象在何时、何地(哪个方法中)被分配这样的信息。

官方文档
Basic Tutorial

MAT中一些概念介绍


要看懂MAT的列表信息,Shallow heap、Retained Heap、GC Root这几个概念一定要弄懂。

Shallow heap

Shallow size就是对象本身占用内存的大小,不包含其引用的对象。

  • 常规对象(非数组)的Shallow size有其成员变量的数量和类型决定。
  • 数组的shallow size有数组元素的类型(对象类型、基本类型)和数组长度决定。

因为不像c++的对象本身可以存放大量内存,java的对象成员都是些引用。真正的内存都在堆上,看起来是一堆原生的byte[], char[], int[],所以我们如果只看对象本身的内存,那么数量都很小。所以我们看到Histogram图是以Shallow size进行排序的,排在第一位第二位的一般是byte,char 。

Retained Set

Retained Set是指当一个对象X被GC时,会因为X的释放而同时被GC掉的所有对象的集合。

Retained Heap

Retained Heap则表示一个对象X的Retained Set中所有对象的Shallow Size的总和。换句话说,Retained Heap就表示如果一个对象被释放掉,那会因此而被释放的总的heap大小。于是,如果一个对象的某个成员new了一大块int数组,那这个int数组也可以计算到这个对象中。相对于shallow heap,Retained heap可以更精确的反映一个对象实际占用的大小(因为如果该对象释放,retained heap都可以被释放)。

这里要说一下的是,Retained Heap并不总是那么有效。例如我在A里new了一块内存,赋值给A的一个成员变量。此时我让B也指向这块内存。此时,因为A和B都引用到这块内存,所以A释放时,该内存不会被释放。所以这块内存不会被计算到A或者B的Retained Heap中。为了纠正这点,MAT中的Leading Object(例如A或者B)不一定只是一个对象,也可以是多个对象(Leading Set)。此时,(A, B)这个组合的Retained Set就包含那块大内存了。

很显然,从上面的对象引用图计算Retained Memory并不那么直观高效。比如A和B的Retained Memory只有它们自身,而E的Retained Memory则是E和G,G的Retained Memory也只是它自身。为了更直观的计算Retained Memory,MAT引入了Dominator(统治者) Tree的概念。

Dominator Tree

在Dominator Tree中,有下面一些非正式的定义:

  • 在对象图中,若每一条从开始节点(或根节点)到节点y的路径都必须经过节点x,那么节点x就dominates节点y。
  • 在Dominator Tree中,每一个节点都是其子节点的直接Dominator。

Dominator Tree还有以下重要属性:

  • x的sub-tree就代表了x的retained set。
  • 如果x是y的直接dominator,那么x的直接dominator同样dominates y,以此类推。
  • Dominator Tree的边缘并不直接对应于对象引用树中的引用关系。比如在引用图中,C是A和B的子节点,而在Dominator Tree中,三者却是平级的。

对应到MAT UI上,在dominator tree这个view中,显示了每个对象的shallow heap和retained heap。而点击右键,可以List objects中选择with outgoing references和with incoming references。这是个真正的引用图的概念,

  • outgoing references :表示该对象的出节点(被该对象引用的对象)。
  • incoming references :表示该对象的入节点(引用到该对象的对象)。

GC Roots

首先要说一下GC的原则:

垃圾回收器会尝试回收所有非GC roots的对象。所以如果你创建一个对象,并且移除了这个对象的所有指向,它就会被回收掉。但是如果你将一个对象设置成GC roots,那它就不会被回收掉。那么GC又如何判断某个对象是否可以被回收呢?在垃圾回收过程中,当一个对象到GC Roots 没有任何引用链(或者说,从GC Roots 到这个对象不可达)时,垃圾回收器就会释放掉它

而GC Roots是一些由虚拟机自身保持存活的对象。比如运行中的线程、当前处于调用栈中的对象、由system class loader加载的类等等。

反过来,从一个对象到一个GC Roots的引用链(path to GC Root),就解释了为什么这个对象无法被GC。这个path就可以帮助我们解决典型的内存泄漏。

一个gc root就是一个对象,这个对象从堆外可以访问读取。以下一些方法可以使一个对象成为gc root:

  • System class:被Bootstrap或者system class loader加载的类,比如位于rt.jar里的所有类(如java.util.*);
  • JNI local:native代码里的local变量,比如用户定义的JNI代码和JVM的内部代码;
  • JNI global:native代码里的global变量,比如用户定义的JNI代码和JVM的内部代码;
  • Thread block:当前活跃的线程block中引用的对象;
  • Thread:已经启动并且没有stop的线程;
  • busy monitor:调用了wait()或者notify()或者被同步的对象,比如调用了synchronized(Object) 或使用了synchronized方法。静态方法指的是类,非静态方法指的是对象;
  • java local:local变量,比如仍然存在于线程栈中的方法的入参和方法内创建的对象;
  • native stack:native代码里的出入参数,比如file/net/IO方法以及反射的参数;
  • finalizable:在一个队列里等待它的finalizer 运行的对象;
  • unfinalized:一个有finalize方法的对象,还没有被finalize,同时也没有进入finalizer队列等待finalize;
  • unreachable:被MAT标记为root,并且无法通过任何其他root到达的对象,这个root的作用是retain那些不这么做就无法包含在分析中的objects;
  • java stack frame:一个持有本地变量的java栈帧。只有在dump被解析且在preferences里设置把栈帧当做对象对待时才会产生;
  • unknown:未知root类型的对象。

Java的引用级别

从最强到最弱,不同的引用(可到达性)级别反映了对象的生命周期。

  • 强引用:通过 new 关键字创建出来的对象引用都是强引用,只有去掉强引用,对象才会被回收。请记住,JVM宁可抛出OOM也不会去回收一个有强引用的对象
  • 软引用:只要有足够的内存,就一直保持对象,直到发现一次GC后内存仍然不够,系统会在将要发生OOM之前针对此类对象进行二次回收。如果此次回收还没有足够的内存,才会抛出OOM。一般可用来实现缓存,通过java.lang.ref.SoftReference类实现。
  • 弱引用:比Soft Ref更弱,被弱引用关联的对象只能生存到下一次GC发生之前。在GC执行时,无论当前内存是否足够,都会立刻回收只被弱引用关联的对象。通过java.lang.ref.WeakReference和java.util.WeakHashMap类实现。
  • 虚引用:也成为幽灵引用或幻影引用。虚引用完全不会影响对象的生存时间,你只能使用Phantom Ref本身,而无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被GC时收到一个系统通知。一般用于在进入finalize()方法后进行特殊的清理过程,通过 java.lang.ref.PhantomReference实现。

获取HPROF(堆转储)文件


HPROF(堆转储)文件可以使用DDMS导出,在Android Studio中选择“Tools → Android → Android Device Monitor”(为方便使用,可以将该按钮固定在AS工具栏面板中),DDMS中在Devices上面有一排按钮,选择一个进程后(即在Devices下面列出的列表中选择你要调试的应用程序的包名),点击Dump HPROF file 按钮:

选择存储路径保存后就可以得到对应进程的HPROF文件。不过该文件是Android格式的,你可以直接拖入AS中进行浏览,但功能有限,若要做更深入的内存分析,一般要用专门的分析工具,比如Eclipse的MAT或者Oracle的jhat(jdk6以后提供的)。MAT有两个版本:Eclipse插件版和客户端版,插件版可以把上面的工作一键完成。只需要点击Dump HPROF file图标,然后MAT插件就会自动转换格式,并且在eclipse中打开分析结果。eclipse中还专门有个Memory Analysis视图 ,得到对应的文件后,如果安装了Eclipse插件,那么切换到Memory Analyzer视图。

使用独立安装的MAT,则须使用Android SDK自带的工具(hprof-conv 位置在sdk/platform-tools/hprof-conv)将上述Android格式的HPROF文件转换为java格式的HPROF文件:

hprof-conv [-z] com.test.myproject.hprof com.test.myproject_conv.hprof
-z:排除非APP泄漏的干扰,比如zygote

(Windows系统可能需要进入上述路径找到hprof-conv.exe安装一次)
转换过后的.hprof文件即可使用MAT工具打开了。

Tips:堆转储文件的导出和格式转换工作,Android Studio也可以帮我们完成,在AS3.0以前是Android Monitor(Logcat窗口旁边的tab页),而AS3.0以上版本则是Android Profiler,并且它们也都可以做一些内存分析。

MAT一般使用步骤


1.打开经过转换的hprof文件:

如果选择了第一个,则会生成一个报告。这个无大碍,是工具帮你分析的有泄漏嫌疑的对象,可以作为一个快速参考。

2.选择OverView界面:

上方的“Unreachable Objects Histogram”指的是当前可以被GC的对象,只是由于系统还未触发GC,所以仍然存活于heap中,这个一般不需要关心。

常用的是Histogram和Dominator Tree。

Histogram

列出每个类分配了多少个实例,以及实例的大小。

排在前两位的基本是byte[]和char[],一般不需要理会。Histogram视图中,默认不显示Retained Heap,如果想查看Retained Heap大小,可以点击工具栏中按钮:

为了方便查看,快速找到自己的类的问题,可以“Group by package”:

Dominator Tree

列出最大的对象以及其依赖存活的Object (大小是以Retained Heap为标准排序的),多了一列Percentage。

同样也可以“Group by package”

我们看到MobUIShell本身占用了408Byte的内存,并且如果它被回收,将释放出201840Byte的内存空间。

右键选择Merge Shortest Paths to GC Roots - exclude weak/soft references,就可以分析出其对象引用关系,为什么选择exclude weak/soft references呢?因为通常情况下weak/soft references不会导致内存泄漏。

可以看到,SizeHelper泄漏了MobUIShell的一个实例,这个实例本身占用了200Byte的内存,而它同时还持有了201160Byte的其他对象的内存,换句话说,一旦它被清理了,就可以释放200K左右的内存。

其实这和LeakCanary报告的结果是一致的:

至此,我们知道了SizeHelper泄漏了MobUIShell,接下来当然就是要分析为什么会泄漏,以及如何解决泄漏,首先看看SizeHelper.java的内容:

public class SizeHelper {
    public static float designedDensity = 1.5f;
    public static int designedScreenWidth = 540;
    private static Context context = null;
    
    protected static SizeHelper helper;
    
    private SizeHelper() {
    }
    
    public static void prepare(Context c) {
        if(context == null || context != c.getApplicationContext()) {
            context = c;
        }
    }
    
    public static int fromPx(int px) {
        return ResHelper.designToDevice(context, designedDensity, px);
    }
    
    public static int fromPxWidth(int px) {
        return ResHelper.designToDevice(context, designedScreenWidth, px);
    }
    
    public static int fromDp(int dp) {
        int px = ResHelper.dipToPx(context, dp);
        return ResHelper.designToDevice(context, designedDensity, px);
    }
}

上述代码是一种最常见也最简单的泄漏 - 由静态变量引起的泄漏。静态全局变量context:

private static Context context = null;

如何解决此处的泄漏?三种方案:

方案一:不改变代码,使用时prepare方法中传入Application Context而非Activity Context,虽然可以避免泄漏,但无法保证其他人在使用SizeHelper时不会传入Activity Context,这里就有泄漏隐患。所以不可取。要从根本上杜绝泄漏隐患,就必须重构代码。

方案二:重构代码,强制让SizeHelper仅持有ApplicationContext的实例。

public static void prepare(Context c) {
        if(context == null || context != c.getApplicationContext()) {
    // 强制从Context获取ApplicationContext,让SizeHelper持有ApplicationContext的实例
            context = c.getApplicationContext();
        }
    }

该方案仍然允许你使用static关键字修饰context,虽然context仍然无法被GC,但它本身所持有的只是ApplicationContext实例,而非Activity Context实例,所以并不会造成某个Activity的泄漏。但个别情况下无法使用该方案,比如只能使用Activity Context(Dialog相关时就不能使用ApplicationContext)时,此时只能另辟蹊径,比如方案三。

方案三:重构代码,不再持有Context实例。

public class SizeHelper {
    public static final float designedDensity = 1.5f;
    public static final int designedScreenWidth = 540;

    public static int fromPx(Context context, int px) {
        return ResHelper.designToDevice(context, designedDensity, px);
    }

    public static int fromPxWidth(Context context, int px) {
        return ResHelper.designToDevice(context, designedScreenWidth, px);
    }

    public static int fromDp(Context context, int dp) {
        int px = ResHelper.dipToPx(context, dp);
        return ResHelper.designToDevice(context, designedDensity, px);
    }
}

Context不再被设置为静态全局变量,而是作为方法内的局部变量被使用,这样看起来,SizeHelper本身已经不可能再发生泄漏了,但它实际上是调用ResHelper的方法,所以我们还要确认一下ResHelper会不会造成泄漏。下面是ResHelper的部分代码:

public class ResHelper {
    private static float density;
    private static int deviceWidth;
    private static Object rp;
    private static Uri mediaUri;

    public ResHelper() {
    }
    ...
    public static int designToDevice(Context context, float designScreenDensity, int designPx) {
        if(density <= 0.0F) {
            density = context.getResources().getDisplayMetrics().density;
        }
        return (int)((float)designPx * density / designScreenDensity + 0.5F);
    }
    ...
}

ResHelper就是一个普通的类,提供了一系列静态方法,并且它没有把Context保存成静态全局变量,所以它并不会造成context对象的泄漏。

好了,我们再运行一次看看MAT的分析结果:

我们看到MobUIShell的泄漏明显降低了,现在它的Retained Heap只有680Byte,内存百分比也已经降到了0.01%,被释放的内存为201840Byte-680Byte=201160Byte(这和前面图表中MAT侦测到此处泄漏时所报告的数据完全一致),虽然没有完全解决,但剩下这一部分明显是Android SDK的InputMethodManager造成的,已经和SizeHelper没有关系了。

还有一个更快捷的对比方式,在Histogram页面,点击“Compare to another Heap Dump”按钮,可以选择与之前的Heap Dump做对比,我们看到MobUIShell的Shallow Heap相比于修改之前,减少了200Byte,而整个应用的内存泄漏,减少了213224Byte(200K左右)。说明一点,下图是在修改后的Heap Dump中选择与修改前的Heap Dump做对比,所以显示为“-200”。如果在修改前的Heap Dump中选择与修改后的Heap Dump做对比,就会显示为“200”,意思是修改前的相比修改后的多了200Byte的内存。


还可以通过immediate dominator找到责任对象,对于快速定位一组对象的持有者非常有用,这个操作直接解决了“谁让这些对象alive”的问题,而不是“谁有这些对象的引用”的问题,更直接高效。

常见的内存泄漏及解决方案


通过以上章节的介绍,我们了解到了如何使用MAT分析内存泄露问题,本章节主要介绍常见的几种内存泄漏和解决方案。在这之前,让我们再多了解一下Android中的内存泄漏。

传统的内存泄漏是由忘记释放分配的内存导致的,比如用完Stream或者DB Connection以后忘记close,而逻辑上的内存泄漏(Logical Leak)则是由于忘记在对象不再被使用的时候释放对它的引用导致的。如果一个对象仍然存在强引用,垃圾回收器就无法对其进行回收。在安卓平台,泄漏 Context 对象问题尤其严重。这是因为Acitivity指向Window,而Window又拥有整个View继承树,除此之外,Activity还可能引用其他占用大量内存的资源(比如Bitmap)。如果 Context 对象发生了内存泄漏,那它引用的所有对象都被泄漏了。

如果一个对象的合理生命周期没有清晰的定义,那判断逻辑上的内存泄漏将是一个见仁见智的问题。幸运的是,activity 有清晰的生命周期定义,使得我们可以很明确地判断 activity 对象是否被内存泄漏。onDestroy() 函数将在 activity 被销毁时调用,无论是程序员主动销毁 activity,还是系统为了回收内存而将其销毁。如果 onDestroy 执行完毕之后,activity 对象仍被 heap root 强引用,那垃圾回收器就无法将其回收。所以我们可以把生命周期结束之后仍被引用的 activity 定义为被泄漏的 activity。

Activity 是非常重量级的对象,所以我们应该极力避免妨碍系统对其进行回收。然而有多种方式会让我们无意间就泄露了 activity 对象。我们把可能导致 activity 泄漏的情况分为两大类,一类是使用了进程全局(process-global)的静态变量,无论 APP 处于什么状态,都会一直存在,它们持有了对 activity 的强引用进而导致内存泄漏,另一类是生命周期长于 activity 的线程,它们忘记释放对 activity 的强引用进而导致内存泄漏。下面我们就来详细分析一下这些可能导致 activity 泄漏的情况。

Tips:为什么说静态变量会导致泄漏呢?这要从java基础说起,static修饰的变量称为静态变量,又称类变量,从命名就能看出类变量的生命周期是绑定在类对象(class)上的,而非某个具体的实例,而类对象的生命周期是从被类加载器加载一直到应用结束为止,几乎就等于应用的生命周期。所以一旦某个静态变量持有了Activity的强引用,那么就会造成泄漏。

静态Activity

private static Context context;
proteced void onCreate(Bundle savedInstanceState) {
    this.context = this;
}

尽量避免使用static关键字修饰context,如果一定要用,就必须保证context只能是ApplicationContext,不能是Activity Context。也就是要结合以下代码:

this.context = context.getApplicationContext();

或者在Activity生命周期结束前,清除这个引用:

protected void onDestroy() {
    this.context = null;
}

静态View

有时候我们可能有一个创建起来非常耗时的 View,在同一个 activity 不同的生命周期中都保持不变,所以让我们为它实现一个单例模式。

private static View view;

void setStaticView() {
  view = findViewById(R.id.sv_button);
}

View svButton = findViewById(R.id.sv_button);
svButton.setOnClickListener(new View.OnClickListener() {
  @Override public void onClick(View v) {
    setStaticView();
    nextActivity();
  }
});

你又泄漏了Activity!因为一旦 view 被加入到界面中,它就会持有 context 的强引用,也就是我们的 activity。由于我们通过一个静态成员引用了这个 view,所以我们也就引用了 activity,因此 activity 就发生了泄漏。所以一定不要把加载的 view 赋值给静态变量,如果你真的需要,那一定要确保在 activity 销毁之前将其从 view 层级中移除。

void removeView (View view)

单例

单纯的单例模式并没有什么问题,但如果在单例模式中,将一个context对象作为全局变量,就会造成泄漏。

class Singleton {
    private static Singleton instance;
    private Context context;

    private Singleton(Context context) {
        this.context = context;
    }

    public static Singleton getInstance(Context context) {
        if (instance == null) {
            instance = new Singleton(context);
        }
        return instance;
    }
}

上述代码是一个线程不安全的单例模式,但不影响我们分析单例导致的泄漏。

同样地,要么保证context只能是ApplicationContext,要么不要将context写成全局变量。

可以改造一下构造方法:

private Singleton(Context context) {
    this.context = context.getApplicationContext();
}

当然了,如果这个单例是和Dialog有关的,那么就无法使用ApplicationContext,此时就只能重构代码,不将context写成全局变量了。

非静态内部类

我们在编程时经常会用到内部类,这样做的原因有很多,比如增加封装性和可读性。如果我们创建了一个内部类的对象,并且通过静态变量持有了该内部类对象的引用,那也会发生 activity 泄漏。

private boolean b = false;
    private static InnerClass inner;

    void createInnerClass() {
        inner = new InnerClass();
    }

    class InnerClass {
        private boolean bool;
        
        public InnerClass() {
            this.bool = b;
        }
    }

内部类的一大优势就是能够直接引用外部类的成员,这是通过隐式地持有外部类的引用来实现的,而这又恰恰是造成 activity 泄漏的原因。

可见,在使用非静态内部类时,一定要注意引用的生命周期,避免内部类的生命周期超出外部类,这样引用就没有问题了:

private InnerClass inner;

但是在实际开发中,我们仍然要尽量避免使用非静态内部类,而要改用静态内部类,因为静态内部类并不会持有外部类的引用,也就不会泄漏外部类了,但相对的,静态内部类无法访问外部类的成员。如果你的代码结构必须访问外部类的成员,那么请使用静态内部类+弱引用,让静态内部类持有外部类的弱引用,既不会造成泄漏,又能解决访问外部类的成员变量的问题。

private boolean b = false;
    private static InnerClass inner;

    void createInnerClass() {
        inner = new InnerClass(this);
    }

    static class InnerClass {   // 静态内部类
        private boolean bool;
        private WeakReference<MainActivity> activityWeakReference;  // 外部类的弱引用

        public InnerClass(MainActivity activity) {
            activityWeakReference = new WeakReference<>(activity);
            this.bool = activityWeakReference.get().b;  // 如此访问外部类的成员
        }
    }

匿名内部类

匿名内部类和非静态内部类导致内存泄露的原理一样,因为匿名内部类也同样隐式持有外部类的引用。在Android开发中有一种典型的场景就是使用Handler,很多开发者在使用Handler时是这样写的:

public class MainActivity extends AppCompatActivity {
    
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        start();
    }

    private void start() {
        Message msg = Message.obtain();
        msg.what = 1;
        mHandler.sendMessage(msg);
    }

    private Handler mHandler = new Handler() {
        @Override
        public void handleMessage(Message msg) {
            if (msg.what == 1) {
                // 做相应逻辑
            }
        }
    };
}

看起来并没有问题,mHandler并未作为静态变量持有Activity引用,生命周期可能不会比Activity长,应该不会导致内存泄露啊,显然不是这样的!

这要从Handler消息机制说起,mHandler会作为成员变量保存在发送的消息msg中,即msg持有mHandler的引用,而mHandler是Activity的非静态内部类实例,即mHandler持有Activity的引用,那么我们就可以理解为msg间接持有Activity的引用。msg被发送后先放到消息队列MessageQueue中,然后等待Looper的轮询处理(MessageQueue和Looper都是与线程相关联的,MessageQueue是Looper引用的成员变量,而Looper是保存在ThreadLocal中的)。那么当Activity退出后,msg可能仍然存在于消息队列MessageQueue中未处理或者正在处理,那么这样就会导致Activity无法被回收。

套用前面说的“静态内部类+弱引用”的方法,重构代码:

public class MainActivity extends AppCompatActivity {

    private Handler mHandler;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        mHandler = new MyHandler(this);
        start();
    }

    private void start() {
        Message msg = Message.obtain();
        msg.what = 1;
        mHandler.sendMessage(msg);
    }

    private static class MyHandler extends Handler {

        private WeakReference<MainActivity> activityWeakReference;

        public MyHandler(MainActivity activity) {
            activityWeakReference = new WeakReference<>(activity);
        }

        @Override
        public void handleMessage(Message msg) {
            MainActivity activity = activityWeakReference.get();
            if (activity != null) {
                if (msg.what == 1) {
                    // 做相应逻辑
                }
            }
        }
    }
}

mHandler通过弱引用的方式持有Activity,当GC执行垃圾回收时,遇到Activity就会回收并释放所占据的内存单元。这样就不会发生内存泄露了。

上面的做法确实避免了Activity的泄露,发送的msg不再持有Activity的强引用了,但是msg还是有可能存在消息队列MessageQueue中,所以更好的是在Activity销毁时就将mHandler的回调和发送的消息给移除掉。

@Override
protected void onDestroy() {
    super.onDestroy();
    mHandler.removeCallbacksAndMessages(null);
}

让我们再来看一个很常见的场景:用Handler实现的计时器。比如发送短信验证码后一般会有个1分钟的倒计时才能重新发送,如果没有在适当的时候主动关闭计时器,而该计时器又正好间接持有了activity context的引用,那么在计时器结束之前就会将该activity泄漏。

以下是短信GUI中使用Handler实现的倒数计时器:

private void countDown() {
        runOnUIThread(new Runnable() {
            public void run() {
                time--;
                setResendText(time);
                if (time <= 0) {
                    time = 60;
                } else {
                    runOnUIThread(this, 1000);
                }
            }
        }, 1000);
    }

其中的runOnUIThread是位于FakeActivity类中的方法:

public void runOnUIThread(final Runnable r, long delayMillis) {
        UIHandler.sendEmptyMessageDelayed(0, delayMillis, new Callback() {
            public boolean handleMessage(Message msg) {
                r.run();
                return false;
            }
        });
    }

这里参数中传入的Callback其实就是一个FakeActivity的匿名内部类,它持有外部类FakeActivity的强引用,而FakeActivity又持有着实际的Activity context的强引用,于是在计时器停止前,当前页面会被泄漏。解决该问题的方法就是在离开当前页面时主动停止计时器:

@Override
    public void onDestroy() {
        super.onDestroy();
        // 离开该页面前停止读秒计时器
        stopCountDown();
    }
    private void stopCountDown() {
        time = 1;
    }

需要注意的是,不止是onDestroy方法中要停止计时器,同时输入正确验证码后跳转下一个页面时也要停止(此时并不会调用onDestroy哦)!

匿名内部类造成泄漏的场景还有很多,比如在Activity中定义一个匿名的AsyncTask,如果Activity结束时没有正确的结束AsyncTask,那么就会妨碍GC对Activity的回收,直到AsyncTask执行结束才能回收。同样地,通过匿名内部类创建的Thread和TimerTask,也很可能因为没有正确的结束而泄漏Activity。另外,常用的listener和callback对象等(无论是通过内部类实现还是通过让Activity直接implements Callback实现)都有可能泄漏Activity,这些Callback的实例很可能会通过多次引用传递最终被某个类的类变量(比如某个单例)或者某个生命周期较长的线程所持有,最终导致Activity被泄漏。我们的AsyncImageView中就发生了这样的情况,AsyncImageView是我们自定义的View,它本身持有activity context,为了处理图片,其内部通过匿名内部类创建了一个Callback对象传给BitmapProcess类以接收图片处理结果,而BitmapProcess中又经过几次传递,最终将Callback对象保存在一个静态的ArrayList对象中。为了解决这个问题,必须在Callback使用结束后显示的清除对它的引用(设置为null)。

SensorManager以及广播接收器

系统服务可以通过 context.getSystemService 获取,它们负责执行某些后台任务,或者为硬件访问提供接口。如果 context 对象想要在服务内部的事件发生时被通知,那就需要把自己注册到服务的监听器中。然而,这会让服务持有 activity 的引用,如果程序员忘记在 activity 销毁时取消注册,那就会导致 activity 泄漏了。

void registerListener() {
       SensorManager sensorManager = (SensorManager) getSystemService(SENSOR_SERVICE);
       Sensor sensor = sensorManager.getDefaultSensor(Sensor.TYPE_ALL);
       sensorManager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_FASTEST);
}

View smButton = findViewById(R.id.sm_button);
smButton.setOnClickListener(new View.OnClickListener() {
    @Override public void onClick(View v) {
        registerListener();
        nextActivity();
    }
});

注册广播也是同理,如果在Activity销毁时忘记注销广播接收器,也会导致Activity的泄漏。

集合中的对象未清理造成内存泄露

这个比较好理解,如果一个对象放入到ArrayList、HashMap等集合中,这个集合就会持有该对象的引用。当我们不再需要这个对象时,也并没有将它从集合中移除,这样只要集合还在使用(而此对象已经无用了),这个对象就造成了内存泄露。并且如果集合被静态引用的话,集合里面那些没有用的对象更会造成内存泄露了。所以在使用集合时要及时将不用的对象从集合remove,或者clear集合,以避免内存泄漏。

资源未关闭或释放导致内存泄露

在使用IO、File流或者Sqlite、Cursor等资源时要及时关闭。这些资源在进行读写操作时通常都使用了缓冲,如果不及时关闭,这些缓冲对象就会一直被占用而得不到释放,以致发生内存泄露。

属性动画造成内存泄露

动画同样是一个耗时任务,比如在Activity中启动了属性动画(ObjectAnimator),但是在销毁的时候,没有调用cancle方法,虽然我们看不到动画了,但是这个动画依然会不断地播放下去,动画引用所在的控件,所在的控件引用Activity,这就造成Activity无法正常释放。因此同样要在Activity销毁的时候cancel掉属性动画,避免发生内存泄漏。

@Override
protected void onDestroy() {
    super.onDestroy();
    mAnimator.cancel();
}

WebView造成内存泄露

关于WebView的内存泄露,因为WebView在加载网页后会长期占用内存而不能被释放,因此我们在Activity销毁后要调用它的destroy()方法来销毁它以释放内存。

另外在查阅WebView内存泄露相关资料时看到这种情况:

Webview下面的Callback持有Activity引用,造成Webview内存无法释放,即使是调用了Webview.destory()等方法都无法解决问题(Android5.1之后)。

最终的解决方案是:在销毁WebView之前需要先将WebView从父容器中移除,然后再销毁WebView。详细分析过程请参考这篇文章:
WebView内存泄漏解决方法

@Override
protected void onDestroy() {
    super.onDestroy();
    // 先从父控件中移除WebView
    mWebViewContainer.removeView(mWebView);
    mWebView.stopLoading();
    mWebView.getSettings().setJavaScriptEnabled(false);
    mWebView.clearHistory();
    mWebView.removeAllViews();
    mWebView.destroy();
}

总结:如何避免写出内存泄漏的代码


  1. 谨慎使用static关键字,尤其不要用static修饰Activity context;
  2. 注意不要让类变量直接或间接地持有Activity context引用;
  3. 尽量不要在单例中使用Activity context,如果要用,不能将其作为全局变量;
  4. 时刻注意内部类(尤其是Activity的内部类)的生命周期,尽量使用静态内部类代替内部类,如果内部类需要访问外部类的成员,可以用“静态内部类+弱引用”代替;内部类的生命周期不应该超出外部类,外部类结束前,应该及时结束内部类生命周期(停止线程、AsyncTask、TimerTask、Handler消息等,移除类变量或长生命周期的线程对Callback、listener等的强引用);
  5. 及时注销广播以及一些系统服务的监听器;
  6. 属性动画在Activity销毁前记得cancel;
  7. 文件流、Cursor等资源用完及时关闭;
  8. Activity销毁前WebView的移除和销毁;
  9. 使用别人的方法(尤其是第三方库),遇到需要传递context时尽量使用ApplicationContext,而不要轻易使用Activity context,因为你不知道别人的代码内部会不会造成该context的泄漏。比如微信支付SDK就有泄漏的隐患,微信支付初始化时需要传入context,最终由WXApiImpl这个类持有了context,如果你传入的是activity context,就会被WXApiImpl泄漏。

知识点梳理


1.GC如何判断某个对象是否可以被回收:

在垃圾回收过程中,当指向某个对象的强引用的个数总和为零(也就是不存在强引用)时,垃圾回收器就会释放掉它。

2.Java的引用级别:

强引用 - 软引用 - 弱引用 - 虚引用

3.JVM宁可抛出OOM也不会去回收一个有强引用的对象
4.GC Root:

有多种方法使得一个对象成为GC Root,GC Root是由虚拟机自身保持存活的对象,所以它不会被回收,由GC Root强引用的对象也无法被回收。

5.内部类和静态内部类:

内部类的一大优势就是可以直接引用外部类的成员,这是通过隐式地持有外部类的引用来实现的;而静态内部类,由于不再隐式地持有外部类的引用,也就无法直接引用外部类的成员了。

6.如何避免内部类造成的泄漏:

为避免内部类泄漏外部类,应该使用静态内部类。但静态内部类又无法访问外部类的成员,为解决该问题,可以使用“静态内部类+弱引用”,让静态内部类持有外部类的弱引用,既不会造成泄漏,又能解决访问外部类的成员变量的问题。

7.LeakCanary如何检查是否存在内存泄漏:

WeakReference + ReferenceQueue

参考文献


本文是笔者做了大量参考学习,并结合自身实践总结得出,特此感谢:
Eight Ways Your Android App Can Leak Memory
Android内存优化——常见内存泄露及优化方案
LeakCanary on GitHub
用LeakCanary检测内存泄露
Overview of memory management
Vidoe:Memory management on Android Apps




原文地址:访问原文地址
快照地址: 访问文章快照