Android性能优化盘点 - 内存优化

内存优化是性能优化的重头戏,因此这部分也花了很多时间来梳理。老规矩,先上大纲:


内存优化大纲
一、基础知识
1.1 Android内存管理框架:
Android内存管理框架

这里针对上图进行简单描述:

1)物理地址与虚拟地址:

虚拟内存是程序和物理内存之间引入的中间层,目的是解决直接使用物理内存带来的安全性问题、超过物理内存大小需求无法满足等等问题。而Linux的内存管理就是建立在虚拟内存之上的。虚拟地址与物理地址通过页表建立映射关系,CPU通过MMU(Memory Management Unit :内存管理单元)访问页表来查询虚拟地址对应的物理地址。虚拟地址分为内核空间和用户空间,它们对应的虚拟地址分别为进程共享和进程隔离的。

2)内核空间内存管理:

内核把page作为内存管理的基本单位。对特性不同的page又以zone来做划分,zone又由node来管理。

以32位为例,主要关注的区有3个:

描述
ZONE_DMA 直接内存访问,无需映射
ZONE_NORMAL 一一对应映射页
ZONE_HIGHMEM 动态映射页

每个zone中内存的组织形式是基于buddy伙伴算法,把空闲的page以2的n次方为单位进行管理。因此Linux最底层的内存申请都是以2的n次方为单位来申请page的。Buddy伙伴算法以产生内部碎片为代价来避免外部碎片的产生。 Linux针对大内存的物理地址分配,采用Buddy伙伴算法,如果是针对小于一个page的内存,频繁的分配和释放,则不宜用Buddy伙伴算法,取而代之的是Slab。Slab是为频繁分配/释放的对象建立高速缓存。

ION是内存管理器,用来支持不同的内存分配机制,如CARVOUT(PMEM),物理连续内存(kmalloc), 虚拟地址连续但物理不连续内存(vmalloc), IOMMU等。用户空间和内核空间都可以使用ION,用户空间是通过/dev/ion来创建client的。

3)用户空间内存管理:

用户空间主要分两部分,一个是面向C++的native层,一个是基于虚拟机的java层。
native内存划分:

  • Data 用于保存全局变量
  • Bss 用于保存全局未初始化变量
  • Code 程序代码段
  • Stack 线程函数执行的内存
  • Heap malloc分配管理的内存

java基于虚拟机的内存划分:

  • Program Counter Register 它是一个指针,指向执行引擎正在执行的指令的地址。
  • VM stack 基于方法中的局部变量,包括基本数据类型以及对象引用等。
  • Native Method Stack 针对native方法,功能与虚拟机栈一致。
  • Method Area 虚拟机加载的类信息、常量、静态变量等。
  • Heap 对象实体。
Heap 分配 手动释放 自动回收 实现
c malloc free libc
c++ new delete 可选/智能指针 可重构
java new 支持 jvm
1.2 linux内存分配与回收
内存分配:

在调用alloc_page()或者alloc_pages()等接口进行一次内存分配时,最后都会调用到__alloc_pages_nodemask()函数,这个函数是内存分配的心脏,对内存分配流程做了一个整体的组织。该流程牵涉到的分配过程有两个:

  • 快速内存分配:是get_page_from_freelist()函数,通过low阀值从zonelist中获取合适的zone进行分配,如果zone没有达到low阀值,则会进行快速内存回收,快速内存回收后再尝试分配。

  • 慢速内存分配:当快速分配失败后,也就是zonelist中所有zone在快速分配中都没有获取到内存,则会使用min阀值进行慢速分配,在慢速分配(slow path)过程中主要做三件事,异步内存压缩、直接内存回收以及轻同步内存压缩,最后视情况进行oom分配。并且在这些操作完成后,都会调用一次快速内存分配尝试获取页框。

内存回收:

内存回收是以zone为单位进行的(也会以memcg为单位,这里不讨论这种情况),而系统判断一个zone需不需要进行内存回收是由水线watermark来判断的。

  • high 当zone的空闲页框数量高于这个值时,表示zone的空闲页框较多,不需要再继续进行内存回收。
  • low 快速分配的默认阀值,在分配内存过程中,如果zone的空闲页框数量低于此阀值,系统会对zone执行快速内存回收。
  • min 在快速分配失败后的慢速分配中会使用此阀值进行分配,如果慢速分配过程中使用此值还是无法进行分配,那就会执行直接内存回收和快速内存回收。

当linux系统内存压力就大时,就会对系统的每个压力大的zone进程内存回收,它针对三样东西进程回收:slab、lru链表中的页、buffer_head。这里主要看lru链表中的页是怎么回收的。lru链表主要用于管理进程空间中使用的页,类型分为:文件页、匿名页、shmem页。

  • 文件页(file-backed page):有文件背景页面。可以直接和硬盘对应的文件进行交换。
  • 匿名页(anonymous page):无文件背景页面。如进程堆、栈、数据段使用的页等,无法直接跟磁盘交换,但是可以跟swap区进行交换。
  • mmap页(tmpfs/shmem的page):它具有文件的属性,能够像操作文件一样去操作它。但是它无文件背景,因此也有匿名页属性,内核在内存紧缺时不能简单的将page从它们的page cache中丢弃,而需要swap-out。
