Android-性能优化-内存优化

前言

Linus Benedict Torvalds : RTFSC – Read The Funning Source Code

概述

Android 的优化是很重要的,在开发的时候一味往前跑,优先把功能做出来往往会对很多代码妥协。目前Android 手机的性能确实越来越好,从一两百M到现在4G、6G的大内存,但是分配给每个应用的内存还是有限的,所以我们对内存的优化还是要继续下去,一个是为了避免OOM的问题,还有就是减少不必要的内存开销,尽量做到完美嘛。本章将从内存优化的工具开始。如何分析内存,如何通过工具找到内存的消耗点和泄漏点。

内存

内存分类

Android 的内存是指手机的RAM,包括五部分:寄存器、堆、栈、静态存储区/方法区、常量池。

  1. 寄存器(Registers):速度最快的存储场所,因为寄存器位于处理器内部,所以在程序中我们无法控制。
  2. 堆(Heap):在堆上分配内存的过程称作内存动态分配过程。在java中堆用于存放由new创建的对象和数组。堆中分配的内存,由java虚拟机自动垃圾回收器(GC)来管理。堆是不连续的内存区域(因为系统是用链表来存储空闲内存地址,自然不是连续的),堆大小受限于计算机系统中有效的虚拟内存。
  3. 栈(Stack):存放基本类型的对象和引用,但是对象本身不存放在栈中而是存放在堆中。在Java中除了基本类型,都是引用类型。在函数执行的时候,函数内部的局部变量就会在栈上创建,函数执行结束的时候这些存储单元会被自动释放。栈内存分配运算内置于处理器的指令集中是一块连续的内存区域,效率很高,速度快,但是大小是操作系统预定好的所以分配的内存容量有限。
  4. 静态存储区/方法区(Static Field):指在固定的位置上存放应用程序运行时一直存在的数据,java在内存中专门划分了一个静态存储区域来管理一些特殊的数据变量如静态的数据变量。
  5. 常量池(Constant Pool):专门存放常量的区域。JVM虚拟机为每个已经被转载的类型维护一个常量池。常量池就是该类型所有用到地常量的一个有序集合包括直接常量(基本类型,String)和对其他类型、字段和方法的符号引用。

沙箱模型

应用程序进程之间,应用程序与操作系统之间的安全性由Linux操作系统的标准进程级安全机制实现。在默认状态下,应用程序之间无法交互,运行在进程沙箱内的应用程序没有被分配权限,无法访问系统或资源。因此,无论是直接运行于操作系统之上的应用程序,还是运行于Dalvik虚拟机的应用程序都得到同样的安全隔离与保护,被限制在各自“沙箱”内的应用程序互不干扰。对于Android来说每个应用都是在Dalvik虚拟机上运行,各个应用间都是相互隔离。那么就存在了内存的分配不是无限的,每个应用最高分配内存都是固定的而且是越少越好的形式分配。对于应用来说内存的优化就显得十分重要了。

GC Log

在我们Android Studio 的 Logcat里我们可以从日志中看到关于GC 的日志,在第一步分析我们可以先分析下系统对我们的app进行GC的信息。
在 Android 5.0 之前是DVM虚拟机,5.0之后是ART虚拟机,所以日志的格式也分为了两种,但大致信息差不多。

DVM 日志

例子:D/dalvikvm: GC_CONCURRENT freed 2012K, 63% free 3213K/9291K, external 4501K/5161K, paused 2ms+2ms
这是一条典型的 DVM GC 日志。包含了这5个结构:
D/dalvikvm: <GC_Reason> <Amount_freed>, <Heap_stats>, <External_memory_stats>, <Pause_time>
GC_Reason:

  • GC_CONCURRENT:当堆开始填充时,并发GC可以释放内存。
  • GC_FOR_MALLOC:当堆内存已满时,app尝试分配内存而引起的GC,系统必须停止app并回收内存。
  • GC_HPROF_DUMP_HEAP:当你请求创建 HPROF 文件来分析堆内存时出现的GC。
  • GC_EXPLICIT:显示的GC,例如调用System.gc()(应该避免调用显示的GC,信任GC会在需要时运行)。
  • GC_EXTERNAL_ALLOC:仅适用于 API 级别小于等于10 ,用于外部分配内存的GC。

其他信息:

  • Amount_freed:本次GC释放内存的大小。
  • Heap_stats:堆的空闲内存百分比 (已用内存)/(堆的总内存)。
  • External_memory_stats:API 级别 10 及更低级别的内存分配 (已分配的内存)/(引起GC的阀值)。
  • Pause time:暂停时间,更大的堆会有更长的暂停时间。并发暂停时间显示了两个暂停:一个出现在垃圾收集开始时,另一个出现在垃圾收集快要完成时。

ART 日志

ART 日志的收集只在当GC暂停超过5ms的时候才会触发日志。

例子:I/art : Explicit concurrent mark sweep GC freed 104710(7MB) AllocSpace objects, 21(416KB) LOS objects, 33% free, 25MB/38MB, paused 1.230ms total 67.216ms
它包含了这几个信息:
I/art: <GC_Reason> <GC_Name> <Objects_freed>(<Size_freed>) AllocSpace Objects, <Large_objects_freed>(<Large_object_size_freed>) <Heap_stats> LOS objects, <Pause_time(s)>

