美文网首页Android Other
Android 利用MAT进行内存泄漏分析

Android 利用MAT进行内存泄漏分析

作者: Bfmall | 来源:发表于2023-03-28 15:04 被阅读0次

    前言

    对于程序员来说码代码容易,保证代码的稳定性很难。有时候写完一个功能可能只需要一天时间,但是这个功能隐藏的bug导致的线上问题排查可能需要一周或者更长时间。因此,拥有良好的代码结构和编码规范是一个程序员应该长期坚持并为之奋斗的一个目标。但是,百密也难免一疏,没有百分之百没有问题的代码,在产品上线前,我们需要对自己的代码进行充分的自测,发现问题解决问题,保证自己产品的稳定性并减少对用户的困扰。今天,就给大家简单介绍一下如何使用MAT(Memory Analyzer Tools)进行Android内存泄漏分析,目前这类文章已经很多,我只是抛砖引玉,希望大家能够将这个强大的工具灵活使用起来。

    MAT简介

    MAT是一款非常强大的内存分析工具,在Eclipse中有相应的插件,同时也有单独的安装包。在进行内存分析时,只要获得了反映当前设备内存映像的hprof文件,通过MAT打开就可以直观地看到当前的内存信息。一般说来,这些内存信息包含:

    所有的对象信息,包括对象实例、成员变量、存储于栈中的基本类型值和存储于堆中的其他对象的引用值。
    所有的类信息,包括classloader、类名称、父类、静态变量等
    GCRoot到所有的这些对象的引用路径
    线程信息,包括线程的调用栈及此线程的线程局部变量(TLS)
    MAT支持将这些信息通过我们需要的方式归纳和显示,这样,整个内存信息就一览无余地显示在了我们的面前。
    虽然MAT有如此强大的功能,但是内存分析也没有简单到一键完成的程度,很多内存问题还是需要我们从MAT展现给我们的信息当中通过经验和直觉来判断才能发现。下面,我们就先介绍一下MAT的一些功能。

    Overview

    用MAT打开一个hprof文件后一般会进入如下的overview界面,或者和这个界面类似的leak suspect界面,overview界面会以饼图的方式显示当前消耗内存最多的几类对象,可以使我们对当前内存消耗有一个直观的印象。但是,除非你的程序内存泄漏特别明显或者你正好在生成hprof文件之前复现了程序的内存泄漏场景,你才可能通过这个界面猜到程序出问题的地方。


    image.png

    Dorminator Tree(支配树)

    支配树可以直观地反映一个对象的retained heap,这里我们首先要了解两个概念,shallow heap和retained heap:

    shallow heap:指的是某一个对象所占内存大小。
    retained heap:指的是一个对象的retained set所包含对象所占内存的总大小。
    retained set指的是这个对象本身和他持有引用的对象和这些对象的retained set所占内存大小的总和,用官方的一张图来解释如下:


    image.png

    虽然说MAT定义的支配树和上图中的引用树稍有不同,但是二者的本质都可以反映一个对象如果被回收,有多少内存可以同时得到释放,只不过支配树的定义下可以更精确地反映这个数量。如果你感兴趣,可以继续看我之后的对支配树的概念解释,如果一时理解不过来可以跳过,把支配树简单想象成引用树即可。
    实际上支配树就是从引用树演化而来。所谓支配,例如x对象支配y对象,就是在引用树当中,一条到y的路径必然会经过x;所谓直接支配,例如x直接支配y,指的是在所有支配y的对象中,x是y最近的一个对象。支配树就是反映的这种直接支配关系,在支配树种,父节点必然是子节点的直接支配对象,下图就是一个从引用树到支配树的转换示意图:


    image.png

    上面这幅图右边就是从左边转换而来形成的支配树,这里特别对C节点形成的子树做一下说明。CDE及DF、EG的关系就不用多说,从引用树和我们刚才对支配概念的阐述可以直接得出结论,关键是H节点,H节点在支配树种既不是D的子节点也不是E的子节点是因为,在引用树当中,能够到达H的路径有两条,DEFG节点都不是到达它的路径上必须经过的节点,符合这个条件的只有C节点,因此在支配树中H是C的子节点。现在仔细观察支配树可以发现,一个节点的子树正好就是这个节点对应的retained set。比如,如果D节点被回收,那么就可以释放DF对象所占的内存,但是H所占的内存还不能得到释放,因为可能G还在引用着H,这也是为什么在支配树中H是C节点的子节点的原因,只有C节点被回收了才一定能够回收H节点所占的内存。
    说了这么多,MAT中Dorminator Tree到底能够用到什么上面?我认为,它主要可以用于诊断一个对象所占内存为什么会不断膨胀,一个对象膨胀,就说明它对应到支配树中的子树就越来越庞大,只要分析这个对象对应的子树,确定那些对象是不应该出现在子树中就可以对问题手到病除。下面举一个实例进行简单分析。
    在android中ListView对象是可以不断对其子view进行服用来达到提高内存使用效率的目的,ListView的这一特性依赖于List Adapter的getView的实现,我们通过如下代码来故意使ListView无法服用其子view,从而模拟对象膨胀的情况,代码如下:

    public class LeakedListviewExample extends Activity {
        private ListView mLeakedListview;
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.leaked_listview);
            mLeakedListview = (ListView) findViewById(R.id.leaked_listview);
            mLeakedListview.setAdapter(new LeakedAdapter());
        }
        private class LeakedAdapter extends BaseAdapter {
            @Override
            public int getCount() {
                return 1000;
            }
            @Override
            public Object getItem(int position) {
                return String.valueOf(position);
            }
            @Override
            public long getItemId(int position) {
                return position;
            }
            @Override
            public View getView(int position, View convertView, ViewGroup parent) {
                // 不复用convertView,每次都new一个新的对象
                TextView leakedView = new TextView(LeakedListviewExample.this);
                leakedView.setText(String.valueOf(position));
                return leakedView;
            }
        }
    }   
    

    在运行代码的设备多次上下滑动这个ListView后,可以观察这个应用程序所占内存越来越大,每次手动GC后效果也不明显,说明出现了内存泄漏的现象,于是我们通过MAT dump出内存,切换到Dorminator Tree后可以看到如下的情况:


    image.png

    首先就可以看到ListView的shallow heap和retained heap是非常不成比例的,这说明这个ListView持有了大量对象的引用,正常情况下ListView是不会出现此现象。于是我们根据retained heap的大小逐步对ListView的支配树进行展开可以发现,Recycle Bin下面持有大量的TextView的引用,数量高达2500多个,而在正常情况下,ListView会复用Recycle Bin当中type类型相同的view,其中的对象个数不可能如此庞大,因此,从这里就可以断定ListView使用不当出现了内存泄漏。

    Histogram

    以直方图的方式来显示当前内存使用情况可能更加适合较为复杂的内存泄漏分析,它默认直接显示当前内存中各种类型对象的数量及这些对象的shallow heap和retained heap。结合MAT提供的不同显示方式,往往能够直接定位问题,还是以上面的代码为例,当我们切换到histogram视图下时,可以看到:


    image.png

    根据retained heap进行排序后可见,TextView存在2539个对象,消耗的内存也比较靠前,由于TextView属于布局中的组件,一般来说应用程序界面不能复杂到有这么多的TextView的存在,因此这其中必定存在问题。那么又如何来确定到底是谁在持有这些对象而导致内存没有被回收呢?有两种方式,第一就是右键选择List Objects -> with incoming reference,这可以列出所有持有这些TextView对象的引用路径,如图


    image.png

    从中可以看出几乎所有的TextView对象都被ListView持有,因此基本可以断定出现了ListView没有复用view的情况;第二种方式就更直接粗暴,但是非常有效果,那就是借助我们刚才说的dorminator tree,右键选择Immediate Dorminator后就可以看到如下结果


    image.png

    从中可以看到,有2508个TextView对象都被ListView的RecycleBin持有,原因自然就很明确了。

    MAT的查询选项

    上面主要介绍的是MAT的两个结果分析界面,其实,配合不同的查询选项,可以将它们的威力最大化。MAT中常用的查询选项集合都在顶部以图标的方式进行了显示


    image.png

    从左到右依次为:显示Overview视图,显示Histogram视图,显示Dorminator Tree视图,使用OQL语言对结果进行查询,显示线程信息,结果智能分析,自定义查询选项集合,根据地址查询对象,结果分组,计算retained heap等。前三项就不再多说,分别对应之前我们介绍的三个结果分析结果显示方式,剩余的我挑选一些常用的选项结合一些实例进行说明。

    OQL(Object Query Language)

    在MAT中支持对象查询语句,这是一个类似于SQL语句的查询语言,能够用来查询当前内存中满足指定条件的所有的对象。OQL将内存中的每一个不同的类当做一个表,类的对象则是这表中的一条记录,每个对象的成员变量则对应着表中的一列。
    OQL的基本语法和SQL相似,语句的基本组成如下

    SELECT * FROM [ INSTANCEOF ]
    

    所以,当你输入

    select * from instanceof android.app.Activity
    

    的时候,就会将当前内存中所有Activity及其之类都显示出来。但是OQL也有一些特别的用法。下面简单介绍一下OQL各部分的基本组成(更详细的可以参看这里)。

    FROM部分

    首先是OQL的from部分,指明了需要查询的对象,可以是如下的这些类型:

    一个类的完整名称,例如

    select * from com.example.leakdemo.LisenerLeakedActivity
    

    正则表达式,例如

    SELECT * FROM "java\.lang\..*"
    

    基本类型数组或者对象数组,例如int[]或者java.io.File[]

    select * from java.io.File[]
    

    对象的地址,例如0x2b7468c8

    select * from 0x2b7468c8, 0x2b7468dd
    

    对象的id,例如20815

    select * from 66888
    

    甚至可以是另外一个OQL的查询结果,以实现级联查询。

    SELECT * FROM (SELECT * FROM java.lang.Class c WHERE c implements org.eclipse.mat.snapshot.model.IClass )
    

    from部分还可以用如下两个关键词进行修饰

    INSTANCEOF : 用来包含查询指定类的子类进行查询,否则只查询和from部分完全匹配的类,例如:

    SELECT * FROM INSTANCEOF android.app.Activity
    

    OBJECTS : 修饰后最多只会返回一条结果,指明查询结果对应的类信息,而不是对象信息,例如:

    SELECT * FROM OBJECTS com.example.leakdemo.LisenerLeakedActivity
    

    返回的就是LisenerLeakedActivity对应的类的信息,而不是内存中存在的ListenerLeakedActivity对象。

    from部分还能为查询指定别名,以便提高可读性。例如

    SELECT result.mType FROM OBJECTS com.example.leakdemo.LisenerLeakedActivity result
    

    这里的result即是我们指定的别名,在我们不指定的情况下OQL默认别名为空,如下语句和上面语句效果是相同的

    SELECT .mType FROM OBJECTS com.example.leakdemo.LisenerLeakedActivity
    
    SELECT部分

    这部分和WHERE部分只最灵活也是最复杂的部分,但是大部分情况下我认为在这里使用“*”最好,因为一般这样我们的结果里面的信息是最全。不过如果你不想被结果中的其他信息干扰,可以自定义这部分的内容来实现。
    OQL可以通过自定义select来实现对内存中对象或者类的各种属性及方法结果等进行查询,这主要通过以下三种表达方式来实现。

    访问查询对象的属性的表达式为:

    [ <alias>. ] <field> . <field>. <field>
    

    其中alias代表别名,是在from部分中定义的,可以省略;field就是对象中定义的属性。这种方式能够访问到查询对象的属性值,例如:

    SELECT result.mType FROM OBJECTS com.example.leakdemo.LisenerLeakedActivity result
    

    能够展现出LisenerLeakedActivity.mType组成的查询结果。

    查询访问Java Bean及该对象在MAT中的属性,需要的表达式为:

    [ <alias>. ] @<attribute> 
    

    通过这种方式能够查询到的内容比较多,我们可以通过使用MAT中对OQL的自动补全功能(点这里查看)来选择需要查询的内容,例如:

    SELECT result.@clazz AS "class", result.@displayName AS "display name" FROM com.example.leakdemo.LisenerLeakedActivity result  
    

    就可以得到对象对应的class信息和对象在heap dump中的显示名称。

    查询访问一些底层对象的Java方法的返回结果。MAT可以通过反射来调用当前查询对象的底层类的Java方法,从而得到这些方法的执行结果。其表达式为:

    [ <alias> . ] @<method>( [ <expression>, <expression> ] ) ...
    

    注意method后面的括号是必不可少的,否则OQL会认为这是一个属性名称。"@"符号在新版本的OQL已经可以不需要了,例如:

    SELECT result.@clazz.hasSuperClass(), result.getField("mType") FROM com.example.leakdemo.LisenerLeakedActivity result 
    

    可以得到当前对象是否有基类,以及对象中的mType属性的值是多少。

    不仅如此,select部分还支持很多有用的内建函数,这里就不一一介绍了,大家可以戳这里查看。
    关于select部分的更多信息还可以到这里具体查看。

    WHERE部分

    灵活设置where参数可以为我们排除很大一部分的干扰信息,这部分对于属性和方法的支持和select部分相同,支持的运算符号和关键字有:

    <=, >=, >, <, [ NOT ] LIKE, [ NOT ] IN, IMPLEMENTS, =, !=, AND, OR
    

    相信大家都清楚这些关键值的意义,举一个简单例子

    SELECT * FROM com.example.leakdemo.LisenerLeakedActivity result where result.mType = 1
    

    上面那个查询语句可以查询到所有LisenerLeakedActivity.mType为1的对象。关于where部分的相信介绍可以参考这里。
    OQL可以用来排查缓慢内存泄漏的情况,这种形式的内存泄漏只有程序长时间运行才会造成OOM,在排查阶段由于泄漏不严重而不能够在Dominator Tree或者Histogram中明显表现出来,但是如果你清楚在当前阶段程序中的一些重要对象的大概数量的话,则可以通过OQL来查询验证。这种方式对于在Android中排查Activity泄漏十分有用,例如有下面的代码:

    public class LisenerLeakedActivity extends Activity {
        private static final String TYPE_KEY = "type_key";
        private DemoListener mListener;
        private int mType = -1;
        public static void startListenerLeakedActivity(Context context, int actiivtyType) {
            Intent intent = new Intent(context, LisenerLeakedActivity.class);
            intent.putExtra(TYPE_KEY, actiivtyType);
            context.startActivity(intent);
        }
        @Override
        protected void onCreate(Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            
            mType = getIntent().getIntExtra(TYPE_KEY, -1);
            mListener = new InnerListener();
            DemoListenerManager.getInstance().registerListener(mListener);
        }
        public int getActivityType() {
            return mType;
        }
        @Override
        protected void onDestroy() {
            super.onDestroy();
            // 故意制造泄漏
    //        if (null != mListener) {
    //            DemoListenerManager.getInstance().unregisterListener(mListener);
    //        }
        }
        private class InnerListener implements DemoListener {
            @Override
            public void nothingToHandle() {
                // nothing to do
            }
        }
    }
    

    上面Activity会在onCreate中向DemoListenerManager注册一个Listener,而这个Listener是Activity的内部类,因此它持有这个Activity的引用,如果在Activity的onDestroy阶段没有及时的反注册,那么这个Activity就泄漏了。现在随着多次启动和关闭这个Activity,我们可以发现gc后的内存总是不断增长,这是一个内存泄漏的特征,于是我们dump出当前内存,但是在其中的Dorminator Tree和Histogram中都不能很明确地得出问题所在,于是我们只能按照经验使用OQL来排查。假设你对Activity的泄漏有所怀疑,通过简单输入:

    select * from instanceof android.app.Activity
    

    可以得到如下结果:


    image.png

    从结果当中可以看到,当前内存中存在多个ListenerLeakActivity,而按照我们的原则,离开一个Activity就应该及时销毁和释放改Activity的资源,因此这就说明了Activity的泄漏是造成泄漏的原因之一。

    自定义查询选项集合

    这部分的功能其实和在显示列表中点击右键得到的菜单相同,如下所示:


    image.png

    下面挑选比较重要的几个选项来说明。

    List Objects

    有两个选项:with incoming refrences和with outcoming refreences,分别会罗列出选定对象在引用树中的父节点和子节点,可以作为为什么当前对象还保留在内存中的基本依据。在平时的排查过程中,此功能还是比较常用,因为可以顺着罗列出的引用路径一步步分析内存泄漏的具体原因,例如如下是上面Demo中针对ListenerLeakedActivity的一个with incoming reference的结果,从这个结果中可以逐步展开,最后得到是因为DemoListenerManager的单例仍然持有ListenerLeakedActivity的InnerListener造成泄漏的结论。


    image.png
    注意,如果你选择with incoming references得到的每个结果展开后仍然是针对选择展开的内容的with incoming references,对于with outcoming references也一样。因此,如果两个对象存在循环引用,你需要仔细分析,排除循环引用的路径。例如上图中就不能再展开引用源:ListenerLeakedActivity的mListener进行分析,因为ListenerLeakedActivity本身就和InnerListner存在相互引用关系,展开就又得到了ListenerLeakedActivity的incoming references结果,和一级列表结果是一样的,一直这样展开下去就真是子子孙孙无穷匮也了。
    
    Show objects by class

    和List Objects相似,也可以罗列出当前选中对象在引用树种的上下级关系,只是这个选项会将结果中类型相同的对象进行归类,比如结果中有多个String类型的对象,List Objects会将它们分别显示出来,而Show objects by class会将它们统一归类为java.lang.String。虽然相对来说结果显示简洁了,但是我个人在平时分析的过程中一般较少使用到这个功能。

    Merge Shortest Path To GC Roots

    这是快速分析的一个常用功能,它能够从当前内存映像中找到一条指定对象所在的到GC Root的最短路径。这个功能还附带了其他几个选项,这几个选项分别指明了计算最短路径的时候是否是需要排除弱引用、软引用及影子引用等,一般来说这三种类型的引用都不会是造成内存泄漏的原因,因为JVM迟早是会回收只存在这三种引用的资源的,所以在dump内存映像之前我们都会手动触发一次gc,同时在找最短引用路径的时候也会选择上exclude all phantom/weak/soft etc. references选项,排除来自这三种引用的干扰。如下是针对ListenerLeakedActivity的一次Merge Shortest Path To GC Roots的结果,从图中可以明显地看到一条从sInstance->mListener->array->ListenerLeakedActivity的引用路径,这个结果已经足以定位Demo中内存泄漏的原因了。


    image.png
    Java Basics

    这个选项并不是很常用,这里只简单介绍一下其中的Class Loader Explorer子选项,它可以用来查看一些和选定对象的class loader相关的特性,包括这个class loader的名称、继承关系和它所加载的所有类型,如果你的程序当中应用到了自定义class loader或者osgi相关的特性,这个选项会为你分析问题提供一定程度的帮助。如下是我们通过Demo中的ListenerLeakedActivity的Class Loader Explorer可以看到如下结果。结果表明ListenerLeakedActivity是由PathClassLoader加载的,同时罗列了由这个class loader加载的其他8个类型以及这些类型目前在内存中的实例数量。


    image.png
    Java Collections

    这块的功能平时确实少有用到,对其中的功能应用不太了解,大家有兴趣可以参考这里。也欢迎大家帮助我补充一下这块的功能应用。

    Immidiate Dominators

    这是一个非常有用的功能,它的作用就是找出选择对象在Dominator Tree中的父节点。根据我们之前对Dominator Tree的阐述,就是找到一个对此对象存在于内存中负直接责任的对象,这个方法一般和List Objects或者Merge Shortest Path To GC Roots相互结合使用,共同定位或相互佐证。因为去掉了一个节点在引用树中的一个父节点并不一定能保证这个对象会被回收,但是如果去掉了它的Immediate Dominator,那这个对象一定会被回收掉;但同时,有时候一个对象的Immidate Dominator计算出来是GC Roots,因为它处在引用树中的路径没有一个非GC Roots的共同点,这个时候我们直接用Immidiate Dominators就不太好分析,而需要借助List Objects或者Merge Shortest Path To GC Roots的功能。因此,这几个方法需要相互配合使用。

    Show Retained Set

    这个功能简单来说就是显示选定对象对应在Dominator Tree中的子节点集合,所有这些子节点所占空间大小对应于它的retained heap。可以用来判断当一个对象被回收的话有多少其他类型的对象会被同时回收掉,例如,我们上面的Demo中,因为InnerListener同时被ListenerLeakedActivity和DemoListenerManager所持有,因此它不在这两个对象任何一个的retained set中,而是在这两个对象的公共节点GC Roots的retained set中,但是如果我们没有向DemoListenerManager中注册过InnerListener,那么它会出现在ListenerLeakedActivity的retained set中。

    结果分组

    这个选项支持将列表显示的结果按照继承关系、包名或者class loader进行分组,默认情况下我们结果显示是不分组的,例如Histogram和Dominator Tree的查询结果只是将对应引用树和Dominator Tree中的各个节点罗列出来,这样就包含了很多系统中的其他我们不关注的信息,使用分组的方法就可以方便聚焦到我们关注的内容之中。例如,下面分别是我们对Demo的Histogram视图结果进行按照包名和继承关系进行分组的结果显示。从中看到按照包名排序的方式方便我们着重关注那些我们自己实现的类在内存中的情况,因为毕竟一般情况下出现泄漏问题的是我们自己的代码;而按照继承关系的方式进行分类我们可以直接看到当前内存中到底有多少个Activity的子类及其对象存在,方便我们重点排查某些类。


    image.png
    image.png

    计算retained heap

    在我们一开始选择Histogram或者Dominator Tree视图查询结果的时候,MAT并没有立即开始为我们计算对应各个节点的Rtained Heap大小,此时我们可以用这个功能来进行计算,以方便我们进行泄漏的判断。这个选项又包含两个算法,一个是quick approx的方式,即快速估算,这个算法特点是速度快,能够快速计算出罗列各项Retained Heap所占的最小内存,所以你用这个方式计算后看到的Rtained Heap都是带有">="标志的;如果你想精确地了解各项对应Retained Heap的大小,可以选择使用Calculate Precise Retained Size,但是根据当前内存映像大小和复杂度的不同,计算过程耗时也不相同,但一般都比第一种方式慢得多。

    结束语

    还是花了不少时间来写这篇博客,中间参考了不少其他同行的劳动成果,也不知道是否后面能否完全一一列举,但更多的是自己的总结。在写这篇博客之前自己也使用过一段MAT,但并不是每个功能都使用到了,所以如果其中有什么地方理解有误还望各位读者留言指正。
    关于Android内存泄漏检测的一个神器LeakCanary,大家可以参考我之前写的另外一篇博客

    ————————————————
    版权声明:本文为CSDN博主「大雀儿飞飞」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
    原文链接:https://blog.csdn.net/yxz329130952/article/details/50288145/

    相关文章

      网友评论

        本文标题:Android 利用MAT进行内存泄漏分析

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