三种文件页回收对比,图片出处:[linux内核tmpfs/shmem浅析]
Lru链表回收算法

Lru链表有5个双向链表:LRU_INACTIVE_ANONLRU_ACTIVE_ANONLRU_INACTIVE_FILELRU_ACTIVE_FILELRU_UNEVICTABLE

老化过程:将不处于lru链表的新页放入到lru链表中->将处于活动lru链表的页移动到非活动lru链表->将非活动lru链表中的页移动到非活动lru链表尾部->回收页然后将页从lru链表中移除。

页回收方式
  • 页回写:文件页保存的数据与磁盘中文件对应的数据不一致,则认定此文件页为脏页,需要先将此文件页回写到磁盘中对应数据所在位置上,然后再将此页作为空闲页框释放到伙伴系统中。
  • 页交换:不经常使用的匿名页,将它们写入到swap分区中,然后作为空闲页框释放到伙伴系统。
  • 页丢弃:文件页中保存的内容与磁盘中文件对应内容一致,说明此文件页是一个干净的文件页,就不需要进行回写,直接将此页作为空闲页框释放到伙伴系统中。

当内存紧张时,优先换出无脏数据的page cache(文件页包含page cache),直接丢弃。其次才是匿名页和有脏数据的文件页的回收。遵循URL老化规则。通过Swappiness来确定更倾向于回收哪种更多一点,swappiness越大,越倾向于回收匿名页,反之越倾向于回收文件页。

内存回收手段

因为在不同的内存分配路径中,会触发不同的内存回收方式,内存回收针对的目标有两种,一种是针对zone的,另一种是针对一个memcg的,而这里我们只讨论针对zone的内存回收,个人把针对zone的内存回收方式分为三种,分别是快速内存回收、直接内存回收、kswapd内存回收。

  • 快速内存回收:处于get_page_from_freelist()函数中,在遍历zonelist过程中,对每个zone都在分配前进行判断,如果分配后zone的空闲内存数量 < 阀值 + 保留页框数量,那么此zone就会进行快速内存回收,即使分配前此zone空闲页框数量都没有达到阀值,都会进行此zone的快速内存回收。注意阀值可能是min/low/high的任何一种,因为在快速内存分配,慢速内存分配和oom分配过程中如果回收的页框足够,都会调用到get_page_from_freelist()函数,所以快速内存回收不仅仅发生在快速内存分配中,在慢速内存分配过程中也会发生。

  • 直接内存回收:处于慢速分配过程中,直接内存回收只有一种情况下会使用,在慢速分配中无法从zonelist的所有zone中以min阀值分配页框,并且进行异步内存压缩后,还是无法分配到页框的时候,就对zonelist中的所有zone进行一次直接内存回收。注意,直接内存回收是针对zonelist中的所有zone的,它并不像快速内存回收和kswapd内存回收,只会对zonelist中空闲页框不达标的zone进行内存回收。并且在直接内存回收中,有可能唤醒flush内核线程。

  • kswapd内存回收:发生在kswapd内核线程中,每个node有一个swapd内核线程,也就是kswapd内核线程中的内存回收,是只针对所在node的,并且只会对 分配了order页框数量后空闲页框数量 < 此zone的high阀值 + 保留页框数量 的zone进行内存回收,并不会对此node的所有zone进行内存回收。

这三种内存回收虽然是在不同状态下会被触发,但是如果当内存不足时,kswapd内存回收和直接内存回收很大可能是在并发的进行内存回收的。而实际上,这三种回收再怎么不同,进行内存回收的执行代码是一样的,只是在内存回收前做的一些处理和判断不同。

alloc_page触发分配连续物理内存流程

内存分配过程,在调用alloc_page()或者alloc_pages()等接口进行一次内存分配时,最终都会调用到__alloc_pages_nodemask()函数,先尝试使用low阀值的快速内存分配,遍历zonelist,判断是否有zone满足分配连续页框,如果不满足走快速内存回收。如果快速内存分配失败,代表当前已经无法分配出连续物理内存,怎么进入slowpath慢速内存分配流程,先唤醒所有node的kswapd内核线程,尝试以min阀值进行快速内存分配,如果做不到则触发kswapd内存回收,还是回收不到怎么通过压缩规整系统可移动页,如果还是不满足,则对zonelist中的所有zone进行一次直接内存回收(这里不同于快速内存回收和kswapd内存回收,只会对zonelist中空闲页框不达标的zone进行内存回收)。最后如果还是无法回收到满足条件的内存,那就触发oom。

1.3 Art虚拟机内存分配与回收
Art堆划分:

