全民K歌内存篇3——native内存分析与监控

2021-04-23 18:10:36 浏览数 (1)

《全民K歌内存篇1——线上监控与综合治理》 《全民K歌内存篇2——虚拟内存浅析》 《全民K歌内存篇3——native内存分析与监控》

一、背景

在2020年的上半年,我们在用户反馈后台发现闪退、白屏问题不断增多,这些问题严重影响用户体验。观察Crash监控平台发现Crash率也在逐步升高,其中Native层的Top1的crash堆栈信息如下:

这个Crash在整体的crash中占比很大,通过这个堆栈信息,发现并没有明显的指向哪个业务代码。此时,把发生Crash时的内存信息上报到后台,分析发现:Crash发生时虚拟内存非常接近4G

以及日志信息:

为此,我们通过脚本模拟用户在不同业务场景反复进出运行,发现内存水位在逐步上升,当接近4G时,复现了该crash。在前期,我们解决了一些专项测试同学提出的内存问题,但是内存水位依然维持较高状态,Crash率也没有得到有效缓解。

经过粗略分析,发现应用内内存占用的大头在native层。那么,该如何系统的分析和解决Native内存问题呢?

二、问题分析

2.1 为什么会内存不足

Android系统是基于Linux之上的,在内存管理上基本一致,但也有差异。简化后如下图所示:

在Android手机上,内存的使用受操作系统和内存硬件设备限制,这里以32位的应用运行在8G内存、64位系统的手机上为例,总结几点如下:

  • 虚拟内存:每个进程都有独立的虚拟内存空间,进程能够访问的也只能是虚拟内存地址,它的大小由操作系统决定,32位系统则限制在4G以内;
  • 物理内存:是实实在在存储数据的物理模块,它的大小由设备本身决定。所有应用APP可用内存 = 内存硬件总大小 - 操作系统内核占用部分(一般小于1G) ;
  • ZRAM区:不像Linux系统,长期不访问的内存块可以将内存数据交换到磁盘,从而释放出这部分内存空间。Android的系统是通过在内存中开辟一块独立的ZRAM区域,当需要交换时,内存只是被压缩并交换到ZRAM区。这就决定了可用内存永远不能超过8G;
  • 虚拟内存区块数:虚拟内存申请时,如果已申请的空间不足,会开辟一块新的虚拟内存区块。区块数默认限制65536以内,可以通过读系统文件  /proc/sys/vm/max_map_count查看 。

2.2 内存分布情况分析

通过adb run-as xxx.xxx.xxx cat /proc/pid/smaps命令,可以获取到系统的smaps文件,该文件详细记录了应用虚拟内存的分配情况。分析可知:左侧 12c00000-13d40000 代表的是一个虚拟内存区块,右侧 [anon:dalvik-main space] 表示这一块内存由JVM虚拟机所申请。如下图可知:虚拟内存主要由虚拟机、系统库文件、应用dex文件,以及业务调用libc.so的malloc所申请。

接着,对smaps文件的pss数据(实际占有的物理内存)进行统计分析,如下图是K歌直播场景内存分布情况:

分析发现,业务申请的内存主要分布在两部分,即:

  • 程序文件:
    • 占比:应用的.dex文件(占35%)、加载的so文件(占7%)
    • 解决方案:
      • 删减代码,减少dex文件内存占用:K歌内有许多旧代码实际已经废弃,可以将其删除。这里的优化是一个持续的过程,其中歌房在一次代码整理之后就删除了8W多行代码。
      • 按需加载,减少so文件内存占用:通过smap文件发现,部分加载到内存的so实际是无需运行的,例如直播观众端不需要使用美颜相关的功能,这里可判断仅主播端才加载相关so,大大减少so文件带来的内存开销。
  • NativeHeap:
    • 占比:业务集成的so库内申请和图片(8.0及以上版本)共占39%
    • 解决方案:检测、监控申请内存的业务,修复不合理申请。
    • 面临困难:不像Java内存有系统支持的内存快照文件,分析起来非常方便。Native内存分析就显得非常困难,后续内容将围绕So库与图片内存问题展开叙述。

三、So库内存检测与分析

3.1 Native内存分析工具对比

1)、内存总量分析工具

在团队内,通常用Perfdog来检测应用程序内存问题,这个工具的实现原理是不断的读取系统的内存值。通过Perfdog,我们只能观察内存总量,去判断是否存在内存异常问题。可是,Perfdog无法提供有效的堆栈信息,帮助开发定位问题所在。如下图所示,是在测试K歌直播上下滑场景过程中的内存数据,发现nativePss在不断上涨。

