《全民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” 指的是业务层,大多数情况下,业务是通过malloc
和free
函数来申请和释放,或者是new
和delete
关键字,它最终的也是由malloc
和free
的函数来实现。更高阶的实现方式,可以直接调用更底层的系统接口,如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文件,发现新增了一段如下:
解读这一段的主要内容:
- e2600000-e2680000: 虚拟内存的地址空间;
- [anon:libc_malloc] :这一段虚拟内存是由libc的malloc所申请;
- Size: 这一段的占用虚拟内存大小为512K;
- Private_Dirty: 也称"脏页",也就是实际申请的内存大小,也是本进程独占的;
- Shared_Dirty:是相对于Private_Dirty而言,表示这一块内存同时被其他进程所引用;
- Pss: Private_Dirty SharedDirty的公摊部分,为195k
- 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的malloc
和free
函数,业务所有的申请和释放操作也就都变成可见了。作为一个内存检测工具,我们需要做以下几个事情:
- 通过步骤①和②hook系统申请和释放内存函数;
- 完成hook之后,在步骤③中获取到:
- 分配内存大小和地址;
- 通过系统堆栈回溯机制追踪到业务函数调用堆栈指针地址;
- 在步骤④记录每次申请和释放,写入到手机文件;
- 在步骤⑤读取当前的smap文件;
- 在步骤⑥中通过“内存记录文件”和“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
的函数入口地址。会经历下述几个步骤:
- 跳转到
malloc
函数在 PLT 表对应的项,PLT 会优先查对应的 GOT 表,如果 GOT 表内已有目标函数malloc
的地址指针,则直接跳转; - 如果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,大概率存在内存泄漏问题。
跟进工具统计得到如下堆栈信息:
>>> 重复次数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.cpp
的doDecode()
函数中,通过HeapAllocate
等内存分配器来分配内存并存储图片的像素数据。完成内存分配后,会创建一个SkBitmp
对象,它持有的SkPixelRef
存储了内存地址。最后调用JNI层的createBitmap
函数,这个函数非常重要,在这一步中创建了Java层的bitmap
对象。
//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。
// 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内存问题的过程中参考学习了许多开源工具与知识资料,同时也有太多的疑惑未解,期待与各位同学交流学习。
七、参考资料
- 【C 】C 内存管理-侯捷
- Android PLT hook 概述
- loli_profiler
- BitmapProfiler
- Bitmap: 监控与分析
- Android-Inline-Hook
- 《深入理解Android Java虚拟机ART》 邓凡平