Android 内存泄漏 - 做一个有“洁癖”的开发者
本文主要介绍以下两个主题:
内存泄露的检测方法:通过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的原理:
- 监听Activity生命周期,当onDestroy被调用时,调用RefWatcher.watch(activity)检查泄漏。
- RefWatcher.watch() 会创建一个 KeyedWeakReference 到要被监控的对象。
KeyedWeakReference是WeakReference的子类,只不过附加了一个key和name作为成员变量,方便后续查找这个KeyedWeakReference对象。这一步创建KeyedWeakReference时使用了WeakReference的一个构造方法WeakReference(T referent, ReferenceQueue<? super T> q)
,这个构造方法很关键,下一步会用到。 - 然后在后台线程检查引用是否被清除,如果没有,调用GC。
究竟如何检查?这就要得益于上一步构造KeyedWeakReference对象时传入的ReferenceQueue了,关于这个类,有兴趣的可以直接看Reference的源码。我们这里需要知道的是,每次WeakReference所指向的对象被GC后,这个弱引用都会被放入这个与之相关联的ReferenceQueue队列中。所以此时我们去检查ReferenceQueue,如果其中没有这个KeyedWeakReference,那么它所指向的这个对象很可能存在泄漏,不过为了防止误报,LeakCanary会进行二次GC确认,也就是主动触发一次GC。 - 如果引用还是未被清除,把 heap 内存 dump 到 APP 对应的文件系统中的一个 .hprof 文件中。
- 在另外一个进程中的 HeapAnalyzerService 有一个 HeapAnalyzer 使用HAHA 解析这个文件。
- 得益于唯一的 reference key, HeapAnalyzer 找到 KeyedWeakReference,定位内存泄漏。
- HeapAnalyzer 计算 到 GC roots 的最短强引用路径,并确定是否是泄漏。如果是的话,建立导致泄漏的引用链。
- 引用链传递到 APP 进程中的 DisplayLeakService, 并以通知的形式展示出来。
更详细的LeakCanary源码分析,可以参考以下博客:
当然LeakCanary还有更多高级用法,比如可以添加忽略(一些第三方库甚至android sdk本身的泄漏你可能无法解决,但又不想LC总是报警)、可以定制ReferenceWatcher以监控特定的类等等,这些可以参考其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中无法包含一个对象在何时、何地(哪个方法中)被分配这样的信息。
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();
}
总结:如何避免写出内存泄漏的代码
- 谨慎使用static关键字,尤其不要用static修饰Activity context;
- 注意不要让类变量直接或间接地持有Activity context引用;
- 尽量不要在单例中使用Activity context,如果要用,不能将其作为全局变量;
- 时刻注意内部类(尤其是Activity的内部类)的生命周期,尽量使用静态内部类代替内部类,如果内部类需要访问外部类的成员,可以用“静态内部类+弱引用”代替;内部类的生命周期不应该超出外部类,外部类结束前,应该及时结束内部类生命周期(停止线程、AsyncTask、TimerTask、Handler消息等,移除类变量或长生命周期的线程对Callback、listener等的强引用);
- 及时注销广播以及一些系统服务的监听器;
- 属性动画在Activity销毁前记得cancel;
- 文件流、Cursor等资源用完及时关闭;
- Activity销毁前WebView的移除和销毁;
- 使用别人的方法(尤其是第三方库),遇到需要传递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