2)、内存分量分析工具

经过调研发现,Android分析native层内存工具,有Android原生支持的,也有开源的。整理如下表:

工具

基本原理

能力

使用方法

优势

劣势

malloc_debug

替换 libc.so的malloc、free等函数;

1、检测内存申请和释放情况, 2、回溯堆栈;

系统自带功能,无需集成,adb命令行指令;

1、无需开发;2、能够还原部分系统堆栈;

1、仅支持已root手机;2、分析结果无法准确识别业务问题;

perfetto

heapprofd;

1、检测内存申请和释放情况, 2、回溯堆栈;

无需集成,adb命令或者webui界面操作。

1、无需开发;2、能够还原部分系统堆栈;

1、仅Android10以上支持;2、分析结果无法准确识别业务问题;

loli_profiler

hook libc.so的malloc,free等函数;

1、检测内存申请和释放情况, 2、可以回溯堆栈;

无需集成,adb连接PC端工具即可检测;

1、无需开发;2、可以指定SO分析;

1、必须连接PC端工具;2、使用不灵活方便;

对比上述工具,结合使用经验,小结如下:

  • malloc_debug:
    • Android原生支持,需要root手机才能使用,非常不方便。
    • 启动应用时就会开启检测,无法控制启动时机,使用不灵活。
  • perfetto(AndroidStudio4.1以后的版本支持native内存检测,底层实现采用的是该方案):
    • Android原生支持,Android10以上才支持,若Android11及以上手机可以直接使用web端工具调试,体验链接https://ui.perfetto.dev/
    • AndroidStudio调试时,发现部分手机直接闪退,无法使用。
    • 检测后的结果,可以看见许多系统堆栈信息,无法关联到实际业务。对于业务集成的so无法看到详细堆栈信息,需要重编so才支持(详细原因见官方文档:https://perfetto.dev/docs/data-sources/native-heap-profiler)。
    • 实测一个已知的泄漏,工具未检测到,原因有待研究。
  • loli_profiler:
    • 腾讯内部研发的开源工具,兼容性好,无系统版本要求;
    • 使用时,需要通过adb连接pc端工具,需要注意同时打开AndroidStudio会占用adb导致工具无法检测;
    • 完成检测后,数据只能展示这一段的时间过程中总的未释放的内存。由于内存的申请和释放是一个持续的过程,有可能是在结束检测之后才释放。这样,我们就不能够准确的说未释放的内存是发生内存泄漏导致。
3)、 所期望的Native内存分析工具

上述工具团队内开发、测试都有尝试去使用,但是在使用和分析问题上都存在一些困难,不能高效准确的定位问题。基于现状以及对当前工具调研情况,我们计划基于loli_profiler来自建工具。希望工具能够在开发、测试、灰度以及外网阶段都能发挥作用,并能够结合业务场景,精准定位问题,来帮助我们高效、可持续的优化内存问题。

3.2、Native 层的内存管理机制介绍

这里先简单的介绍下native层内存相关的基础知识。

1)、如何申请和释放内存

如下图,“C Application” 指的是业务层,大多数情况下,业务是通过mallocfree函数来申请和释放,或者是newdelete关键字,它最终的也是由mallocfree的函数来实现。更高阶的实现方式,可以直接调用更底层的系统接口,如mmap。为了更好的管理内存,业务代码极少去直接调用mmap来申请内存。在我们的业务场景内,内存的申请和释放通常最终都是由malloc和free来实现的。

2)、Native内存申请流程

如下图,当应用APP申请内存时,申请的是虚拟内存。当应用访问这块内存并进行写操作时,如果物理内存还未分配则会发生缺页中断并触发分配物理内存。在实际分配物理内存时,是以“页”为单位,每页通常是4KB的内存空间。完成分配后,在MMU模块中的PageTable记录了每一页的虚拟地址和物理地址映射关系。

3.3、So库加载、申请内存的核心流程

1)、实验测试

我们先来做一个测试demo,如下,在libkmemory.so内通过调用malloc函数,申请176k的内存空间 (176k,随机测试的内存大小,建议不要太小,以便申请一块新的虚拟内存区块,方便后面对smaps文件的观察):

运行demo之后,通过adb run-as xxx.xxx.xxx cat /proc/pid/smaps,读到smaps文件如下:

