Android内存泄漏检测与MAT使用

宝剑锋从磨砺出,梅花香自苦寒来。这篇文章主要讲述Android内存泄漏检测与MAT使用相关的知识,希望能为你提供帮助。
公司相关项目需要进行内存优化, 所以整理了一些分析内存泄漏的知识以及工作分析过程。
本文中不会刻意的编写一个内存泄漏的程序, 然后利用工具去分析它。而是通过介绍相关概念, 来分析如何寻找内存泄漏, 并附上自己的项目实战过程。
撰写过程中, 本人深感JVM、操作系统相关知识了解不够深刻, 不足之处非常欢迎指正说明。
内存泄漏基本概念 内存检测这部分, 相关的知识有JVM虚拟机垃圾收集机制, 类加载机制, 内存模型, 以及操作系统的基础知识( 所以不要说JVM有啥用, 操作系统有啥用啦 :) ) 。
编写没有内存泄漏的程序, 对提高程序稳定性, 提高用户体验具有重要的意义; 同时, 也是java程序员进阶的重要内容。利用java编写程序的时候, 要特别注意内存泄漏相关的问题。虽然JVM提供了自动垃圾回收机制, 但是还是有很多情况会导致内存泄漏。
内存泄漏主要原因就是一个生命周期长的对象, 持有了一个生命周期短的对象的引用。这样, 会导致短的对象在该回收时候无法被回收。android中比较典型的有:
1、静态变量持有Activity的context。
2、或者Handler持有某个组件的context, 同时如果Looper的消息队列中有针对该Handler的消息没有被处理, 那么会被作为target持有强引用, 最终的导致context无法释放, 导致相应组件在退出时无法被内存回收。
3、非静态内部类默认持有外部类的引用。有时候为了方便, 我们会在Activity中定义一个Thread内部类, 同时直接通过new Thread的方式去运行线程, 那么在线程运行结束之前, 线程都会持有Activity的引用, 从而导致Activity无法被释放。
内存检测工具 LeakCananry 使用步骤
LeakCanary, 主要监测的是使用过程中Activity, Fragment等组件是否没被内存回收。使用方法也十分简单, 相当于装了一个监听器, 然后通过正常 操作去寻找内存泄漏, 发生内存泄漏的时候会有Toast, 同时可以在相应程序查看哪里发生内存泄漏。
方法比较简单, 具体步骤可以查阅官方github。添加leakcanary依赖以后, 新建一个Application入口, 在Oncreate方法中安装Leakcanary即可。

Android内存泄漏检测与MAT使用

文章图片

当发生内存泄漏时, 屏幕会出现Toast, 同时打开桌面上的Leaks程序, 显示泄漏的内存, 如下图:
Android内存泄漏检测与MAT使用

文章图片

整体流程
LeakCananry实现步骤大致是:
实现大致步骤是:
1、自动把activity加入到KeyedWeakReference
2、在background线程中, 检查onDestroy后reference是否被清除, 且没有触发gc
3、如果reference没有被清除, 则dump heap到一个hprof文件并保存到app文件系统中
4、在一个单独进程中启动HeapAnalyzerService, HeapAnalyzer使用HAHA来分析heap dump。
5、HeapAnalyzer在heap dump中根据reference key找到KeyedWeakReference。
6、HeapAnalyzer计算出到GC Roots的最短强引用路径来判断是否存在泄露, 然后build出造成这个泄露的引用链。
7、结果被传回来app进程的DisplayLeakService, 并展示一个泄露的notification。
结论
方法的优点是简单易行, 但是只能检测Activity、Fragment是否发生内存泄漏。 对于一些项目比如sdk开发, 很可能整个程序没有一个Activity, 所以这种方式就不是很实用。
观看整体内存使用情况 详情参见官方文档:
https://developer.android.com/studio/profile/investigate-ram.html#ViewingAllocations
使用adb shell, 进入手机adb, 执行命令:
dumpsys meminfo < 包名> [-参数]

可以查看应用不同部分内存分配情况。比如Java heap, Native heap等
输出是目前具体应用的内存分配, 单位是kilobytes
因为程序涉及jni, 经常会分配本地内存, 所以会使用adb shell 的方式去查看native heap的分配情况。
结果如下:
Android内存泄漏检测与MAT使用

文章图片

