Android 内存剖析 – 发现潜在问题
简介
移动平台上的开发和内存管理紧密相关。尽管随着科技的进步,现今移动设备上的内存大小已经达到了低端桌面设备的水平,但是现今开发的应用程序对内存的需求也在同步增长。主要问题出在设备的屏幕尺寸上-分辨率越高需要的内存越多。熟悉Android平台的开发人员一般都知道垃圾回收器并不能彻底杜绝内存泄露问题,对于大型应用而言,内存泄露对性能产生的影响是难以估量的,因此开发人员必须要有内存分析的能力。本文介绍一些有用的工具,以及如何使用它们来检测这些关键的内存泄露问题。
工具
有很多工具可以用来帮助检测内存泄露问题,这里列举了一些,并附上一点相应的介绍:
工具名称: |
简介: |
DDMS (Dalvik调试监视服务器) |
和Android一起推出的调试工具(android sdk的tools目录下就有)。提供端口转发服务、截屏、线程监控、堆dump、logcat、进程和无线状态监控以及一些其他功能。可以通过”./ddms”命令启动该工具,同时它也被集成在ADT中,安装以后Eclipse切到DDMS视图即可。 |
MAT (内存分析工具) |
快速的Java堆分析器,该工具可以检测到内存泄露,降低内存消耗,它有着非常强大的解析堆内存空间dump能力。还有很多其他功能无法在这里一一列出,可以安装一下MAT的eclipse插件试试,要活的更多详细信息请点击这里。 |
术语介绍
内存分析涉及到很多专用术语,他们在本文中将频繁出现,这里给出每个术语的定义:
术语: |
定义: |
堆大小 |
分配给Java堆的内存,在Android平台,这些内存都是针对每个Activity分配的(这还取决于设备) |
堆转储文件 |
一个包含了堆中信息的二进制文件 |
支配树(Dominator Tree) |
一个用来图形化展示对象之间关系的工具,详情请参考wiki |
内存泄露 |
内存泄露是指有个引用指向一个不再被使用的对象,导致该对象不会被垃圾回收器回收。如果这个对象有个引用指向一个包括很多其他对象的集合,就会导致这些对象都不会被垃圾回收。因此,需要牢记,垃圾回收无法杜绝内存泄露。 |
GC根 |
GC根是指那些假设可达的对象。 通常包括所有当前栈和系统的类加载器加载的类中引用的对象。(【译者注】栈里引用的对象是指当前执行的方法里的局部变量指向的对象,系统加载器加载的类引用的对象包括类的静态属性引用的对象) |
内存泄露
内存泄露是指有个引用指向一个不再被使用的对象,导致该对象不会被垃圾回收器回收。如果这个对象有个引用指向一个包括很多其他对象的集合,就会导致这些对象都不会被垃圾回收。
图. 1
被一个对象直接消耗的内存
例子:
public final class myObject { // 头部: 8字节
private int valueA; // 整型: 4字节
private int valueB; // 整型: 4字节
private char valueC[]; // 字符型数组: 4字节
}
一个myObject对象共消耗20个字节保留堆(Retained Heap)
因释放了某个对象后,可以释放的所有内存总和。
GC前:GC即将回收对象A
图. 2
GC后:回收对象A(300字节)从而导致回收对象B(50字节)和C(50字节),同时释放了对象B以后对象D也会被回收(100字节),因此回收对象A就可以释放500字节的内存,所谓保留队正式这些对象直接占用的浅堆总和。
图 3
检测内存泄露
Logcat输出的log
第一种发现内存泄露的方法是检查logcat输出的log。当垃圾回收器工作时,可以在Logcat中看到它的消息,这消息长的样子类似于:
D/dalvikm( 14302): GC_CONCURRENT freed 2349K, 65% free 3246K/9551K, external 4703K/5261K, paused 2ms+2ms
这条消息的第一个部分说明该消息产生的原因,一共有四种类型:
GC_CONCURRENT |
当堆变得很大,防止出现堆溢出异常时产生 |
GC_FOR_MALLOC |
如果GC_CONCURENT类型的操作没有及时运行,并且应用程序还需要分配更多内存时产生。 |
GC_EXTERNAL_ALLOC |
在Android3.0 (Honeycomb)以前,释放通过外部内存(externel memory, 通过JNI代码中malloc分配得到的内存)时产生。Android3.0和更高版本中不再有这种类型的内存分配了。 |
GC_EXPLICIT |
调用System.gc时产生 |
“freed 2349K,” – 说明释放了多少内存.
“65% free 3246K/9551K” – 65%表示目前可分配内存占比例,3426K表示当前活动对象所占内存,9551K表示堆大小。
“external 4703K/5261K” – indicates external memory allocation, how much external memory the app has allocated and the soft limit of allocation.说明外部内存的分配,已经分配了多少以及能够分配的上限。
“paused 2ms+2ms” –说明GC运行完成需要的时间。
有了这些信息,我们就可以知道GC运行几次以后有没有成功释放出一些内存,如果分配出去的内存在这几个周期中持续增加,那么很明显存在内存泄露。下面的例子中就是存在内存泄漏时的Log。(【译者注】图片有点不清楚,但是大概可以看出来GC运行了好多次,可分配内存比例反而从47%降到45%了)
图. 4
OutOfMemoryError 异常
跟踪内存分配情况
成功的发现内存泄露问题以后,就应该查找根源在哪里了,有两个工具可以用来辅助分析问题根源所在。
DDMS
DDMS是一个强大的工具,他可以提供有很价值的信息,它还可以生成一个HPROF dump文件,该文件可以使用MAT打开分析。在Eclipse中打开DDMS,只需安装ADT插件以后打开DDMS视图即可。
图. 5
图. 6
图. 7
内存分析工具 (MAT)
MAT是个强大的内存分析工具,可以单独使用也可以作为Eclipse的插件(【译者注】这个工具不在ADT中,可以在http://www.eclipse.org/mat/downloads.php下载,有stand-alone版本和Eclipse安装的update URL),这两种使用方法唯一的区别就是如何打开一个HPROF文件。独立版本需要一个打包好的HPROF文件。我们可以使用android adk自带的hprof-conv工具(在android sdk的tools目录下)打包。如果使用Eclipse插件版的MAT则不需要,直接在Eclipse中打开MAT视图即可。
概述
当打开HPROF文件后,可以看到一个Overview界面,它由以下元素构成:
- Overview标签页,提供一个概览界面。
- Histogram视图,它提供每个类的对象统计(本文稍后详述)
- 支配树(Dominator Tree),提供程序中最占内存的对象 (described later in the article).
- 对象查询语言(Object Query Language Studio), 用来写MAT查询的工具.
- 专家系统测试(Expert System Test) –
- 堆Dump概况(Heap Dump Overview) –提供堆dump文件的详细信息
- 疑似泄露点(Leak Suspects) – 提供内存泄露疑点占用内存大小,被谁加载的,以及类型等详细信息。
- Top Components – 提供占内存最多的对象信息,还包括可能的内存浪费信息.
- 查询浏览器(Query Browser) – 提供很多很有用的查询,有助于内存分析,本文将会介绍最有用的那些查询。根据地址查找对象 – 可以根据提供的一个地址查找某个特定的对象.
- 对象列表(List Objects) – 显示应用中所有对象,以及这些对象持有哪些其他对象和被哪些其他对象持有,(MAT会提示查询哪一个对象)。
- 根据类显示对象(Show Objects by Class) – 列出每个类有多少对象.
- 到GC根节点的路径(Path to GC Roots) – 显示到根节点的引用路径 (有好多过滤选项).
- 合并到GC根节点的最短路径(Merge Shortest Paths to GC Roots) –找到从GC根节点到一个对象或一组对象的共同路径。
- 即时支配(Immediate Dominators) – Finds and aggregates on a class level all objects dominating a given set of objects. 在给定的一组对象中,从类的层面上查找并聚合所有支配关系。(【译者注】好吧,我觉得实在有必要说一下支配的意思,支配在计算机的控制流理论中意思是假如说从起始节点到节点B的所有路径都经过节点A,则节点A支配节点B。在垃圾回收理论中应该是指从某个对象在另外一个对象的保留堆中)
- 显示保留集合(Show Retained Set) – 计算一个对象的保留堆大小.
- 饼图 – 显示持有内存最大的对象
- 直方图 – 显示每个类的对象数量
- 支配树 – 列出所有对象,并按照对象持有的保留堆大小排序
- 检查器 – 选择一个对象,并显示其详细信息
图. 8
直方图(Histogram)
MAT最有用的工具之一,它可以列出任意一个类的实例数。查找内存泄露或者其他内存方面问题是,首先看看最有可能出问题的类,这个类有多少个实例是个比较好的选择。它支持使用正则表达式来查找某个特定的类,还可以计算出该类所有对象的保留堆最小值或者精确值。
- 计算保留堆大小
a) 计算保留堆最小值(Calculate Minimum Retained Size) –计算保留堆最小值,并显示在表格中.
b) 计算保留堆精确值(Calculate Precise Retained Size) – 计算保留堆精确值(这个过程需要一点时间) 并且显示在表格中. - 正则表达式(Regex pattern) – 让用户查询某个特定的对象类
图. 9
另外,当选择了某条显示条目后,可以通过右击弹出菜单。在诊断内存相关问题时,这个菜单是个非常重要的工具。如果开发者怀疑这里有个内存泄露,可以通过菜单直接查看该类的对象持有哪些其他对象,当然,MAT支持过滤查询结果(比如说限制被持有对象的类型)。查询结果出来时,列表通过另外一个有用的工具-”Path toGC Roots”-展示给开发人员。它支持多种过滤选项,比如说排除弱引用-这是最常见的一个选项,因为当GC运行时,被弱引用持有的对象会被GC直接回收,所以这种对象是不会造成内存泄露的,一般直接把这种信息排除。如果MAT预定义的查询不能满足用户需求的话,它还支持自己定制查询,定制的自由度非常大,拥有无限的可能。本文稍后会介绍如何高效的定制查询。
图. 10
图 11
支配树(Dominator Tree)
支配树可以算是MAT中第二有用的工具,它可以将所有对象按照保留堆大小排序显示。用户可以直接在“Overview”选项页中点击“Dominator Tree”进入该工具,也可以在上面提到的菜单中选择“immediate dominators”进入该工具。前者显示dump文件中所有的对象,后者会从类的层面上查找并聚合所有支配关系。支配树有以下重要属性:
- 属于X的子树的对象表示X的保留对象集合。
- 如果X是Y的持有者,那么X的持有者也是Y的持有者。
- 在支配树中表示持有关系的边并不是和代码中对象之间的关系直接对应,比如代码中X持有Y,Y持有Z,在支配树中,X的子树中会有Z。
这三个属性对于理解支配树而言非常重要,一个熟练的开发人员可以通过这个工具快速的找出持有对象中哪些是不需要的以及每个对象的保留堆。
图. 12
查询(Queries)
查询是用来检查对象树的基本工具,内存分析就是在许多对象中查找不希望看到的引用关系的过程-这件事听上去容易做起来难。如果可以过滤这些对象和应用关系的话可以使这项复杂的运动简单不少。一个开发人员想要成功的调试内存问题,必须掌握两个关键点。第一个是对自己的应用充分了解,如果对自己应用程序中的对象之间的关系不够了解的话,是不能找到内存问题的。第二个是掌握过滤和查找的技巧。如果开发者知道对象结构,而且也可以快速的找到想要的东西,那么找到那些异常状况将会变得容易一些。这里列出MAT工具所有内建的查询:
(【译者注】下面表格中的前两列都是MAT工具中菜单的名称)
查询: |
选项: |
描述: |
List objects |
With Outgoing References |
显示选中对象持有哪些对象. |
With Incoming References |
显示选中对象被哪些对象持有。[如果一个类有很多不需要的实例,那么可以找到哪些对象持有该对象,让这个对象没法被回收] |
|
Show object by class |
With Outgoing References |
显示选中对象持有哪些对象, 这些对象按类合并在一起排序 |
With Incoming References |
显示选中对象被哪些对象持有.这些对象按类合并在一起排序 |
|
Path to GC Roots |
With all references |
显示选中对象到GC根节点的引用路径,包括所有类型引用. |
Exclude weak references |
显示选中对象到GC根节点的引用路径,排除了弱引用. [弱引用不会影响GC回收对象] |
|
Exclude soft references |
显示选中对象到GC根节点的引用路径,排除软引用(【译者注】软引用持有的对象在内存空间足够时,GC不回收,内存空间足够时,GC回收) |
|
Exclude phantom references |
显示选中对象到GC根节点的引用路径,排除虚引用(【译者注】虚引用是最弱的引用,get()总是返回null,当它的对象被GC回收时,GC将reference放在ReferenceQueue中,用户代码当发现这个reference在在ReferenceQueue时就知道它持有的对象已经被回收了,这时可以做一些清理工作。《Java编程思想》第四版,中文版,第87页写到Java的finilize方法是为了对象被回收前做清理工作,但是事实上会有隐患,虚引用正是弥补) |
|
Merge Shortest Paths to GC Roots. |
选项和“Path to GC Roots”一样 |
显示GC根节点到选中对象的引用路径 |
Java Basics |
References Statistics Class Loader Explorer |
显示引用和对象的统计信息,列出类加载器,包括定义的类 |
Customized Retained Set |
计算选中对象的保留堆,排除指定的引用 |
|
Open in Dominator Tree |
对选中对象生成支配树 |
|
Show as Histogram |
展示任意对象的直方图 |
|
Thread Details |
显示线程的详细信息和属性 |
|
Thread Overview and Stacks |
- |
|
Java Collections |
Array Fill Ratio |
输出数组中,非基本类型、非null对象个数占数组总长度的比例。 |
Arrays Grouped by Size |
显示数组的直方图,按大小分组 |
|
Collection Fill Ratio |
输出给定集合中,非基本类型、非null对象个数占集合容量的比例。 |
|
Collections Grouped by Size |
显示集合的直方图,按大小分组 |
|
Extract Hash Set Values |
列出指定hash集合中的元素 |
|
Extract List Values |
列出指定LinkedList,ArrayList或Vector中的元素 |
|
Hash Entries |
展开显示指定HashMap或Hashtable中的键值对 |
|
Map Collision Ratio |
输出指定的映射集合的碰撞率 |
|
Primitive Arrays With a Constant Value |
列出基本数据类型的数组,这些数组是由一个常数填充的。 |
|
Leak Identification |
Component Report Top Consumers |
分析可能的内存浪费或者低效使用的组件,并输出最大的那个 |
报告(Reports)
MAT自带有一个报告生成系统,他可以自动分析dump文件并且生成报告给用户。第一种报告叫做“泄露疑点(Leak suspects)”,MAT分析dump文件,检查是否存在一些个头大的对象被一些引用持有保持活动状态,需要注意的是,泄露疑点并不一定是真的内存泄露。第二种报告叫做“顶级组件(Top Components)“,它包括可能的内存浪费信息,占用内存大的对象以及引用的统计信息。此报告对内存优化有很大帮助。
泄露疑点报告
泄露疑点报告包括一些潜在的内存泄露信息,再次强调一下,在这里列出的对象并不一定是真正的内存泄露,但它仍然是检查内存泄露的一个绝佳起点。报告主要包括描述和到达聚点的最短路径, 第三部分(每种类型积累的对象)主要是从第二部分衍生出来的(根据类型排序)。
图 13
“到聚点的最短路径” 非常有用,它可以让开发人员快速发现到底是哪里让这些对象无法被GC回收。
图. 14
使用MAT检测内存泄露
本小节主要介绍如何使用MAT检测内存泄露的实践部分,因此将会提供一段会造成内存泄露的代码作为例子。
会内存泄露的样例代码
第一个内存泄露例子
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
public class MainActivity extends Activity { //静态属性持有非静态内部类的实例--这么做非常糟糕 static MyLeakedClass leakInstance = null ; @Override public void onCreate(Bundle savedInstanceState) { super .onCreate(savedInstanceState); // Static field initialization if (leakInstance == null ) leakInstance = new MyLeakedClass(); ImageView mView = new ImageView( this ); mView.setBackgroundResource(R.drawable.leak_background); setContentView(mView); } /* *非静态内部类 */ class MyLeakedClass { int someInt; } } |
图. 17
从第一次旋转开始,每次都存在3mb的差异。每次旋转后,堆的大小都涨3mb。这是有什么东西不对劲的第一个警告,然后LogCat没有显示任何释放已分配内存的信号。这时就需要检查应用程序内部内存的情况了,运行DDMS,获取HPROF文件,然后用自带的MAT打开。
主屏幕上的确显示了有什么东西导致内存泄露了-它大概长的和图18差不多。
图. 18
饼图上显示,有很大一部分内存被资源文件占用-这很正常,因为任何应用都有一个GUI,但是本例子只有一个资源文件,因此问题应该应该隐藏在这里。还有两个FrameLayout实例(每个有3mb)需要检查,开发者还可以沿着一些路径还检查内存泄露问题。
基于直方图的检查
图. 19
有三个比较大的bitmap对象,这么看来这个本例最坏可能有两到三个内存泄露。这几个对象的内存大小符合LogCat的输出,让我们在检查一下他们到GC根节点的路径(剔除所有弱应用)。第一个bitmap对象看上去没什么问题,因为它只有一个应用指向自己,没有被任何其他对象引用,而且它正在等着被垃圾回收器回收。
图. 20
图 21
看来问题就出在剩下的第三个bitmap上了。它到GC根节点只有一条路径,而且它是被“leakInstance”对象持有的,正是leakInstance对象阻止了该bitmap对象被回收。
图. 22
同时,在路径上还有一个MainActivity对象 – 看到MainActivity对象不奇怪,因为每次旋转都会新创建一个Activity,让我们看看到底发生了什么。首先通过正则表达式过滤器在直方图中找出MainActivity对象。
图. 23
图. 24
第一个MainActivity对象有一个引用指向context和ActivityThread,因此它看上去是现在正在显示的Activity。
图. 25
第二个对象只有一个引用指向自己,它正等着被垃圾回收,到目前为止,一切看上去都正常的。
图 26
现在再看第三个 – 就是它了!有个强引用指向leakInstance对象,就是它阻止了该对象被垃圾回收。
图. 27
基于支配树的检查
开发者可以通过很多种方法找到内存泄露。本文只能介绍其中几种,第二个要介绍的是基于支配树视图的。打开HPROF文件的支配树视图,按照保留堆大小进行排序。正如预料的一样,最上面的是资源类对象,还有三个FrameLayout类的对象(每个3mb)以及一个Bitmap对象(1mb)。FrameLayout对象看上去嫌疑很大,因此我们首先检查它们。因为支配树已经列出了具体的对象,因此我们可以直接查看它们到GC根节点的路径。
图. 28
第一个对象就是问题所在!它到GC根节点的唯一路径正是leakInstance对象,因此它是一个泄露。
图. 29
第二个和第三个对象分别是当前正在显示和正在等着垃圾回收的。
图. 30
让我们在看看那个bitmap对象,它也有可能是一个内存泄露。选择android.graphic.Bitmap,选择显示到GC根节点的路径,剔除所有弱引用。
图. 31
bitmap类型有三个对象,每个对象到GC根节点的路径都可以查看到,上面说的情况再次重演,三个实例中的两个很显然没问题,但是第三个对象指向leakInstance,它一直都是活动状态,不会被回收。
图. 32
可能还有上百条路径可以顺藤摸瓜找出最终的泄露点,应该选择哪条路径取决于不同的开发者了,不同的开发人员有对如何分析内存有着不同的见解。
第二个内存泄露例子
第二个内存泄露场景发生在application context上。它将application context传递给一个单例模式的类,并将其作为一个属性保留下来。这个操作将会使得MainActivity无法被垃圾回收。将context作为静态属性保存也会导致同样的结果,因此这种做法应该避免。为了避免重复罗嗦,这里只介绍一种查找内存泄露的方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
|
public class MainActivity2 extends Activity { SingletonClass mSingletonClass = null ; @Override public void onCreate(Bundle savedInstanceState) { super .onCreate(savedInstanceState); mSingletonClass = SingletonClass.getInstance( this ); } } class SingletonClass { private Context mContext = null ; private static SingletonClass mInstance; private SingletonClass(Context context) { mContext = context; } public static SingletonClass getInstance(Context context) { if (mInstance == null ) { mInstance = new SingletonClass(context); } return mInstance; } } |
图. 33
概览界面并没有提供什么重要信息,因此开发人员需要继续自己的探索。这个例子中没有bitmap和其他资源,但是直方图显示这里有很多MainActivity对象 – 检查检查它们也许能得到更多更有价值的消息。
图. 34
将手机旋转3次,直方图显示这里有4个MainActivity对象。嗯,是时候检查是不是有哪个对象阻止它们被回收了。要做到这一点,首先列出所有有incomming refrence的对象。只需要展开视图就很容易发现第一个对象就是当前正在显示的Activity(他包含指向ActivityThread的引用)。
图. 35
继续列出其他两个对象的到GC根节点的路径。其中一个只有一个引用指向它自己,另外一个指向mInstance,该引用在SignletonClass中,还有一个应用指向当前显示的Activity(从mSigletonClass)。这正是一个泄露。
图. 36
很明显可以看出context让垃圾回收无法回收该对象。另外还有一个问题 – 每次创建一个Acitivity实例的时候,context都被传递给SingletonClass。这是个严重的问题,因为context引用指向一个不在需要的Activity,从而让这个Activity保持活跃无法被回收。在比较大的项目中,这中情况可能会导致一些意象不到的行为,并且这种问题很难被检查出来。