可以发现,libkmemory.so在虚拟内存的空间区域为0xb65c8000-b65d6000,起始地址也称基址为0xb65c8000

接着,对比调用mallocTest前后对比smaps文件,发现新增了一段如下:

解读这一段的主要内容:

  1. e2600000-e2680000: 虚拟内存的地址空间;
  2. [anon:libc_malloc] :这一段虚拟内存是由libc的malloc所申请;
  3. Size: 这一段的占用虚拟内存大小为512K;
  4. Private_Dirty: 也称"脏页",也就是实际申请的内存大小,也是本进程独占的;
  5. Shared_Dirty:是相对于Private_Dirty而言,表示这一块内存同时被其他进程所引用;
  6. Pss: Private_Dirty SharedDirty的公摊部分,为195k
  7. Rss: Private_Dirty SharedDirty , 176 152 = 328 k

以上可以验证,由malloc申请的内存在smaps文件中记录在[anon:libc_malloc]的虚拟内存空间内。

2)、流程分析

那么,so库是如何在手机上运行的呢?简化流程如下图:

  • 第一步:调用System.loadLibrary("kmemory")加载so,会在虚拟内存申请一块内存空间,来映射so库文件;
  • 第二步:libkmemory.so内调用malloc()函数申请内存,最终分配在[anon:libc_malloc]对应的内存空间里;

由上可知,如果要定位内存申请是由具体那个业务代码所申请,实际也就是要将 “[anon:libc_malloc] ”内的内存和“libkememory.so” 内的xxxmallocTest() 函数关联起来。

3.4、实现So库内存分析的基本方案

1)、如何分析So库内存的申请和释放

如下图,通过hook系统库libc.so的mallocfree函数,业务所有的申请和释放操作也就都变成可见了。作为一个内存检测工具,我们需要做以下几个事情:

  1. 通过步骤①和②hook系统申请和释放内存函数;
  2. 完成hook之后,在步骤③中获取到:
    • 分配内存大小和地址;
    • 通过系统堆栈回溯机制追踪到业务函数调用堆栈指针地址;
  3. 在步骤④记录每次申请和释放,写入到手机文件;
  4. 在步骤⑤读取当前的smap文件;
  5. 在步骤⑥中通过“内存记录文件”和“smap”文件进行离线分析,检测出内存泄漏、大内存等问题,输出内存占用情况;
2)、xhook基本原理

xhook是一个开源的plt hook 方案,基础原理内容涉及众多,笔者也多次阅读相关资料,这里仅简单讲述关键之处。

基本概念

Runtime Linker: 动态链接器,负责将so加载到内存中,处理符号解析、地址的计算等操作;

PLT: Procedure Linkage Table 程序链接表,实际是一小段代码,这段代码能够触发函数地址的计算以及跳转到对应函数上;

GOT:Gloabal Offeset Table,它是一张表,除了前3个特殊的,其余项都保存着“符号(函数或者变量)名”与“入口函数地址指针”的对应关系;

跳转步骤

如下图所示,Java_com_tme_memory_util_MemoryUtil_00024Companion_allocTest 函数内要执行 malloc 函数来申请内存,此刻需要找到malloc的函数入口地址。会经历下述几个步骤:

  1. 跳转到 malloc 函数在 PLT 表对应的项,PLT 会优先查对应的 GOT 表,如果 GOT 表内已有目标函数 malloc 的地址指针,则直接跳转;
  2. 如果GOT表中不存在 malloc 函数记录,说明之前一直没有跳转过这个函数,此时会触发动态链接器解析并加载这个函数的地址到GOT表中,然后再跳转到对应目标函数;

hook方案

观察上图右侧,GOT表可以简化为键值对,key存储的是函数名,value对应的该目标函数地址的指针地址。通过解析GOT表,拿到指针地址b65ba10c,通过这个指针地址,就可以访问指向的内存块,它实际存储的是malloc函数的入口地址0xf0a75241;所以,只需要将这个地址改为我们自己的hook_malloc()函数的入口地址0x0206e85即可。

3)、获取堆栈地址以及还原函数名

如下图,左侧图是由smaps文件所得虚拟内存的分布情况。首先通过系统堆栈回溯获取的堆栈的指针地址 0xb65cd635,发现是落在了“libkmemory.so”的区域内,由此判定申请内存的函数在libkmemory.so内。然后根据libkmemory.so的基址计算出偏移地址,最后通过脚本自动实现指针地址还原为对应的函数名。这里的方案是首先根据有符号表的so导出的地址与符号的对应关系表,然后采用二分法遍历这一张关系表,查找到最邻近偏移地址所对应的函数名。