Image Space 连续地址空间,不进行垃圾回收,存放系统预加载类,而这些对象是存放system@framework@boot.art@classes.oat这个OAT文件中的,每次开机启动只需把系统类映射到Image Space。
Zygote Space 连续地址空间,匿名共享内存,进行垃圾回收,管理Zygote进程在启动过程中预加载和创建的各种对象、资源。
Allocation Space 与Zygote Space性质一致,在Zygote进程fork第一个子进程之前,就会把Zygote Space一分为二,原来的已经被使用的那部分堆还叫Zygote Space,而未使用的那部分堆就叫Allocation Space。以后的对象都在Allocation Space上分配。
Large Object Space 离散地址空间,进行垃圾回收,用来分配一些大于12K的大对象。

注:Image Space和Zygote Space在Zygote进程和应用程序进程之间进行共享,而Allocation Space就每个进程都独立地拥有一份。注意,虽然Image Space和Zygote Space都是在Zygote进程和应用程序进程之间进行共享,但是前者的对象只创建一次,而后者的对象需要在系统每次启动时根据运行情况都重新创建一遍。

当满足以下三个条件时,在large object heap上分配,否则在zygote或者allocation space上分配:

  • 请求分配的内存大于等于Heap类的成员变量large_object_threshold_指定的值。这个值等于3 * kPageSize,即3个页面的大小。
  • 已经从Zygote Space划分出Allocation Space,即Heap类的成员变量have_zygote_space_的值等于true。
  • 被分配的对象是一个原子类型数组,即byte数组、int数组和boolean数组等。

Art运行时为新创建对象分配内存过程:

图片出自罗升阳[ART运行时为新创建对象分配内存的过程分析]

执行GC的三个阶段:

  • 阶段一:首先会进行一次轻量级的GC, GC完成后尝试分配。如果分配失败,则选取下一个GC策略,再进行一次轻量级GC。每次GC完成后都尝试分配,直到三种GC策略都被轮询了一遍还是不能完成分配,则进入下一阶段。
  • 阶段二:允许堆进行增长的情况下进行对象的分配。
  • 阶段三:进行一次允许回收软引用的GC的情况下进行对象的分配。

这里牵涉到几种引用类型:
强引用(StrongReference):JVM 宁可抛出 OOM ,也不会让 GC 回收具有强引用的对象;
软引用(SoftReference):只有在内存空间不足时,才会被回的对象;
弱引用(WeakReference):在 GC 时,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存;
虚引用(PhantomReference):任何时候都可以被GC回收,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否存在该对象的虚引用,来了解这个对象是否将要被回收。可以用来作为GC回收Object的标志。

Art GC
与GC有关参数

[dalvik.vm.heapgrowthlimit]: [256m] 默认情况下, App可使用的Heap的最大值, 超过这个值就会产生OOM。
[dalvik.vm.heapsize]: [512m] 如果App的manifest配置了largeHeap属性, 则App可使用的Heap的最大值为此项设定值。
[dalvik.vm.heapstartsize]: [8m] App启动后, 系统分配给它的Heap初始大小. 随着App使用会增加。

[dalvik.vm.heapmaxfree]: [8m] GC后,堆最大空闲值
[dalvik.vm.heapminfree]: [512k] GC后,堆最小空闲值
[dalvik.vm.heaptargetutilization]:[0.75] GC后,堆目标利用率
这三个指标动态调整堆的大小,预留一定空间用于下个对象申请,多余空间还给系统。

虚拟机主流GC算法:

引用计数算法(jdk1.2之前):堆中的每个对象对应一个引用计数器,创建对象置为1,每次引用到此对象+1,其中一个引用销毁-1,变为0即满足回收。致命缺点:循环引用的对象无法进行回收。

可达性算法(jdk1.2之后):确定GC root,寻找路径可达的引用节点,形成可达性树,不在树上的节点即满足回收条件。

  • 标记-清除算法(Mark-Sweep):遍历所有的GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。对没标记的对象全部清除。
    优点:对不存活对象进行处理,在存活对象高的情况下非常高效。
    缺点:清除对象不会整理,造成内存碎片,这部分内存碎片属于内部碎片。

  • 复制算法(Coping): 遍历所有的GC Roots,将可达的对象复制到另一块内存空间,遍历完后清空原来的内存空间(剩下的都是不可达对象)。
    优点:对可达对象进行复制,在存活的对象比较少时极为高效。
    缺点:需要额外的内存空间。

  • 标记-整理算法(Mark-Compact):在标记-清除算法基础上,增加存活对象内存整理。
    优点:不造成内存碎片,也不需要额外内存空间
    缺点:整理过程耗时,效率不高。

Art的三种GC策略

Sticky GC :只回收上一次GC到本次GC之间申请的内存。
Partial GC:局部垃圾回收,除了Image Space和Zygote Space空间以外的其他内存垃圾。
Full GC: 全局垃圾回收,除了Image Space之外的Space的内存垃圾。

策略的对比:(gc pause 时间越长,对应用的影响越大)
GC 暂停时间:Sticky GC < Partial GC < Full GC
回收垃圾的效率:Sticky GC > Partial GC > Full GC

