Android性能优化-方法区导致内存问题实例分析

0.469字数 3230阅读 969

说到Android内存优化,网上相关资料主要是关于内存泄露和内存溢出,基本都是针对堆内存问题进行分析,很少有关注方法区导致的内存问题,堆内存回收主要是回收对象,方法区内存回收主要是类回收,简单来说就是目前主要关注堆中对象回收,很少关注方法区中类信息导致的内存问题,本文主要关注方法区导致的内存问题,通过实际例子来详细分析方法区导致的内存问题,解释问题原因并给出修改方案。Android内存优化(堆内存)相关内容可参考:鸿洋大神的文章Android 性能优化的方方面面都在这儿-内存优化,以及Android性能优化-内存篇

本文主要内容有:
1.先抛出实际遇到的内存问题以及网上类似的相关问题;
2.介绍虚拟机内存及类加载相关背景知识,这些知识是分析问题的方法论。
3.讲解实际遇到方法区导致的内存溢出,会详细讲解分析过程,以及导致问题的根本原因,最终给出修改方案;

网上相关类似问题

how to reduce .apk mmap size in my android app
如下图所示:

apkmmap.PNG

简单来说就是如何减少.apk mmap所占用的内存,和文本所讲的问题类似,但是目前还无人解答,文章最后会解答该问题。

实际工作遇到的内存问题

在测试过程中发生多次内存告警,dumpsys meminfo信息如下所示

** MEMINFO in pid 10095 [test.test.test] **
                   Pss  Private  Private  SwapPss     Heap     Heap     Heap
                 Total    Dirty    Clean    Dirty     Size    Alloc     Free
                ------   ------   ------   ------   ------   ------   ------
  Native Heap    22556    22512        0     4262    45056    23670    21385
  Dalvik Heap     2833     2796        4       23     6718     3359     3359
 Dalvik Other     3300     3300        0       60                           
        Stack       48       48        0       16                           
       Ashmem        2        0        0        0                           
    Other dev       13        0       12        0                           
     .so mmap     3569      132        4      145                           
    .apk mmap   417594   413340     3124   129860     //.apk占用内存很大                      
    .ttf mmap       26        0        0        0                           
    .dex mmap     4327        0     3208       16                           
    .oat mmap     1543        0        0        0                           
    .art mmap     1875     1216       36       28                           
   Other mmap      756        4       80        1                           
   EGL mtrack     6582     6582        0        0                           
    GL mtrack     4200     4200        0        0                           
      Unknown     1243     1240        0      558                           
        TOTAL   605436   455370     6468   134969    51774    27029    24744
 
 App Summary
                       Pss(KB)
                        ------
           Java Heap:     4048
         Native Heap:    22512
                Code:   419808   //code占用内存也很大,和.apk有关系
               Stack:       48
            Graphics:    10782
       Private Other:     4640
              System:   143598
 
               TOTAL:   605436       TOTAL SWAP PSS:   134969

可以看出.apk mmap占用的内存达到四百多兆,正常情况下.apk所占用内存为9M。同时也可以看到Code占用的内存也是四百多兆,正常情况下Code所占用的内存也是10M左右,以上就是我们遇到问题的现象,暂时先不分析该问题,我们先来了解虚拟机内存相关背景知识,然后再详解讲解该问题的分析过程。

虚拟机内存相关背景知识

JVM运行时数据区域如下图所示:

JVM运行时数据区.JPG

方法区:方法区存放的是类信息、常量、静态变量,所有线程共享区域。
虚拟机栈:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息,线程私有区域。
本地方法栈:与虚拟机栈类似,区别是虚拟机栈为虚拟机执行Java方法服务,本地方法栈为虚拟机使用到的Native方法服务。
:JVM管理的内存中最大的一块,所有线程共享;用来存放对象实例,几乎所有的对象实例都在堆上分配内存,此区域也是垃圾回收器(Garbage Collection)主要的作用区域,内存泄漏就发生在这个区域。
程序计数器:可看做是当前线程所执行的字节码的行号指示器;如果线程在执行Java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址,如果执行的是Native方法,这个计数器的值为空(Undefined),这个区域是唯一一个不会抛出OutOfMemoryError异常的区域。
其中程序计数器、虚拟机栈、本地方法栈3三个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈的操作,每个栈帧中分配多少内存基本上是类结构确定下来时已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内不需要过多的考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了,而Java堆和方法区不一样,这部分内存分配和回收都是动态,垃圾收集器所关注的就是这部分内存,堆内存回收资料很多,不做过多介绍,我们主要关注一下方法区回收,方法区内存在虚拟机中的永久代,Java虚拟机规范说过可以不要求虚拟机在方法区实现垃圾收集,而且方法区垃圾收集性价比一般比较低(永久代)。永久代垃圾收集主要回收两部分内容:废弃常量和无用类,回收废弃常量与回收Java堆中对象类似,判断常量是否是废弃常量比较简单,而要判定一个类是否是无用的类条件相对苛刻,类需要满足下面三个条件才能算无用的类:
(1)该类所有实例都已被回收,也就是Java堆中不存在该类的任何实例。
(2)加载该类ClassLoader已经被回收。
(3)该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足以上三个条件的无用类进行回收,这里说的仅仅是”可以“,而并不是和对象一样,不使用了就必然回收,是否对类进行回收,部分虚拟机提供了参数可以进行控制。所以对类的回收整体是比较难的。使方法区发生类导致的内存溢出基本思路:在运行时产生大量的类去填满方法区,也就是在运行时动态产生很多的类,直到方法区内存溢出。所以频繁动态产生很多类时,需要注意方法区内存溢出。