3.5、So库内存监控与分析流程

1)、数据采集

检测工具的客户端部分作为一个SDK模块,集成到app中。SDK提供了接口由业务app来控制,接口主要有:启动开关、检测so列表、业务打点。启动开关可以在APP运行的任何阶段控制打开检测,so列表配置了需要检测的so。业务打点是为了精准的将当前运行状态与分析数据关联起来,如下图右侧。是统一将Activity的创建和销毁时机写入记录文件中,分析时就可以按照打点统计这个Activity创建和销毁这段时间内的内存申请和释放情况。当然,也可以将其他场景添加业务打点,比如K歌直播歌房上下滑的每次滑动事件。当需要检测时,只需要开启开关运行业务场景即可,采集的数据自动写入设定的目录文件下。

2)、数据分析

整体内存统计分析

获取到保存在手机上的内存记录文件,通过统计分析业务打点"LiveActivity_onCreate"和"LiveActivity_onDestroyed"这段时间内所有的内存申请和释放记录,我们可以得知这段时间内每个so的内存申请次数和释放次数,以及在这段时间内未释放的内存大小,对应的业务so以及函数。

例如: 直播Activity创建和销毁之间内存申请和释放情况如下:

从以上数据中,可以进一步将堆栈进行归类,分析出哪些函数申请的次数最多,哪些函数申请的内存最多,哪些函数申请了大内存。由此,能够对so库的内存申请释放情况有一个比较深入的分析。

内存占用分布分析

通过在每个activity和fragment的生命周期“打点”(写入一条记录到文件中),以及预埋的业务“打点”,然后统计从“开始点”到每个“打点”之间内存的未释放内存量,就可以绘制检测场景运行过程中各个so的内存占用情况分布图。由此,我们对native的内存分布不再是停留在整体的“nativePss”这个内存总量数值,而是可以清晰明确的观察到具体每个so的内存占用情况。下图是直播上下滑场景so库内存分布,可以观察到某个音频相关的内存占用峰值达到20M以上,退出直播后均完全释放。

3)、整体框架

如下图,将工具SDK集成到应用apk中。运行时,开启检测开关,配置相关参数,如要监控的so列表,内存阈值等。开启后采集到数据后,将记录写入手机文件。然后将文件上传到质量平台,质量平台通过脚本自动化分析,发现内存问题并自动提单到tapd系统,推动开发侧修复。

3.6、 发现问题案例

1)、案例一:So库内部的泄漏问题

通过分析发现,libxxx.so内存占用量持续上涨,20分钟约增长3M,大概率存在内存泄漏问题。

跟进工具统计得到如下堆栈信息:

代码语言:javascript复制
>>> 重复次数2874 libxxx.so   putxxx(char const*, unsigned int, xx::strutf16&)      xx::io::xxx::Open(char const*, char const*)       xx::name(unsigned char) 

随即找libxxx.so 负责人查看对应代码,发现确实存在内存泄漏问题。了解到这个问题是K歌打印日志的操作导致,也就是说在K歌运行过程中因为打日志而一直泄漏,连续运行时间越长,泄漏越多,触发OOM的概率也就大大提高。

2)、案例二:业务层导致的泄漏问题

如下图,在我们的自动化内存水位检测时发现歌房业务上下滑50次后,native内存峰值相对历史版本明显偏高。那是什么问题导致的呢?

通过工具检测发现其中一个负责音频处理的so库滑动50次后,泄漏了40M,泄漏的函数是一些初始化操作。最终定位问题是由于每次上下滑切换房间后,打分业务会重新初始化操作,但是在退房时却没有进行释放操作。

通过以上案例,自建工具的优势就很明显的展示出来了。在前面的内存水位图,测试同学已经完成了自动化测试流程,可以帮助我们发现问题,只是还不能提供更多有效信息,帮助我们快速定位并解决问题。其实,测试同学只需要在运行自动测试的过程中,打开so库内存检测开关,就能够得到每个so的内存水位细分图,并附带堆栈信息。这样的做的好处,就是极大节约测试、开发人力成本,提升研发效率。

3.7  在线监控方案探索