分析各个参数:
Private Clean/Dirty RAM:
这部分内存是app的私有内存, 当app销毁是操作系统可以回收到全部的内存。其中private dirty只能被你的进程使用, 同时只能存在在内存当中, 当内存不够, 也不能通过分页技术存储到硬盘( 操作系统相关知识) , dalvik和native heap上的分配都是private dirty RAM。 Dirty RAM是内存中被修改过的页面, 而Clean RAM是从持久文件( 比如代码执行文件) 映射出的内存。
PSS Total:
我们知道, 进程之间彼此通信底层通过Binder Driver, 通过操控一块共享内存进行读写来相互通信。这样一来, 为了进程间通信, Binder会为每个进程在共享内存中开辟一块空间。
PSS的部分, 包含了每个进程的共享内存。例如, 一个内存页面被两个进程共享, 那么页面大小的一半会被加到两个进程各自的PSS中。
通过累加全部进程的PSS, 我们可以查看整个系统的内存使用情况。事实上, PSS是衡量 ( 实际) 使用内存的重要标准。
Dalvik Heap:
该字段衡量的是Dalvik虚拟机上堆分配情况, 也就是我们在Java中使用new生命对象分配的内存。
列中PSS Total包括了和其它Zygote进程共享的内存( 全部app进程都是从Zygote中fork出来的, 都有一部分内存共享) 。而Private Dirty则是app进程本身所使用的的内存。
.so mmap / .dex mmap
这部分主要指的是本地代码( so) 和Davlik 虚拟机代码( dex) 的代码大小。PSS Total列中指的是包含android平台的代码, 而private clean仅仅是程序本身运行的代码。
上面参数很多, 理解相关知识需要掌握操作系统内存部分。我们在测试的使用, 一般情况下, 我们关注private Dirty或者pss Total就可以查看app内存整体趋势。
DDMS 使用流程
  1. 启动eclipse后, 切换到DDMS透视图, 并确认Devices视图、Heap视图都是打开的;
  2. 将手机通过USB链接至电脑, 链接时需要确认手机是处于“USB调试”模式, 而不是作为“MassStorage”;
  3. 链接成功后, 在DDMS的Devices视图中将会显示手机设备的序列号, 以及设备中正在运行的部分进程信息;
  4. 点击选中想要监测的进程, 比如system_process进程;
  5. 点击选中Devices视图界面中最上方一排图标中的“Update Heap”图标;
  6. 点击Heap视图中的“Cause GC”按钮;
  7. 此时在Heap视图中就会看到当前选中的进程的内存使用量的详细情况。
如何检测内存泄漏? Heap视图中部有一个Type叫做dataobject, 即数据对象, 也就是我们的程序中实例化的对象。在data object一行中有一列是“Total Size”, 其值就是当前进程中所有Java数据对象的内存总量, 一般情况下, 这个值的大小决定了是否会有内存泄漏。
正常情况下Total Size值都会稳定在一个有限的范围内, 也就是说没有造成对象不被垃圾回收的情况, 所以说虽然我们不断的操作会不断的生成很多对象, 而在虚拟机不断的进行GC的过程中, 这些对象都被回收了, 内存占用量会会落到一个稳定的水平。如果代码中存在没有释放对象引用的情况, 则dataobject的Total Size值在每次GC后不会有明显的回落, 随着操作次数的增多Total Size的值会越来越大
通过DDMS方式, DataObject 的totalSize如果稳定在一个大概范围内, 则可以确定没有发生内存泄漏。
MAT 然而, 并不是所有的内存泄漏都十分明显, 并且会最终导致OOM。有时候只有几个对象被泄漏, 虽然影响不大, 但是无疑浪费了内存。
要发现这种比较隐蔽的内存泄漏, 我们需要使用MAT工具。
在了解MAT具体使用之前, 要先了解一些相关概念。
支配树 支配树体现了对象实例间的支配关系, 在对象引用图中, 所有指向对象B的路径都经过对象A, 则认为对象A支配对象B。
Android内存泄漏检测与MAT使用

文章图片