前后台GC
应用从前台切到后台,从后台切到前台都会发生一次gc,应用程序在前台运行时,响应性是最重要的,因此也要求执行的GC是高效的。相反,应用程序在后台运行时,响应性不是最重要的,这时候就适合用来解决堆的内存碎片问题。因此,Mark-Sweep GC适合作为Foreground GC,而Compacting GC适合作为Background GC。

Art运行时GC过程:
图片出自罗升阳[ART运行时垃圾收集(GC)过程分析]

非并行GC
1)调用子类实现的成员函数InitializePhase执行GC初始化阶段。
2)挂起所有的ART运行时线程。
3)调用子类实现的成员函数MarkingPhase执行GC标记阶段。
4)调用子类实现的成员函数ReclaimPhase执行GC回收阶段。
5)恢复第2步挂起的ART运行时线程。
6)调用子类实现的成员函数FinishPhase执行GC结束阶段。

除了当前执行GC的线程之外,其它的ART运行时线程都会被挂起,整个标记过程会稍长。

并行GC:
1)调用子类实现的成员函数InitializePhase执行GC初始化阶段。
2)获取用于访问Java堆的锁。
3)调用子类实现的成员函数MarkingPhase执行GC并行标记阶段。
4)释放用于访问Java堆的锁。
5)挂起所有的ART运行时线程。
6)调用子类实现的成员函数HandleDirtyObjectsPhase处理在GC并行标记阶段被修改的对象。
7)恢复第4步挂起的ART运行时线程。
8)重复第5到第7步,直到所有在GC并行阶段被修改的对象都处理完成。
9)获取用于访问Java堆的锁。
10)调用子类实现的成员函数ReclaimPhase执行GC回收阶段。
11)释放用于访问Java堆的锁。
12)调用子类实现的成员函数FinishPhase执行GC结束阶段。

ART运行时充分地利用了设备的CPU多核特性,在并行GC的执行过程中,将每一个并发阶段的工作划分成多个子任务,然后提交给一个线程池执行,这样就可以更高效率地完成整个GC过程,缩短了gc 暂停时间,避免长时间暂停对应用程序造成停顿。

注:串行执行效率太低了,现在Art虚拟机默认都是并行GC。

GC打印log分析:

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:GC触发原因

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

具体可参考:art/runtime/gc/gc_cause.h

GC_Name:垃圾收集器名称

  • Concurrent mark sweep (CMS):CMS收集器是一种以获取最短收集暂停时间为目标收集器,采用了标记-清除算法(Mark-Sweep)实现。 它是完整的堆垃圾收集器,能释放除了Image Space之外的所有的空间。
  • Concurrent partial mark sweep:部分完整的堆垃圾收集器,能释放除了Image Space和Zygote Spaces之外的所有空间。
  • 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:暂停时间。
1.4 Android内存回收主要手段
应用层:

onTrimMemory 由lmk触发的,AMS抛出的应用层面的回调,目的是让应用程序配合做一些内存释放工作,同时也可以作为系统内存过低的监听。

gc 虚拟机层面的垃圾回收内存。

系统层:

Process.kill 通过signal 9 kill进程,释放占用的内存。

lmk(lowmemorykiller)在内存不足时杀掉优先级较低的进程来回收内存的策略。具体内容可以参考之前文章lowmemorykiller总结
lmk演变:

  • android 8.1之前 -kernelspace lmk 监听:kswapd触发的shrink回调
  • android 8.1 - 9.0 -userspace lmk 监听:vmpressure
  • android 10 -userspace lmk 监听:PSI(Pressure stall information)

app compaction Android 10版本引入的,AMS与Kernel层联动对满足一定条件的App进行内存压缩,在保证后台进程尽量不被杀的基础上减少它们的内存占用。具体内容可参考之前文章 app compaction

kswapd 根据水线进行周期内存回收。对当前非活跃lru链表链尾的文件页、匿名页进行回收,其中匿名页回收会写入zram区间。

这里单独介绍下zram:
zram是Linux内核的一项功能, 它将内存的部分区域划分为压缩空间, 在内存较低时先通过内存压缩来变现获取更多内存使用空间, 耗尽之后才使用磁盘, 变相提高内存利用率。本身会消耗部分CPU占用率来换内存空间的相对增加。

cat proc/meminfo

SwapTotal:       2306044 kB    zram配置大小
SwapFree:        1737380 kB 当前zram剩余大小

如果为0,则表明zram没有打开。

二、分析工具
1)adb命令

对应用进程和系统整体内存状态做一个宏观把控。
具体分析参考之前文章:性能优化工具(十)- Android内存分析命令

2)Memory Profiler

操作应用程序过程中,以实时图表的形式反馈当前的内存情况,对像明显的内存抖动、内存泄漏能做一个初步分析。
具体使用参数之前文章:性能优化工具(十三)-使用 Memory Profiler 查看 Java 堆和内存分配

3)leakCanary

傻瓜式内存泄漏检测工具,对于Activity/Fragment的内存泄漏检测非常好用。
具体使用参考之前文章:性能优化工具(九)-LeakCanary

4)MAT