基于这段时间的经验,工具的能力以及优势都得以充分验证。但是,如何充分发挥工具的作用?如何在不增加人力成本的情况下覆盖更多场景?如何可持续的监控内存问题?这是我们所思考的。所以在想是否可以实现在线监控。对于在线监控,必须要考虑性能问题,工具面临的两大性能瓶颈如下:

  • 性能瓶颈1:
    • 问题:每次内存申请释放都需要记录、非常频繁,数据量大、IO操作频繁
    • 方案:通过过滤机制减少记录次数、缓存多条数据1次写入,减少IO操作
  • 性能瓶颈2:
    • 方案1:不获取堆栈,通过每个so在hook时对应不同的hook_malloc函数,可以获得各个so库的内存总量数据
    • 方案2:优化堆栈回溯方案,如下图,是目前主流的方案调研结果 堆栈回溯技术优点缺点耗时/帧libunwind通用速度慢15000nsInstrunment-function速度快重新编译,包体积增大1400nsFramePointer速度最快重新编译,  ndk>=20,  arm64—
    • 问题:获取函数堆栈耗时长
    • 方案:

基于对用户性能最低影响的同时又能拥有在线监控能力的考虑,我们提出了一个具备可行性的在线监控方案,如下图:

在不获取堆栈的情况下如何将内存的申请和释放归类到各自的so呢?这里有一个取巧的办法,如下图所示。在hook每个so的malloc和free函数时,让不同的so对应不同的hook函数,这样就可以单独记录各个so的内存分配情况了。估算统计直播场景运行20分钟,某音频处理相关的so产生约74万条记录,数据占用内存仅约4.5M。如果超过1M则将数据写入文件,IO操作也几乎无性能影响。

上述方案还在进一步探索完善中。

四、图片内存分析

在治理内存问题的过程中,我们多次发现内存超出限制时是由于创建图片导致的。如下图案例,在客户端加载后,图片内存占用达到20M以上。

可见,应用内有许多不合理的图片,拉高了内存水位。所以对图片的内存申请展开分析。如下图,可以知道80%以上的用户的手机是8.0及更高系统版本,它的内存申请是在Native层的。因此,图片内存的分析重点放在8.0以及更高的系统版本上。

系统

API**版本**

内存占用位置

用户量占比

Android2.3以前

<=10

native

0%

Android3.0-7.1

11~26

java

18%

Android8.0及以后

>=27

native

82%

4.1、分析图片内存申请流程

如下图,在我们的业务中,一般会在布局的xml文件中定义图片,或者在代码中动态给imageview设置图片,或者是用引入的Glide组件来加载网络图片资源。基本的框架流程总结如下图所示:

再来看native层的具体实现:

分析源码,整理如下图。发现所有的图片创建最终都会走到BitmapFactory.cppdoDecode()函数中,通过HeapAllocate等内存分配器来分配内存并存储图片的像素数据。完成内存分配后,会创建一个SkBitmp对象,它持有的SkPixelRef存储了内存地址。最后调用JNI层的createBitmap函数,这个函数非常重要,在这一步中创建了Java层的bitmap对象。

代码语言:javascript复制
//frameworks/base/libs/hwui/jni/Bitmap.cpp//关键函数 createBitmapjobject createBitmap(JNIEnv* env, Bitmap* bitmap,int bitmapCreateFlags, jbyteArray ninePatchChunk, jobject ninePatchInsets,int density) {
// 省略部分代码
// 调用Java层的Bitmap构造方法,创建java层bitmap对象    jobject obj = env->NewObject(gBitmap_class, gBitmap_constructorMethodID,reinterpret_cast<jlong>(bitmapWrapper), bitmap->width(), bitmap->height(), density,            isPremultiplied, ninePatchChunk, ninePatchInsets, fromMalloc);
return obj;}

4.2、检测图片bitmap的创建

分析bitmap的创建流程,总结得到了图片创建的关键函数,所以,我们只需要hook这个createBitmap函数,就能够拿到每次图片创建时的bitmap的Java对象。通过该对象,可以获得图片的尺寸大小、内存占用大小,堆栈等信息,将这些信息上报到性能平台也就达到了检测与监控图片创建的目标。

这里hook的方案采用开源的inline-hook开源方案来完成。原理在本篇中不做描述。怎么查找到这个函数呢?

首先需要通过adb pull system/lib/libandroid_runtime.so 拿到系统的so文件,然后通过arm-linux-androideabi-nm -D libandroid_runtime.so | grep bitmap ,可以查找到对应的函数名,也就是我们要hook的函数,系统版本不同可能会有差异,注意兼容。

