美文网首页Android技术知识Android必知必会Android开发经验谈
Android 内存泄漏 - 做一个有“洁癖”的开发者

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

作者: 未子涵 | 来源:发表于2018-09-14 10:54 被阅读52次

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

    内存泄露的检测方法:通过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

    相关文章

      网友评论

        本文标题:Android 内存泄漏 - 做一个有“洁癖”的开发者

        本文链接:https://www.haomeiwen.com/subject/vpongftx.html