内存问题全面分析工具,也可以说是兜底工具,使用相对复杂点。
具体使用参考之前文章:性能优化工具(八)-MAT

三、内存问题分析

常规内存问题主要分三种:

内存溢出:指程序在申请内存时,没有足够的内存空间供其使用,造成OOM。往往是由内存抖动、内存泄漏等问题量变到质变之后出现。

内存溢出伴随的是crash日志,它分两种情况:一种是大对象直接造成的OOM,比如bitmap,这种看报错日志就能定位到问题,比较好解决; 还有一种是存在内存泄漏,压死骆驼的最后一根稻草报的crash信息并不能准确反映出oom的真正问题。可以结合内存泄漏一起分析。

内存抖动:短时间内有大量的对象被创建或者被回收。频繁创建和销毁对象造成频繁GC, 导致内存不足及碎片。

内存抖动mem profile 锯齿图

内存抖动主要就是循环或者频繁调用处短时间内创建和销毁大量对象,这里值得警惕的是频繁调用点的字符串”+”拼接的log日志,还有用ArrayList做大量的非尾部remove操作等。

内存泄漏:指程序在申请内存后,无法释放已申请的内存空间。造成可用内存逐渐减少。

内存泄漏mem profile 内存逐步增长图

内存泄漏分析整体思路:
首先通过adb命令对整个内存状况做个宏观把握:
cat proc/meminfo

MemTotal:        2914764 kB
MemFree:           78008 kB 系统空闲内存(系统尚未被使用的,total-free = used)
MemAvailable:     440972 kB 可用内存(memfree + 可回收内存(部分buffer/cached,slab也能回收一部分))
...
SwapTotal:       1048572 kB 交换空间的总大小(设置的zram交换空间大小)
SwapFree:         471124 kB 未被使用交换空间的大小
...
Slab:             176044 kB 内核中slab分配的内存大小(slab = SReclaimable+SUnreclaim)
SReclaimable:      55528 kB 可收回Slab的内存大小
SUnreclaim:       120516 kB 不可收回Slab的内存大小

这里主要关注:
系统剩余内存MemAvailable,如果比较低代表当前系统内存整体不足;Zram开没开,SwapFree还剩余多少;Slab占用内存多大,其中SUnreclaim部分占用内存是多少,看是否有kernel泄漏。

dumpsys meminfo

Total PSS by process:         Java层存活的进程及其占用内存情况
   241,086K: system (pid 1479)
   161,423K: surfaceflinger (pid 544)
   137,754K: com.android.systemui (pid 4843 / activities)
   ...
Total PSS by OOM adjustment:  Native存活的进程及其占用内存情况
   376,783K: Native
       161,423K: surfaceflinger (pid 544)
        14,303K: audioserver (pid 725)
         9,247K: zygote (pid 719)
   ...
   576,007K: Persistent      按进程优先级分别来统计对应的进程及其内存使用情况
       241,086K: system (pid 1479)
   ...
   219,381K: Foreground
       167,657K: com.tengxin.youqianji (pid 29421 / activities)
   ...
   317,970K: B Services
        33,115K: com.UCMobile:channel (pid 25225)
   ...
   410,541K: Cached
        36,294K: com.android.vending (pid 13418)
   ...

这里主要看下当前存活的进程是否有占用内存明显异常的,另外看看不同优先级进程的比例如何,如果当前可用内存比较低,但是B services和cache类型进程数量还比较高,那得看framework内存管理策略是否有问题。一般来说,内存比较低时,lmk会从cache开始杀进程,并且b services进程会降级为cache,变相增加lmk第一档查杀数量。

dumpsys meminfo pid

** MEMINFO in pid 1479 [system] **
                  Pss  Private  Private  SwapPss     Heap     Heap     Heap
                Total    Dirty    Clean    Dirty     Size    Alloc     Free
               ------   ------   ------   ------   ------   ------   ------
  Native Heap    26106    25984      104     8739    73728    34267    39460
  Dalvik Heap    63706    63676        4     1858    56528    40144    16384
  Dalvik Other     6704     6668       12       24                         
       Stack     2388     2364       24     1352                         
      Ashmem     8004     8000        0        0                         
     Gfx dev      532      124        0        0                         
   Other dev       55        8       36        0                         
    .so mmap     1561      444      780      488                         
   .jar mmap        0        0        0        8                         
   .apk mmap      145        0        0        0                         
   .dex mmap    28702        0     4428       40                         
   .oat mmap    78494        0    49992        0                         
   .art mmap     3068     2684       76      856                         
  Other mmap       34        4        0        0                         
   GL mtrack     1424     1424        0        0                         
     Unknown     1275     1084      188     2882                         

       TOTAL   238445   112464    55644    16247   130256    74411    55844