4.3、分析图片的销毁流程

1)、主动调用recycle销毁

在Java层通过bitmap对象调用它的recycle()方法,即可达到立即释放的目的。在native的具体实现如下,最终是在SKMemory_malloc.cpp中调用sk_free()释放申请的native内存。

2)、等待Gc触发自动销毁

在Java层的Bitmap构造方法内,会通过NativeAllocationRegistry将Java的bitmap和native的bitmap对象注册到JVM。

代码语言:javascript复制
// frameworks/base/graphics/java/android/graphics/Bitmap.java// called from JNI and Bitmap_Delegate.    Bitmap(long nativeBitmap, int width, int height, int density,boolean requestPremultiplied, byte[] ninePatchChunk,            NinePatch.InsetStruct ninePatchInsets, boolean fromMalloc) {//省略代码final int allocationByteCount = getAllocationByteCount();        NativeAllocationRegistry registry;if (fromMalloc) {            registry = NativeAllocationRegistry.createMalloced(                    Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), allocationByteCount);        } else {            registry = NativeAllocationRegistry.createNonmalloced(                    Bitmap.class.getClassLoader(), nativeGetNativeFinalizer(), allocationByteCount);        }// 注册 Java的 bitmap 对象和native的 bitmap 到JVM中        registry.registerNativeAllocation(this, nativeBitmap);
    }
代码语言:javascript复制

JVM在GC过程中如果发现Java层的`bitmap`对象已经释放,随后也就会触发`delete` native层的`bitmap`对象,最终释放native层的内存。

对比上述两种图片销毁流程,值得注意的是,虽然android的注释文档中说明不需要主动调用bitmap的recycle方法来销毁bitmap,这是因为有上述GC自动触发销毁的保证。但是,如果已经非常明确这个bitmap已不再需要使用,主动调用recycle尽快释放可以帮助降低整体内存水位。

4.4、整体监控方案框架

我们将工具集成到应用中,通过配置网络开关来监控应用内图片的创建。然后把图片的相关信息,如尺寸大小、占用内存大小,调用的堆栈等信息上报到性能平台。后台通过信息的聚类,可以监控到如图片占用内存过大等异常问题。最后一键提bug单到tapd系统,推动业务开发来修复。

性能监控平台数据展示:

4.5、发现问题案例

通过图片检测工具,我们能够很明确得知道应用创建的bitmap占用内存大小,以及创建图片的堆栈信息。经过脚本自动统计和人工分析,主要归类未如下几类问题。

1)、原始图片过大

在解码图片的时候,图片的像素大小是影响图片bitmap占用内存的关键因素之一,所以缩小原图尺寸是减少内存占用的有效手段。这里有两种情况:

原始图片的尺寸大于View:图片的大小超过View的大小数倍时,而解码图片时按照图片尺寸来解码就很浪费了。(注:此类情况主要发生在非Glide组件创建图片的场景);如下案例当中,是直播业务里面的一个红包弹框,View的大小是 宽度255dp,对应382.5px,高度339dp,对应高度508.5px。缩小图片到View的尺寸后可降低到760K,减少内存约2M多。

在清晰度要求不高的场景下,可以适当的缩小原始原图,如下图的案例中,我们发现一张磨砂的模糊图片内存占用超过6.4M,通过优化,优化后将长和宽缩小原来的1/4,内存减少到原来的1/16,约400K,减少内存占用约6M。

2)、 相同图片重复创建

我们抓到了相同的图片由不同的堆栈调用来创建,这里逐一分析是否有优化空间,尽量复用来节约内存。如下,是直播场景内的背景图,可以发现有相同的图有三个不同大小的尺寸,其创建路径堆栈也不一致。修复后复用同一bitmap,内存可减少约5M。

3)、未及时recycle

在业务里,时常有bitmap拷贝行为,通过源bitmap对象获得变换后的bitmap对象,这里需要考虑源bitmap是否可以立即释放。

4)、Web动态框架问题