虚拟机类加载机制相关背景知识

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确定其在Java虚拟机中唯一性,每一个类加载器,都拥有一个独立的类名称空间,简答来说就是比较两个类是否相同,只有在两个类是由同一个类加载器加载的前提下才有意义,否则,即使两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个就必定不相同。类加载采用双亲委派模型,具体内容请参与《深入理解Java虚拟机》,双亲委派模型对保证Java程序稳定运行有很重要作用,实现双亲委派代码在java.lang.ClassLoader的loadClass()方法中,如下所示

private final ClassLoader parent;
    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    c = findClass(name);
                }
            }
            return c;
    }

可以看出在加载类时先检查类是否已经被加载过,如没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器,如果父类加载器加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。与本文相关的内容就是类如果已经加载就不会再次被加载,以及判断类是否相同的条件,如果不停的加载类,就会导致方法区内存溢出。以上就是本文所需要的背景知识,下面我们来分析遇到的方法区内存问题。

方法区导致内存问题实例分析

现象
如前文中dumpsys meminfo信息所示,在dumpsys meminfo中.apk mmap占用的内存达到四百多兆,正常情况下.apk所占用内存为9M。同时也可以看到Code占用的内存也是四百多兆,正常情况下Code所占用的内存也是10M左右。
分析过程
(1)《移动App性能评测与优化》书中专门讲解了如何分析内存的不同部分,以及如何优化内存的不同部分。由于书中分析的Android版本比较旧,我们遇到的问题暂时是没有相关内容的,但是给我们提供一些方法论,如smaps中信息是meminfo的来源,虽然版本不同,但还是有些线索供我们分析该问题的。
(2)首先我们需要搞明白.apk以及Code代表的含义,
Code:您的应用用于处理代码和资源(如 dex 字节码、已优化或已编译的 dex 码、.so 库和字体)的内存。
.apk mmap:apk代码占用的内存,简单来说就是class文件的字节码,也可以理解apk中类所占用的内存,Android中.dex文件将所有APK中的.class里边所包含的信息全部整合在一起,所以Class字节码信息存储在.dex文件中。
其中,一个Class文件占用内存大小是可以计算出来,具体内容请参考《深入理解JVM》或Java虚拟机原理图解
(3)APK代码占用内存大,也就是类信息占用的内存很大,该部分内存属于方法区,在上边虚拟机内存相关知识中介绍了类信息占用内存大,是因为动态创建了很多类,导致这部分内存增加。
(4)因为之前版本没有该问题,应该是最新修改引起的,既然是不停的创建新的类,就查看最近修改,最近修改发现确实有DexClassLoader加载第三方apk,然后重复相关操作,每操作一次.apk和Code内存增长7M左右,问题代码如下

            DexClassLoader dexClassLoader = new DexClassLoader(dexPath, cacheDir, "", ClassLoader.getSystemClassLoader());
                        testView = (View) dexClassLoader.loadClass(clsName)
                    .getMethod(method, new Class[]{Context.class, Handler.class})
                    .invoke(null, new Object[]{loadContext, handler});

所以就是动态加载第三方APK时,动态产生了很多类,导致内存不停增长,每执行一次上述代码,内存增加7M,并且APK中.dex文件大小当好就是7M。
(5)刚开始病急乱投医阶段,一头雾水不知道如何分析,使用MAT工具查看hprof文件,hprof文件中存储的是堆中对象的快照,而我们遇到的问题是类信息导致的内存问题,所以MAT工具是分析不出来的,那么分析类信息占用内存应该使用什么工具那?最后找到了showmap和swaps,showmap可以查看进程跑起来后各库所占用内存情况,swaps中信息是meminfo中数据来源。因此,.apk内存增加和堆内存是不一样的,使用MAT工具是分析不出来的,MAT是用来分析堆转储,堆转储是应用堆中所有对象的快照,而我们遇到的问题是.apk代码占用内存太大,说白来就是加载了太多的类。
(6)执行adb shell showmap pid,查看进程跑起来后各库所占用内存情况,最终发现每次运行一次,第三方APK的内存每次增加7M的大小。如下所示:

 virtual                     shared   shared  private  private
    size      RSS      PSS    clean    dirty    clean    dirty     swap  swapPSS   # object