App Summary
                      Pss(KB)
                       ------
          Java Heap:    66436 从Java或Kotlin代码分配的对象内存。受dalvik.vm相关配置限制
        Native Heap:    25984 从C或C ++代码分配的对象内存。不受限
               Code:    55644 应用用于处理代码和资源(如dex字节码,已优化或已编译的dex码,.so库和字体)的内存。
              Stack:     2364 应用中的原生堆栈和Java堆栈使用的内存。这通常与您的应用运行多少线程有关。
           Graphics:     1548 图形缓冲区队列向屏幕显示像素所使用的内存。这是与CPU共享的内存,不是GPU专用内存。
      Private Other:    16132
             System:    70337
              TOTAL:   238445       TOTAL SWAP PSS:    16247

Objects
              Views:        3         ViewRootImpl:        1
        AppContexts:       20           Activities:        0 当前存活的activity
             Assets:        8        AssetManagers:        6
      Local Binders:      888        Proxy Binders:     1652
      Parcel memory:     1697         Parcel count:      751
   Death Recipients:      753      OpenSSL Sockets:        0

如果在dumpsys meminfo中发现明显内存异常的进程,那么直接dump对应的进程详细内存分配数据,看看是什么原因。Java、Native或者Graphics图形占据内存比较高,是否存在泄漏情况,另外关注Activities,比如以前就看到过由相机进入相册重复创建activity的情况,造成大量activity泄漏。

另外还可以关注下当前系统内存碎片情况:

cat /proc/pagetypeinfo

   Number of blocks   type       Unmovable      Movable     Reclaimable    CMA    HighAtomic    Isolate 
      Node 0, zone       DMA         167          239             8         43          0         0 
      Node 0, zone       Normal      228          266             11         0          1         0

Unmovable 超过总数的20%,可能存在内存碎片。

另外还可以dumpsys cpuinfo 关注下当前kswapd0 cpu占用率 ,侧面反映内存不足。

在有现场和有有效的bugreport信息的情况下,通过adb命令分析,基本对内存问题做一个基本面的判断,是否存在内存不足,如果内存不足是哪个层面的问题?user space内存问题 还是kernel space内存问题,user space的话,看是系统进程内存问题,还是应用进程内存问题,同时也还能细分到是java heap的问题 还是native heap的问题。

那么总结下,最终问题会分为三类:java heap问题、native heap问题、kernel问题。

1)java内存泄漏分析

Android提供了hprof机制,通过MAT进行定位分析。

分析步骤:
首先如果是android studio抓取的hprof文件,需要做下转换才能在MAT上打开:hprof-conv 源文件路径 转换文件路径,然后通过MAT来分析转换后的hprof文件,MAT提供了若干分析视角:

  • histogram 基于类的角度
    列举所有对象情况,可以group by class、package等切换视角。可以在里面具体检索某个类,对类可以看它当前的引用关系:outgoing 我引用了哪些类 ,incoming 哪些类引用了我 内存泄漏看这个。
    基本数据字段说明:
    object 对象数目
    shallow 对象自己占多少内存
    retained 在我这个引用链之上,对象总共占用多少内存
  • dominator_tree基于对象的角度
    每个对象的支配数,percentage 是对象在所有对象中占的百分比。
  • top consumers 占用内存比较高的对象
  • leak suspects 内存泄漏怀疑点
  • OQL sql操作
2)native内存泄漏分析

Android提供了分析native进程内存泄漏的方法---malloc debug。
详细使用参考kernel文档:bionic/libc/malloc_debug/README.md

步骤:
1)adb root
2)adb shell setenforce 0
3)adb shell chmod 0777 /data/local/tmp
4)adb shell setprop libc.debug.malloc.program app_process64 //跟踪zygote及zygote的子进程
5)adb shell setprop libc.debug.malloc.options "backtrace_enable_on_signal leak_track"
6)adb shell stop
7)adb shell start
8)adb shell kill -45 <需要跟踪的进程号> //enable backtrace
9)adb shell kill -47 <需要跟踪的进程号> //在/data/locat/tmp/目录下会生成名为backtrace_heap.<pid>.txt,logcat中也会打印出内存泄漏调用栈信息,如下图片所示


当复现出问题时,可以通过几次抓取的log,对比找出一直malloc没有free的调用栈,即内存泄漏点。
10)使用native_heapdump_viewer.py解析backtrace_heap生成.html文件,分析内存占用情况。

以9.0的代码为例,调试audioserver进程

1)adb root
2) # setenforce 0  //避免由于selinux权限问题,无法生成heap文件
3) # setprop libc.debug.malloc.program audioserver // 调试audioserver进程
4) # setprop libc.debug.malloc.options "backtrace_enable_on_signal leak_track"
5) #kill -9 pid_ audioserver // 杀掉audioserver进程, 该进程会重启
logcat日志会有如下信息:
# logcat -v time | grep malloc_debug
09-26 20:19:41.573 I/malloc_debug( 6934): /system/bin/audioserver: Run: 'kill -45 6934' to enable backtracing.
09-26 20:19:41.573 I/malloc_debug( 6934): /system/bin/audioserver: Run: 'kill -47 6934' to dump the backtrace.
6) #kill -45 pid_ audioserver_new(6934) // 使能backtrace,然后复测内存泄漏问题,当audioserver进程存在内存泄漏时,执行下一步生成内存泄漏的backtrace
7) # kill -47 pid_ audioserver_new(6934)  // 在/data/local/tmp目录下面生成backtrace_heap文件
/data/local/tmp # ls -al
-rw------- 1 audioserver  audio       89704 2019-09-26 20:30 backtrace_heap.6934.txt
8)使用native_heapdump_viewer.py解析backtrace_heap.6934.txt
命令如下:
python development/scripts/native_heapdump_viewer.py --verbose --html backtrace_heap.6934.txt --symbols ./out/target/product/raphael/symbols > backtrace_heap.html