GC_Reason:

  • Concurrent:并发GC,不会使App的线程暂停,该GC是在后台线程运行的,并不会阻止内存分配。
  • Alloc:当堆内存已满时,App尝试分配内存而引起的GC,这个GC会发生在正在分配内存的线程。
  • Explicit:App显示的请求垃圾收集,例如调用System.gc()。与DVM一样,最佳做法是应该信任GC并避免显示的请求GC,显示的请求GC会阻止分配线程并不必要的浪费 CPU - 周期。如果显式的请求GC导致其他线程被抢占,那么有可能会导致 jank(App同一帧画了多次)。
  • NativeAlloc:Native内存分配时,比如为Bitmaps或者RenderScript分配对象, 这会导致Native内存压力,从而触发GC。
  • CollectorTransition:由堆转换引起的回收,这是运行时切换GC而引起的。收集器转换包括将所有对象从空闲列表空间复制到碰撞指针空间(反之亦然)。当前,收集器转换仅在以下情况下出现:在内存较小的设备上,App将进程状态从可察觉的暂停状态变更为可察觉的非暂停状态(反之亦然)。
  • HomogeneousSpaceCompact:齐性空间压缩是指空闲列表到压缩的空闲列表空间,通常发生在当App已经移动到可察觉的暂停进程状态。这样做的主要原因是减少了内存使用并对堆内存进行碎片整理。
  • DisableMovingGc:不是真正的触发GC原因,发生并发堆压缩时,由于使用了 GetPrimitiveArrayCritical,收集会被阻塞。一般情况下,强烈建议不要使用 - GetPrimitiveArrayCritical,因为它在移动收集器方面具有限制。
  • HeapTrim:不是触发GC原因,但是请注意,收集会一直被阻塞,直到堆内存整理完毕。

GC_Name:

  • Concurrent mark sweep (CMS):CMS收集器是一种以获取最短收集暂停时间为目标收集器,采用了标记-清除算法(Mark-Sweep)实现。 它是完整的堆垃圾收集器,能释放除了Image Space之外的所有的空间。
  • Concurrent partial mark sweep:部分完整的堆垃圾收集器,能释放除了Image Space和Zygote Spaces之外的所有空间。关于Image Space和Zygote Spaces可以查看Android内存优化(一)DVM和ART原理初探这篇文章。
  • Concurrent sticky mark sweep:分代收集器,它只能释放自上次GC以来分配的对象。这个垃圾收集器比一个完整的或部分完整的垃圾收集器扫描的更频繁,因为它更快并且有更短的暂停时间。
  • Marksweep + semispace:非并发的GC,复制GC用于堆转换以及齐性空间压缩(堆碎片整理)。

其他信息:

  • Objects freed:本次GC从非Large Object Space中回收的对象的数量。
  • Size_freed:本次GC从非Large Object Space中回收的字节数。
  • Large objects freed: 本次GC从Large Object Space中回收的对象的数量。
  • Large object size freed:本次GC从Large Object Space中回收的字节数。
  • Heap stats:堆的空闲内存百分比 (已用内存)/(堆的总内存)。
  • Pause times:暂停时间,暂停时间与在GC运行时修改的对象引用的数量成比例。目前,ART的CMS收集器仅有一次暂停,它出现GC的结尾附近。移动的垃圾收集器暂停时间会很长,会在大部分垃圾回收期间持续出现。

所以当我们app卡顿的时候可以考虑下是否因为频繁申请内存导致虚拟机gc频繁操作而耗时。

内存优化方法

内存优化我们要做两点,首先要问有没有内存问题,然后这个问题要怎么解决。

第一步:有没有内存问题

查找有没有内存问题的第一步是查看内存,直观的能看到内存到底是涨还是跌,才好分析内存是否健康。

  1. adb shell ps
  2. adb shell dumpsys meminfo {package name}
  3. Android Monitor / Memory
  4. MAT
  5. LeakCanary

这五个方法大致可以看出有没有内存问题,接下来详细解说下这几个方法。

adb shell ps

这个方法我们是通过手机连接电脑,在电脑上再通过 adb 的形式获取的。
adb shell ps

这里的几个字段的意思分别为:
USER: 进程当前用户。
PID: Process ID,进程ID。
PPID: Process Parent ID,进程的父进程ID。
VSIZE: Virtual Size,进程的虚拟内存大小。
RSS: Resident Set Size,实际驻留”在内存中”的内存大小。
WCHAN: 休眠进程在内核中的地址。
PC: Program Counter。
NAME: 进程名。

通过看 RSS 的字段我们就可以看出内存的大小,通过不断的测试我们的功能再看内存,就可以发现那个地方的内存有暴涨,当然这个方法比较难发现问题,不建议使用。

adb shell dumpsys meminfo

这个方法也是通过连接电脑来使用 adb 形式来获取内存的。
adb shell dumpsys meminfo