-------- -------- -------- -------- -------- -------- -------- -------- -------- ---- ------------------------------
   21132    21132    21132        0        0        0    21132        0        0    3 /dev/ashmem/dalvik-classes.dex extracted in memory from /data/app/test-5iR1QUK1UTKpuR4CbwVkTg==/base.apk (deleted)

现在第三方APK占用内存大小为21132Kb,每运行一次上述问题代码,第三方APK所占用内存增加7M。
(7)dumpsys meminfo数据来源于swaps,就是将smaps不同数据相加可以得到对应meminfo中相关的数据,执行adb shell cat /proc/pid/smaps > smapsinfo.txt,我们来查看其中的第三方APK占用内存情况

712811f000-7128800000 r--p 00000000 00:05 209598                         /dev/ashmem/dalvik-classes.dex extracted in memory from /data/app/test-5iR1QUK1UTKpuR4CbwVkTg==/base.apk (deleted)
Size:               7044 kB
Rss:                7044 kB
Pss:                7044 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:      7044 kB
Referenced:         7044 kB
Anonymous:          7044 kB
AnonHugePages:         0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
PSwap:                 0 kB
SwapPss:               0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
MMUPageSize:           4 kB
Locked:                0 kB
713851f000-7138c00000 r--p 00000000 00:05 203443                         /dev/ashmem/dalvik-classes.dex extracted in memory from /data/app/test-5iR1QUK1UTKpuR4CbwVkTg==/base.apk (deleted)
Size:               7044 kB
Rss:                7044 kB
Pss:                7044 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:      7044 kB
Referenced:         7044 kB
Anonymous:          7044 kB
AnonHugePages:         0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
PSwap:                 0 kB
SwapPss:               0 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Locked:                0 kB

可以看出,第三方APK占用内存大小为7M,每执行一次问题代码,smaps中就会多一个APK内存信息,所以我们猜测.apk占用内存的大小与上述smaps中apk占用内存有关,所以要是能保证smaps中APK不重复出来,应该就可以解决该问题,最新的smaps中信息和meminfo中的对应关系我也不是太清楚,目前也没有找到相关资料,只能通过阅读源码才能知道了,如果大家知道这部分内容可以分享一下。
通过showmap和swaps相关信息我们从侧面也反应出应用内存在不停的增加。至此,我们已经找到问题的复现路径和问题代码,也知道了因为加载了太多类导致.apk mmap内存不断增加。
问题根本原因
需要从虚拟机类加载相关知识说起,在加载类时先检查类是否已经被加载过,如没有加载才会去重新加载该类,对于任意一个类,都需要由加载它的类加载器和这个类本身一同确定其在Java虚拟机中唯一性,每一个类加载器,都拥有一个独立的类名称空间,简答来说就是比较两个类是否相同,只有在两个类是由同一个类加载器加载的前提下才有意义,否则,即使两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载他们的类加载器不同,那这两个就必定不相同。现在我们来看问题代码,在每次加载第三方APK时,都会重新new一个ClassLoader,然后将第三方APK中的类重新加载一次,问题就在new ClassLoader,因为加载第三方APK中类信息时,都会new一个新的类加载器,这样类加载器在检查类是否已经被加载时,因为不是同一个类加载器,就会判定类还没有被加载,所以每次执行都需要重新加载一次类,就导致每次执行问题代码就将第三方apk中的类全部重新加载一遍,这样存放类信息的.apk mmap内存就会不断增加。
修改方案
将类加载器设置为单例或者只要保证类加载器是同一个对象即可,这样后续每次加载第三方APK时,发现APK中的类信息都已经被加载过了,就不会重新加载类,相应的.apk mmap内存就不会不停的增长。

网上类似问题解答

how to reduce .apk mmap size in my android app,从描述来来看,应该是WebView中加载了第三方APK或者动态生成了大量的类,这样就会导致.apk的内存增加,上述我们遇到的例子中加载第三方APK的时候.apk mmap内存也会增加7M(应该是将APK中所有类信息全部加载进来,APK中dex文件大小也刚好是7M),增加之后这部分内存也不会降下来,目前我们只能保证不让这部分内存不停的增加,确实不知道如何回收这些类信息。但可以从如下两角度来考虑如何解决:
(1)从回收类信息角度来看:回收类信息应该是虚拟机来完成,应该是ART虚拟机不支持类回收,所以这部分内存降不下来,要想回收类信息需要虚拟机支持;
(2)从减少类信息内存角度来看:在动态加载第三方APK的时候,如何才能使用APK中的部分类信息来实现想要的功能,而不是将APK所有类信息全部加载进来(目前我也不知道应该如何做,欢迎讨论)。
以上就是方法区类信息导致内存问题的分析过程以及相关背景知识,希望对你有所帮助,谢谢!