在调试native进程内存泄漏问题的时候要注意以下两个方面:

3)kernel内存泄漏分析

到这一层已经比较深了,这部分没有深入玩过,之前使用page_owner分析过一个问题。page_owner的目的是存储页面分配时的调用栈信息, 这样我们就能知道每一个页面是由谁分配的。

详细使用参考kernel文档:kernel/msm-4.19/Documentation/vm/page_owner.rst

步骤:

Usage
=====
1) Build user-space helper::
     cd tools/vm
      make page_owner_sort
2) Enable page owner: add "page_owner=on" to boot cmdline.
3) Do the job what you want to debug
4) Analyze information from page owner::
 cat /sys/kernel/debug/page_owner > page_owner_full.txt
 grep -v ^PFN page_owner_full.txt > page_owner.txt
 ./page_owner_sort page_owner.txt sorted_page_owner.txt
 See the result about who allocated each page
  in the ``sorted_page_owner.txt``.

通过page_owner工具抓出kernel的调用栈,用脚本捋出top问题,分析其调用栈来排查。

这里我自己写了个sort脚本:

#__author__ = 'stan'
import argparse
import re

parser = argparse.ArgumentParser()
parser.add_argument('input')
args = parser.parse_args()
f = open(args.input, 'r')
current_page = ''
current_stack = ''
page_dict = {'':[]}
page_mem_dict = {}
page_order = ''
totalMemory = 0
count = 0


for line in f:
    if (len(line) == 1):
        if (current_stack != '') and (current_stack.find('___slab_alloc') >= 0):
            sum_mem = pow(2,int(page_order)) * 4
            if(page_mem_dict.get(current_stack) != None):
               sum_mem = sum_mem + page_mem_dict.get(current_stack)
            page_mem_dict[current_stack] = sum_mem

            page_list = page_dict.setdefault(current_stack, [])
            page_list.append(current_page)
            current_stack = ''
            current_page = ''
           # print len(page_list)
    elif ('Page allocated via order' in line):
        page_order = line.split(",")[0].split(' ')[4]
       # print page_order
    elif ('type Unmovable' in line):
        page_info = re.findall(r"\d+", line)
        #print page_info
        current_page = page_info[0]
    elif ((current_page != '') and ('+0x' in line)):
        current_stack += line
       # print current_stack

items = sorted(page_mem_dict.items(), lambda x, y: cmp(x[1], y[1]), reverse=True)
for item in items:
   count = count+1;
   if(count < 11):
       pages = page_dict.get(item[0])
       total_len = len(pages)
       discrete_len = total_len
       for i in range(1, total_len):
           if (int(pages[i]) - int(pages[i-1]) == 1):
              discrete_len -= 1
       print "discrete: "+str(discrete_len)+" , times: "+str(total_len)+" , memory: "+str(item[1]) +" KB"
       print '\n' * 2
       print item[0]
       print "============================"
   totalMemory = totalMemory + item[1]

print "#########"
print "total unreclaim memory:"+str(totalMemory)
print "#########"

出来的数据是这样的:

//离散程度         //次数            //内存占用
discrete: 9336 , times: 9336 , memory: 74688 KB

 //调用栈
 get_page_from_freelist+0x850/0x924
 __alloc_pages_nodemask+0x104/0xebc
 allocate_slab+0x7c/0x464
 ___slab_alloc.isra.62.constprop.67+0x5d4/0x694
 __slab_alloc.isra.63.constprop.66+0x24/0x34
 kmem_cache_alloc+0x16c/0x2ac
 create_object+0x5c/0x2b8
 kmemleak_alloc+0x5c/0xc8
 kmem_cache_alloc+0x1c0/0x2ac
 alloc_buffer_head+0x18/0xac
 alloc_page_buffers+0x74/0xd4
 create_empty_buffers+0x20/0x150
 ext4_block_write_begin+0x94/0x508
 ext4_da_write_begin+0x160/0x5a4
 generic_perform_write+0xb4/0x1b0
 __generic_file_write_iter+0x154/0x190

如果是单个某个驱动出现的大量内存泄漏,可能page owner能定位到,但是如何泄漏点太分散的话,也不太好定位。

四、内存监控方案

内存监控方案这部分参考张绍文在内存优化部分做的分享,主要还是针对app的

1)采集方式