K歌集成了腾讯浏览器自研的hippy框架,它是一套类ReactNative的移动端跨平台解决方案。我们业务大量使用了该动态框架,甚至运营可以直接配置上架页面,这里时常存在超大超长的不规范图片。hippy加载网络图片时透传url给到客户端,最终的解码由客户端来实现。透传时没有把View的宽和高带给客户端,客户端最终以原始图片尺寸来解码。在原始图片尺寸大于View时,会浪费不必要的内存。这个问题曾经因为web端发了一张超大图片,并在灰度过程中发现OOM由此问题导致。如下案例中:图片尺寸为12300*480,占用内存达22.5M。这里浏览器团队已完成优化此类问题。

5)、图片引擎问题

问题1:我们图片引擎是集成的Glide,设置的默认RGB_565格式没有生效,查看源码是原始设计API在26以上默认到ARGB_8888。如下图所示,是直播全屏背景图,在RGB_565格式下,每个像素占用内存为2个字节,应该是1080x1902x2=4050KB,修复后能减少约4M的内存占用。

问题2:在Glide内部,当图比view小的时候,在ImageView的scaleType不是fitXY和centerInsider配置的时候,会根据View的尺寸来放大解码,从而拉高内存的占用。如下,是性能平台监控到歌房背景图,在房主未设置背景的情况下,会默认取房主头像来作为背景。而背景的View是全屏的,此时 view的尺寸是1440x3064,最后经Glide放大后的真正解码的bitmap会是3064x3064,最后背景图占用的内存高达30多M。修复后按照图片尺寸解码,仅1.56M,如果上面问题1修复可降低到0.78M。差距非常的大。

问题3:业务繁杂,长期处于不可见状态的页面加载的图片一直未释放,其实这里可以适时释放掉,以降低内存水位。

问题4:发现K歌内还有少量业务还在使用旧图片加载引擎,需要彻底删掉。

五、待解决问题

通过建设so库内存检测工具、图片检测工具,我们可以对native层动态申请的内存进行准确的检测。在这个过程中,发现并解决了不少问题,但也还存在许多地方待进一步研究与优化,主要有如下两个方面:

1)、图片缓存问题

通过工具检测,发现K歌大卡片、歌房、直播反复上下滑的等业务场景下,内存会不断增长,这些增长是由于图片缓存不断累积导致的。研究发现,Glide组件的缓存机制在我们的业务中,存在一些不合理性,比如没有缓存价值的bitmap会加入到缓存池,不能及时回收不在界面展示的bitmap。这里需要进一步探索优化,结合实际业务场景,不影响流畅度的前提下及时释放,降低内存水位。

2)、未知的内存申请

在歌房的练唱房场景内,反复切歌后NativePss会有1~2M的内存增长,这些内存当前的so库检测工具、图片检测工具均未能抓到申请的业务。也尝试使用AndroidStudio来检测,所抓到的都是在系统层面的堆栈信息,这些堆栈还是缺乏一些说服力。那么,这些内存到底是由谁申请的,目前还待研究分析。

六、总结

在治理Native内存的过程中,通过建设检查工具,分析业务so库和图片的内存申请,就基本对当前业务native内存分配情况了如指掌。如下图分析案例中,我们可以非常直观的观察直播上下滑场景下native内存分配情况。

根据上图,分析如下:

  • 横轴:业务运行时间线,对应业务打点,进房->上下滑X次->退房。
  • 纵轴:当前占用内存大小,单位KB。
  • 水位线:不同颜色代表着各个so以及图片当前各自占用的内存大小,pss是当前内存占用总量。

在优化Native内存时,我认为主要抓住以下关键点:

1、聚焦优化目标:降低应用内存水位,修复内存泄漏、不合理申请等问题;

2、分析内存问题并优化:学习内存基础知识,建设so库,图片等检测工具,检测出内存到底由哪些业务占用,并给出有价值的堆栈信息,帮助业务同学定位问题,推动业务优化;

3、监控并持续改善:由于业务是在不停的迭代的,需要有一定的手段来持续监控内存问题,及时发现,推动解决。通过建设工具,完善流程,形成内存优化闭环。

本篇文章涉及相关知识众多,仅抛砖引玉,如果描述错误之处请指正。工欲善其事必先利其器,我们也在不断完善优化中,在分析native内存问题的过程中参考学习了许多开源工具与知识资料,同时也有太多的疑惑未解,期待与各位同学交流学习。

七、参考资料

  1. 【C 】C 内存管理-侯捷
  2. Android PLT hook 概述
  3. loli_profiler
  4. BitmapProfiler
  5. Bitmap: 监控与分析
  6. Android-Inline-Hook
  7. 《深入理解Android Java虚拟机ART》 邓凡平

0 人点赞