在这张图里, 左边是对象引用关系, 对于A和B, 要抵达这两个点必须经过GC root。而对于C可以从A也可以从B抵达, 但都必须经过GC root, 所以最近的支配点同样也是GC root。
对于点D, 不管是从C-> D还是C-> D-> F-> D, 都必须经过的最近的点是C, 所以C是D的支配点。同理可得EFHG在支配树中的位置。
SHALLOWHEAP和RETAINED HEAP Shallow heap表示对象本身所占内存大小, 一个内存大小100bytes的对象Shallow heap就是100bytes。
Retained heap表示通过回收这一个对象总共能回收的内存, 比方说一个100bytes的对象还直接或者间接地持有了另外3个100bytes的对象引用, 回收这个对象的时候如果另外3个对象没有其他引用也能被回收掉的时候, Retained heap就是400bytes。
在使用mat进行分析时, 我们常常接触到的数据就是shallow size和retained size:
Shallow Size
对象自身占用的内存大小, 不包括它引用的对象。
针对非数组类型的对象, 它的大小就是对象与它所有的成员变量大小的总和。当然这里面还会包括一些java语言特性的数据存储单元。
针对数组类型的对象, 它的大小是数组元素对象的大小总和。
Retained Size
Retained Size= 当前对象大小+ 当前对象可直接或间接引用到的对象的大小总和。(间接引用的含义: A-> B-> C, C就是间接引用)
换句话说, Retained Size就是当前对象被GC后, 从Heap上总共能释放掉的内存。
不过, 释放的时候还要排除被GC Roots直接或间接引用的对象。他们暂时不会被回收。如下图:
Android内存泄漏检测与MAT使用

文章图片

A对象的Retained Size= A对象的Shallow Size
B对象的Retained Size= B对象的Shallow Size + C对象的Shallow Size
因为B对象被释放时, C同时被释放, 而D由于被GC roots直接引用所以不会被释放。而Retained Size就是当前对象被GC后, 从Heap上总共能释放掉的内存。
以上概念, 都是在使用MAT进行内存分析经常使用的。
我们在分析内存泄漏的时候, 着重会查看retained heap, 也就是这个对象没有被释放前, retained heap中的相关内存不会被释放。
然后, 在分析某个对象为何没被释放的时候, 会查看引用关系或者支撑树。因为引用树父子关系可能比较杂乱, 而支撑树更加清晰。
在使用MAT分析内存泄漏的过程中, 主要流程就是:
1、分析retained heap, 找一个使很多对象无法被释放的内存。
2、正常情况下, 该释放这个对象, 所以通过支撑树, 或者查看GC 路径, 分析为什么这个对象没有被释放。
MAT的下载与使用 下载地址: https://eclipse.org/mat/downloads.php
这里没有作为eclipse插件的方式下载mat, 而是通过下载单独的软件客户端。
首先, 在DDMS中选择要检测的进程并dump HPROF file, 如下图:
Android内存泄漏检测与MAT使用

文章图片

HPROF中存储的是当前内存的快照, 因此, 在dump快照之前先点击cause GC手动触发一次垃圾回收, 这样可以避免软引用、弱引用等不必要的对象保留在内存中影响我们的分析。
转储出来的hprof文件, 还有使用sdk自带工具进行一下格式转化, 工具在sdk路径下的platform-tools下, 名称为hprof-conv。
使用方法:
/.hprof-conv.exe a.hprof b.hprof
a 是输入hprof文件名, b是输出文件名。
然后将b.hprof在eclipse memory Analyzer中打开, 注意要转换格式, 不然无法成功打开。
如下:
Android内存泄漏检测与MAT使用

文章图片

利用MAT分析内存泄漏 分析过程中, 主要使用的是Histogram直方图, 和Dominater tree支配树。
在Histogram视图中查找retained heap值最大的项, 并分析这里是否发生内存泄漏。
Android内存泄漏检测与MAT使用

文章图片