用户在前台的时候,可以每 5 分钟采集一次 PSS、Java 堆、图片总内存。我建议通过采样只统计部分用户,需要注意的是要按照用户抽样,而不是按次抽样。简单来说一个用户如果命中采集,那么在一天内都要持续采集数据。

2)计算指标

通过上面的数据,我们可以计算下面一些内存指标。

内存异常率:可以反映内存占用的异常情况,如果出现新的内存使用不当或内存泄漏的场景,这个指标会有所上涨。其中 PSS 的值可以通过 Debug.MemoryInfo 拿到。

内存 UV 异常率 = PSS 超过 400MB 的 UV / 采集 UV

触顶率:可以反映 Java 内存的使用情况,如果超过 85% 最大堆限制,GC 会变得更加频繁,容易造成 OOM 和卡顿。

内存 UV 触顶率 = Java 堆占用超过最大堆限制的 85% 的 UV / 采集 UV

其中是否触顶可以通过下面的方法计算得到。

long javaMax = runtime.maxMemory();
long javaTotal = runtime.totalMemory();
long javaUsed = javaTotal - runtime.freeMemory();
// Java 内存使用超过最大限制的 85%
float proportion = (float) javaUsed / javaMax;

一般客户端只上报数据,所有计算都在后台处理,这样可以做到灵活多变。后台还可以计算平均 PSS、平均 Java 内存、平均图片占用这些指标,它们可以反映内存的平均情况。通过平均内存和分区间内存占用这些指标,我们可以通过版本对比来监控有没有新增内存相关的问题。

因为上报了前台时间,我们还可以按照时间维度看应用内存的变化曲线。比如可以观察一下我们的应用是不是真正做到了“用时分配,及时释放”。如果需要,我们还可以实现按照场景来对比内存的占用。3. GC 监控在实验室或者内部试用环境,我们也可以通过 Debug.startAllocCounting 来监控 Java 内存分配和 GC 的情况,需要注意的是这个选项对性能有一定的影响,虽然目前还可以使用,但已经被 Android 标记为 depre

3)GC 监控

在实验室或者内部试用环境,我们也可以通过 Debug.startAllocCounting 来监控 Java 内存分配和 GC 的情况,需要注意的是这个选项对性能有一定的影响,虽然目前还可以使用,但已经被 Android 标记为 deprecated。

通过监控,我们可以拿到内存分配的次数和大小,以及 GC 发起次数等信息。

long allocCount = Debug.getGlobalAllocCount();
long allocSize = Debug.getGlobalAllocSize();
long gcCount = Debug.getGlobalGcInvocationCount();

上面的这些信息似乎不太容易定位问题,在 Android 6.0 之后系统可以拿到更加精准的 GC 信息。

// 运行的GC次数
Debug.getRuntimeStat("art.gc.gc-count");

// GC使用的总耗时,单位是毫秒
Debug.getRuntimeStat("art.gc.gc-time");

// 阻塞式GC的次数
Debug.getRuntimeStat("art.gc.blocking-gc-count");

// 阻塞式GC的总耗时
Debug.getRuntimeStat("art.gc.blocking-gc-time”);

需要特别注意阻塞式 GC 的次数和耗时,因为它会暂停应用线程,可能导致应用发生卡顿。我们也可以更加细粒度地分应用场景统计,例如启动、进入朋友圈、进入聊天页面等关键场景。

纵观通篇,我更多的篇幅是花在了内存基础总结,以及如何分析具体内存问题上。对大部分app开发者或者rom开发者来说基本是通过存量内存问题的分析去倒逼内存优化产出。至于内存监控,我本身也没有太多经验,因此就参考张绍文微信的方案,了解下思路点到为止。

写在最后:
今天是2020年4月12日,周日。我依然来到公司写博客,感觉还是在公司写效率高些,这应该是性能优化盘点的最后一篇文章,自己定的计划就算有再大困难也要去完成,明天是我在小米的最后一天,本来想着离职这段时间好好放松下,结果天天写博客到零点,哈哈哈,这怕就是命了。

一段经历的结束也意味着新的旅程的开始,感谢帮助我成长的每一个人,也感谢我自己,一切都是最好的安排!加油!

参考:
linux内核tmpfs/shmem浅析
linux内存源码分析 - 内存回收(整体流程)
直接内存回收中的等待队列
linux内存源码分析 - 内存压缩(实现流程)
Linux内存管理 (16)内存规整
linux内存源码分析 - 内存压缩(同步关系)
ART运行时为新创建对象分配内存的过程分析
ART运行时垃圾收集机制简要介绍和学习计划
ART运行时垃圾收集(GC)过程分析
Android内存优化(二)DVM和ART的GC日志分析
04 | 内存优化(下):内存优化这件事,应该从哪里着手?

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
禁止转载,如需转载请通过简信或评论联系作者。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,108评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,699评论 1 296
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,812评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,236评论 0 213
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,583评论 3 288
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,739评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,957评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,704评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,447评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,643评论 2 249
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,133评论 1 261
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,486评论 3 256
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,151评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,108评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,889评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,782评论 2 277
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,681评论 2 272

推荐阅读更多精彩内容