这里的几个字段的意思分别为:
Naitve Heap Size: 从mallinfo usmblks获得,代表最大总共分配空间。
Native Heap Alloc: 从mallinfo uorblks获得,总共分配空间。
Native Heap Free: 从mallinfo fordblks获得,代表总共剩余空间。
Native Heap Size 约等于Native Heap Alloc + Native Heap Free。

Dalvik Heap Size:从Runtime totalMemory()获得,Dalvik Heap总共的内存大小。
Dalvik Heap Alloc: Runtime totalMemory()-freeMemory() ,Dalvik Heap分配的内存大小。
Dalvik Heap Free:从Runtime freeMemory()获得,Dalvik Heap剩余的内存大小。
Dalvik Heap Size 约等于Dalvik Heap Alloc + Dalvik Heap Free。

同样的这种方法来发现内存问题太痛苦了,不建议使用。

Android Monitor / Memory

这个方法比较有效,划重点的来了。当然Android Monitor下面是有两个功能的,一个logcat,一个就是我们的Memory了。
Android Monitor

这是我们Android Monitor的界面,很简洁,还是可视化的,非常好。横坐标是时间,纵坐标是内存使用量。
那么在这里我们就可以看到内存的使用情况了,而且还一目了然。

Android Monitor3
说下这三个按钮,第一个是GC,也就是调用回收内存的方法。第二个是dump java heap,把java的堆栈给收集的方法。第三个是Allocation Tracking,追踪内存分配信息。

当我们查看内存的时候就可以选择dump java heap,将一段内存收集起来。
Android Monitor4
收集完后就可以在界面上看到具体的收集情况了。从中我们可以从 class view 的角度去分析内存,也可以从 package view 的角度去分析。

MAT

这个MAT比较具体,可以分析出很多东西,使用这个一般是比较后期的优化了,因为从前期的泄漏和大块内存的冗余都可以直接从Android Monitor这个工具上很方便看出。
使用MAT也比较简单,在Android Monitor中使用dump java heap收集到一个hprof的文件后右键,将它设置为标准文件保存。
Android MAT

在我们导入进去后我们可以发现很直接的就把各种性能给你展现出来了。那么通过它也可以快速发现内存的占用。
这里先献上MAT 使用手册给大家使用。

LeakCanary

LeakCanary是square出的一款开源的用来做内存泄露检测的工具。
LeakCanary

具体使用参考LeakCanary README文档。

好,通过这些工具我们就可以有效的分析出到底有没有内存的问题了,如果发现内存有暴涨并且没法释放,那肯定是有内存泄漏。如果发现某一个模块突然占用大量的内存,那这块可能存在内存冗余的问题。

第二步:找到内存问题

如果我们发现之前说的那些现象,那么我们大体可以确定是有内存问题的。接下来用Monitor为例子来说下怎么查找到内存泄漏点。

Step 1:打开Monitor,不停使用某一个功能

首先我们打开Monitor 的内存板块,并且不停的点击GC按钮,使得这个时段的内存一定是最平稳的。接下来我们找一个目标模块开始不停的测试它。在测试一段时间后会发现内存涨了不少,这时候就可以停止测试,并且回到APP的首页。回到首页后继续狂点我们的GC按钮。留意内存,内存如果回落到刚刚的大小或者差不多的大小,那么可以认为是正常的。

很可惜我在项目中第一个测试的模块就发现了很严重的问题。
monitor
看到这里,我不停的让应用GC,但程序内存一点都没降,还保持在100+M的高位。这就是找到一个内存泄漏点了。

Step 2:通过dump java heap把问题点定位

找到这个泄漏点后我们选择第二个按钮:dump java heap,把当前内存状况抓取下来。
Android Monitor4
我们打开后调整到package视图,找到我们的com 文件夹开始从上往下看。这时候我们是看到图片缓存非常大,而且在里面有非常多的对象被持有者没有释放。好,回到那个工程,我们从图片哪里排查可以发现因为图片用了一个算法,而那个算法把持了activity的对象,导致内存没法释放。还有其他一些点我们也顺路做了修改。

通过这种方式基本可以把大的内存泄漏点排查出来了。接下来我们分析第二个需要排查的问题,内存冗余。

Step 3: 接着分析dump 找到里面内存大的部位

因为一个应用出的问题只会在其中20%代码引起的,所以找到内存冗余也是从这20%着手。
在分析dump java heap 的其中我发现有四个图片占据了高位,在没有泄漏的大内存后,就它的内存占用高,我们先尝试的把这几张图片去掉,结果发现内存果然降了下来,开心了。接下来就很简单了,代码层面花点时间优化它,最后的结果是把内存降了整整18M

Android save
最后的内存花销降低非常多,效果也非常不错。

总结

内存优化是一件长期的任务,因为代码每天都在写。各种工具也只是帮你找到优化点,重点还是在每个功能的制作中也要有优化的时间。内存问题的分析, 分析对象的内存占用, 找出Retained Size大的对象, 找到其直接支配, 跟踪其GC可达路径, 从而找到是谁让这个大对象活着并将其尽可能的优化。