上图中一坨一坨的, 其实就是Class的名称。这样分类比较清晰, 后面会说到如何查看Class声明的对象。
在最上面class Name下有输入过滤的地方, 需要注意是, 如果要查看com包下的类, 那么要输入com. , 这里的正则中’*’貌似不会去匹配’.’, 所以就要我们自己输入啦。 一般情况下, 我们忽略会java、android系统自带的类, 而着重分析我们自己程序中编写的对象内存使用情况。
Retained heap表示因为这个对象, 会导致多少对象无法回收。
右击相应类, list objects-> with incoming references。表明引用这个类的某个实例的其它类, 也就是它在引用树中的父节点。通过分析该对象被谁引用, 来判断为何没被垃圾回收。
outcoming reference就是子节点, 查看一些当前对象引用着的对象。
此外看, Merge shortest path to gc root, 可以找到一条到GC root的最短路径, 来看为什么当前对象无法被回收。
实战分析 下面记录了本人对一个项目的具体分析过程, 以及各个工具的使用方法。
1、使用DDMS查看内存
使用DDMS的过程中, 针对应用分别进行了多次检测, 主要查看程序运行前的内存使用情况和程序运行后的内存使用情况:
使用前:
Android内存泄漏检测与MAT使用

文章图片

使用后:
Android内存泄漏检测与MAT使用

文章图片

通过上述数据可以看到, 在程序运行前data object也就是在堆上分配的数据是180KB左右, 而运行后内存大概在300KB上下浮动, 没有呈现一个明显的一直上升的情况, 故而没有明显的内存泄漏, 基本没有导致OOM的可能。
但是, 可以发现, 程序运行一次以后, 放置一段时间, 即便手动触发GC, 堆上的内存虽然回落, 但是仍然是288KB, 与执行前的180KB相差较大, 说明有一些对象被GC roots引用, 无法完成释放。
下面采用MAT工具进行进一步分析。在上面的过程中, 转出了三个hprof文件, 将hprof文件利用Android sdk tools下的工具进行格式转换,进行对比分析:
Android内存泄漏检测与MAT使用

文章图片

2、使用MAT分析内存转储
前面分析内存使用发现, 使用前和使用后有一个100KB左右的差值, 同时即便放置一段时间仍然无法使用。将before和after的直方图加入对比栏, 在MAT中进行对比:
Android内存泄漏检测与MAT使用

文章图片

点击右上角的红色叹号:
Android内存泄漏检测与MAT使用

文章图片

Android内存泄漏检测与MAT使用

文章图片

对比发现两个shallow heap大小基本相同, 多出的部分是UpdatePartResultThread, 系统类而不是我们自己编写程序造成的。
再看一下使用前后直方图中的retained heap:
Android内存泄漏检测与MAT使用

文章图片

可以看出, 程序执行后, newActivity强引用了一些对象, 在newAcitivity没有推出前, retainedheap部分内存无法被回收。这也就是我们在DDMS中发现堆内存差异的主要原因。
右击直方图中的NewActivity, 可以看见如下选项:
Android内存泄漏检测与MAT使用

文章图片

用的比较多的是List objects和Merger shortest Paths to GC Roots。
List objects:
Outgoing reference是支配树中当前对象的子节点, 也就是当前对象持有哪些引用。
Incoming reference是父节点, 即当前对象被谁引用, 为什么没被回收。
Merger shortest Paths to GC Roots: 找到当前无法被释放的对象到GC roots的最短路径。即排查当前对象被谁引用, 为什么没有被释放。这里因为我们的对象是一个Activity, 当它显示在前台的时候, 不会被垃圾回收, 所以不是我们分析的点。
在这里, 我们查看outgoing reference, 查看当前对象拥有哪些强引用:
Android内存泄漏检测与MAT使用

文章图片

排除系统的对象, 还是主要分析我们编写的程序。
Android内存泄漏检测与MAT使用

文章图片

最后发现, 我们在之前使用LeakCanary时, 注册的相应监听器没有回收, 发现了内存泄漏 :) 。
去掉LeakCanary, 再次测试发现data object的值确实下降了不少。
继续分析, 发现newActivity引用了一个
Android内存泄漏检测与MAT使用

文章图片

致使一部分内存无法被释放。这个问题属于客户端实现问题, 不在内存泄漏的范围内。
接下来, 在直方图中过滤出服务端的类:
Android内存泄漏检测与MAT使用

文章图片

可以看到, 服务端的类大部分shallow heap都为0, 也就是已经被垃圾回收。
结论 【Android内存泄漏检测与MAT使用】在使用MAT分析内存时, 最关键的就是找引用关系。如果一个应该被释放的对象没有被释放, 那么我们往往要查看它的incoming reference, 看看是谁持有了它的强引用。同时利用Merger shortest GC roots找到到GC root的最短路径, 确定是由于被谁引用而导致无法GC。

    推荐阅读