最近我们的android app占用了大量内存,于是领导安排做减少内存占用的工作。 要优化内存,首先要做的就是分析内存占用情况。android提供了多个工具和命令进行内存分析。
Android内存分析和调优——上篇
第一层 Procrank
很粗略的,可以使用”adb shell procrank”,结果类似于
PID Vss Rss Pss Uss cmdline
……
2319 42068K 42032K 13536K 7028K com.xxx
……
该命令可以列出当前系统所有进程的内存占用情况。
- PID是进程ID。
- Vss是占用的虚拟内存,如果没有映射实际的内存也算进来。
- Rss是占用的物理内存。是共享内存+私有内存。因为共享内存是多个进程共用的,所以存在重复计算。
- Pss是占用的私有内存加上平分的共享内存。例如一块1M的共享内存被两个进程共享,那每个进程分500K。各进程的Pss相加基本等于实际被使用的物理内存,所以这个经常是最重要的参数。
- Uss是私有内存。
- cmdline可以看做是apk包名。
通过procrank,只能很宏观的横向比较不同的应用。如果要更细致的了解具体内存是如何使用,则需要进入第二层 dumpsys meminfo
第二层 dumpsys meminfo
命令“adb shell dumpsys meminfo package.name”。在4.0 ICS(或者3.0 HoneyComb)之后的系统上,会看到类似下面的输出
Shared Private Heap Heap Heap
Pss Dirty Dirty Size Alloc Free
—— —— —— —— —— ——
Native 16 8 16 3416 3300 79
Dalvik 3884 10592 3580 9560 9022 538
Cursor 0 0 0
Ashmem 0 0 0
Other dev 5110 10244 0
.so mmap 640 1948 396
.jar mmap 0 0 0
.apk mmap 68 0 0
.ttf mmap 817 0 0
.dex mmap 411 0 0
Other mmap 55 16 32
Unknown 2404 660 2388
TOTAL 13405 23468 6412 12976 12322 617
(如果使用2.3或之前的版本,结果会粗糙一些,很多都被归入了Other,但基本结构是一样的)
stacktrace上有个经常被搜到的帖子对这个格式有说明,虽然针对的是android 2.3格式,但读后非常有收获。
但仍有很多疑问没有解答,例如针对上面的例子,为什么Native heap size那么大,但Pss却那么小?占用内存比较多的Other dev是什么?Unknown又有哪些?等等。
要理解这些,需要知道这个report是如何生成的。实际上,生成report的代码是android的android_os_Debug.cpp。
从中我们可以发现,上面列表的数据是由三种方式获取的:
- Pss/Shared Dirty/Private Dirty三列是读取了/proc/process-id/smaps文件获取的。它会对每个虚拟内存块进行解析,然后生成数据。
- Native Heap Size/Alloc/Free三列是使用C函数mallinfo得到的。
- Dalvik Heap Size/Alloc/Free并非该cpp文件产生,而是android的Debug类生成。
后面两个Heap的获取比较简单,我唯一的疑惑是为什么有free的?我的理解是无论是c的malloc还是java的new,最后都是通过mmap系统调用进行内存分配的。而mmap必须以页的4K为单位。所以如果一次一次只需要malloc 2K,则剩下的2K是free的。如果下次再malloc 2K,可以仍然使用上次mmap剩余的2K内存。
至于smaps文件,我们可以通过adb shell cat /proc/process-id/smaps来查看(需要root)。这是个普通的linux文件,描述了进程的虚拟内存区域(vm area)的具体信息。每次mmap一般都会生成一个vm area。
在Android上,一个更加方便的命令是adb shell showmap -a process-id。
第三层 adb shell showmap
该命令也是读取smaps文件,但结果细化的具体的vm area。
该命令输出的每行表示一个vm area,列出了该vm area的start addr, end addr, Vss, Rss, Pss, shared clean, shared dirty, private clean, private dirty,object。
第二层的dumpsys meminfo其实就是读取这些数据,然后分类(native, dalvik, .so map, etc.)统计生成。
start addr和end addr表示进程空间的起止虚拟地址。
Vss,Rss,Pss跟前面说的一样。
Object可以看做mmap的文件名。
Shared clean,按字面意思,表示共享的干净的数据。共享表示多个进程的虚拟地址可以都指向这块物理空间,表示多个进程共享的so库。为什么这里说是多个进程共享的so而不是所有的so呢?
关于so库的加载,我一直觉得是mmap带MAP_SHARED参数,但看了memory_faq, 才知道是MAP_PRIVATE。如果使用showmap命令查看vm area,会发现有的so的内存都属于Shared clean,而有的so则属于private clean。前者一般是当前进程特有的so,而后者一般是通用的so。后来看了对mmap的各种参数的实验(很赞实践精神),才知道第一次以MAP_PRIVATE mmap so,内存都是private clean的。如果另外一个进程mmap了同一个so,那该vm area就变成shared clean了。
Private clean,包括该进程私有的干净的内存。包括前面说的该进程独自使用的so和进程的二进制代码段。
Clean内存的好处是在内存紧张时,可以释放物理内存。因为是clean的,所以不需要写回到disk,只需要下次读取该内存(导致缺页错误)时再从disk读入。
Private dirty,表示该进程私有的不跟disk数据一致的内存段。例如堆(heap),栈(stack),bss段。关于bss段,因为在elf文件为了节约 控件没有赋值,所以在加载到内存时赋值为0,于是跟disk就不一致了。在showmap结果中,会发现几乎每个so都有一个显示位[bss]的 private dirty段。数据段我估计是private clean的,因为elf文件是有初值的。
Shared dirty开始我一直搞不清楚。后来看了Dalvik vm internal这个video(slides), 才明白了些。对于普通的linux进程,当父进程fork子进程时,父进程的虚拟内存区域都会”复制“一份到子进程中。这里”复制“加引号,是因为为了节 省内存,也为了减少内存拷贝的时间,使用的是copy-on-write的方法。当子进程对private dirty的堆,栈,bss没有修改时,则是父子进程share这份dirty(因为跟disk没法映射)数据。如果发生改变,则会修改为private dirty。所以android有zygote进程,是所有android apps进程的父进程,在其中会加载resource等资源(下文会看到,最简单的应该也有大概5M resource,例如图片),这些资源都是只读的。具体的apps继承了这些shared dirty的数据,因为不修改它们,所以也不用分配多余的内存空间。
由于android使用的linux没有swap分区,所以dirty的数据必须常驻内存。所以dumpsys meminfo会把private dirty和shared dirty重点列出来,这也是我们优化内存的重点。
现在可以回答一个前面提到的问题,为什么Native Heap(根据mallinfo系统调用得到)很大而Native Pss(根据swaps得到)很小。我觉得这是dumpsys meminfo的一个bug。根据android_os_Debug.cpp的代码,object名字是[heap]的段被认为是native heap。这在2.3是正确的,但在4.0之后,[heap]为名字的段却很小(只有几K)。同时,我却发现有大量的[anon]的区域。我认为anon 是anonymous的缩写。malloc一般是通过mmap来分配内存的,而参数是MAP_ANONYMOUS。所以我觉得这些[anon]是 native heap。从大小上看,现在这些[anon]被看做是Unkown的一部分,也跟hative heap的大小差不多。
在dumpsys meminfo结果的其他值比较大的行,.so表示映射的so库(vm area行的object名称包含.so字样),.dex表示映射的.dex文件(dalvik的虚拟机二进制码),Other dev表示映射其他的/dev的(dalvik的heap也是映射到特殊的/dev上)。加上native和dalvik的heap,下次写如何具体分析 这五项。
Dalvik heap分析和优化——中篇
在上篇中讨论了如果使用adb shell procrank, dumpsys meminfo和showmaps分析进程的内存占用情况。 中篇将继续细化,具体分析导致内存过大的dalvik heap。
Dalkvik heap是最常见的android应用内存优化的对象。
通过上篇的分析,我们可以通过adb shell的命令,知道用了多少dalvik heap。在ADT的eclipse的DDMS视图,可以更细致的查看这些内存用到什么地方。
参考DDMS使用说明(搜索viewing heap),我们可以首先在devices view中选中一个进程,然后enable “update heap“(不带红箭头的半杯水图标),之后在heap view中点击”Cause GC”。这样子除了Heap Size, Allocated, Freed,还可以看到data object,class object,和n-byte array分别占用的内存大小。
不过真心说,这个还是太粗糙了,没法精确到具体的类。此时大名鼎鼎的MAT就派上用场了。
MAT是对java内存镜像进行分析的工具。所以首先需要导出进程的内存镜像,可以在DDMS上的device view点击Dump HPROF file(带红箭头的半杯水图标),生成hprof文件。因为android的文件格式跟通用的java的hprof格式不一样,还需要通过hprof- conv命令来转换。然后就可以用MAT来打开。
看起来挺麻烦的。事实上,现在MAT的eclipse插件可以把上面的工具一键完成。只需要点击Dump HPROF file图标,然后MAT插件就会自动转换格式,并且在eclipse中打开分析结果。eclipse中还专门有个Memory Analysis视图,可以更详细的查看MAT的分析结果。
MAT可以根据内存镜像,以可视化的方式告诉我们哪个类,哪个对象分配了多少内存。但如果只是这样,用处就没那么大了。因为不像c++的对象本身可以存放大量内存,java的对象成员都是些引用。真正的内存都在堆上,看起来是一堆原生的byte[], char[], int[]。所以我们如果只看对象本身的内存,那么数量都很小。我们称之位shallow heap。
于是MAT提出了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)不一定只是一个对象,也可以是多个对象。此时,(A, B)这个组合的Retained Set就包含那块大内存了。对应到MAT的UI中,在Histogram中,可以选择Group By class, superclass or package来选择这个组。(又开始Histogram中不显示Retained heap,需要点击那个计算器的按钮才会计算出来)。这里最小的粒度是类级别的。
为了计算Retained Memory,MAT引入了Dominator Tree。 加入对象A引用B和C,B和C又都引用到D(一个菱形)。此时要计算Retained Memory,A的包括A本身和B,C,D。B和C因为共同引用D,所以他俩的Retained Memory都只是他们本身。D当然也只是自己。我觉得是为了加快计算的速度,MAT改变了对象引用图,而转换成一个对象引用树。在这里例子中,树根是 A,而B,C,D是他的三个儿子。B,C,D不再有相互关系。把引用图变成引用树,计算Retained Heap就会非常方便,显示也非常方便。对应到MAT UI上,在dominator tree这个view中,显示了每个对象的shallow heap和retained heap。然后可以以该节点位树根,一步步的细化看看retained heap到底是用在什么地方了。要说一下的是,这种从图到树的转换确实方便了内存分析,但有时候会让人有些疑惑。本来对象B是对象A的一个成员,但因为B 还被C引用,所以B在树中并不在A下面,而很可能是平级。
为了纠正这点,MAT中点击右键,可以List objects中选择with outgoing references和with incoming references。这是个真正的引用图的概念,表示该对象的出节点(被该对象引用的对象)和入节点(引用到该对象的对象)。
另外一个类似的功能是右键菜单的Path to GC Roots。GC roots是可能导致GC的节点。这个Path则是从这些GC root节点中的某个到当前对象的最短引用路径。对这个如何计算不是很确定,我想应该是根据引用树而不是dominator tree。后面会看到这个功能在非常的有用。
说完工具,下面是具体的减少内存大小。一般要解决两个问题:内存泄露和释放暂时不需要的内存。
Java内存泄露归根结底都是一个原因导致的,应该被释放的对象被生命期更长的对象引用,所以没法被GC。这个生命期更长的对象很常见的是static对象,会持续整个进程。
在个人实际工作中,我会先用adb shell dumpsys meminfo查看dalvik heap会不会持续增长。如果是,我会在在dominator Tree中按照Retained Memory排序,找出比较大的(经常是Bitmap),然后用Path to GC Roots看看其引用情况。在这个Path中,一般会发现我们app自己包的类,可以分析这个类是不是还是需要的。如果不需要,那说明可能存在内存泄露。 此时,在对这个自己包的类查看incoming references。看看到底是哪些引用导致它没有释放。用这种方法,会比较快的发现问题。MAT自己也提供了智能的内存分析工具,我没有用,不好评论。
一个制造内存泄露的很有效的办法是不断的切换横屏和竖屏。现实中很多内存泄露都是因为static的对象指向了Activity对象(作为context传),而切换横屏和竖屏会导致Activity重新生成。所以如果有问题,内存很快就会变大。从编码上讲,avoid-memory-leak这篇文章教育我们,在需要context的地方,尽量使用getApplicationContext,而不是Activity本身。
另外一个可以减少内存的方法是删除临时不用的内存。编码中可能是为了内存cache以提高性能,可能只是偷懒,之前场景使用的内存并没有被释放掉。 这样子下次再回到这个场景,会快一点;但会可能会占用不少内存。我觉得在android这类内存受限的系统上,还是应该谨慎使用空间换时间的策略。如果想删除临时不用的内存,也可以使用MAT像监测内存泄露一样,看看哪些比较大的内存临时不用却仍然被引用,然后删除对其引用。
关于MAT的一个小技巧是MAT经常发现比较大的内存泄露是图片,此时如果知道图片是什么内容就很容易定位到何时导致的内存泄露。这个帖子回答了这个问题。
关于dalvik MAT最后再推荐自己看的一个android memory manage video(slides , content,content2)。里面对MAT和内存泄露都有介绍。这个blog也是对二者都有介绍,很好。关于MAT更好的文档集合在这里,MAT作者写的。
Native Heap分析和优化——下篇
最后一部分是关于native heap,.dex,/dev/other的优化。
android的DDMS可以帮助查看c++ native heap的使用,但需要一定的配置,而且必须是root的手机。
- 在~/.android/ddms.cfg增加”native=true”。这样子ddms才会有native heap的tab。
- 指向下面adb命令打开malloc的debug模式
adb root
adb shell setprop libc.debug.malloc 1
adb shell stop
adb shell start - 打开standalone的DDMS(不是eclipse中那个,是独立的应用程序,sdk目录下有),然后在native heap这个tab下,可以查看native heap的分配情况。
在很多手机上,即使执行了这些命令,还是看不到结果。原因是很多手机上并没有安装debug版本的malloc库(包括libc_malloc_debug_leak.so 和 libc_malloc_debug_qemu.so)。这篇经常被引用的文章介绍了一种方法。是从供大家刷机用的CyanogenMod image中提取这两个文件,然后拷贝到自己的机器上。可以参考那片文章的具体步骤。
下面的问题是只能看到地址而不知道文件名和行号。至少有下面一些办法
- 使用ndk中的arm-linux-androideabi-gdb(android ndk的gdb)来打开.so文件。这里的.so不能使apk中使用的,因为那个已经把symbol给strip了。而应该使用 ***\obj\local\armeabi\***.so,这个是带着symbol的。
然后可以在gdb中使用info symbol 0x000xxxxx来定位到地址对应的函数名。这里的0x000xxxxx是ddms中地址把前三位变成0。因为gdb .so中使用.so的静态地址,而ddms中的地址经过动态链接,是内存的虚拟地址。但动态链接并不改变地址的后五位,所以这里后五位保持不变,前三位变 成0,从而转换为so的静态地址。
然后用info line xxx.cpp:xxx来定位具体行。
这个方法比较繁琐,因为当时自己没找到好办法,就这么用的。 - 用ndk的arm-linux-androideabi-addr2line。后面跟so和0x000xxxxx。跟gdb差不多,会简单一些。
- 有人说设置PATH加上包含addr2line的目录,然后再设置ANDROID_PRODUCT_OUT可以在ddms中直接显示函数名和行号,但没有试过。
/other/dev分析和优化
自己用的是4.2版本的android。每次打开preference setting,/other/dev的private dirty都会增加很多(10M作用),并且不会释放。通过查看smaps,发现是/dev/pvrsrvkm导致的(4.3后设备名改为kgsl-3d0)。这个是显示相关的设备,按我的理解,大概是显存(如果没有独立显存,那是用于显示的内存)。通过网上查询,并不是只有我遇到这个问题。例如chrome也有这个问题。但还是不知道为何这个会增加。在一通乱试后,发现如果对activity设置android:hardwareAccelerated=false,就能解决。此时只增加shared dirty,并且关掉activity,内存会被释放。后来再查,看到stackoverflow上这篇文章,才知道这是4.2的一个bug。4.3和4.1都没有问题。
.Dex mmaps优化
这个是java代码编译只会的.dex文件的大小。
开始自己使用eclipse编译出来的apk作性能分析,发现这个也有几M。但release版本的却不到1M。转念一想,原来是proguard 的作用。proguard是android自带的混淆器,会对java的类名,函数名,变量名等重新命名,给一个非常短的名字。有两个作用,一个是使得反 编译的代码不容易理解,另一个就是减少了dex文件的大小。经过这次内存分析,才发现其效果还是非常明显的。
因为proguard无法对res下的layout,xml文件做混淆,所以他们引用到的java类(例如一些view类)的名字是不能被改变的。所以一个小经验是让xml文件尽量少的引用java类,从而提高混淆的比例。
总结
关于android内存优化,自己就先做了这些。整体思路就是从宏观到微观,利用各种工具和网络资料,从内存占用量最多的模块下手,一步步的分析原因,解决问题。再细化下去,还有很多代码级别的优化,例如perf tips里介绍了很多经验,Memory efficient java也很值得参考。有时间再在这个级别做更多的优化。