一、背景
2020年K歌安卓的白屏反馈和top crash在逐渐恶化,深入分析后,这两个问题的原因都指向了内存不足,我们通过脚本压测直播、歌房等核心场景复现了问题,也实锤了我们的猜想,确定是内存、线程、fd等资源耗尽,app开始出现各种异常。当前需求的性能测试主要依赖我们测试同学的人工覆盖,在K歌需求飞速迭代的情况下,人工性能测试的发现问题能力出现了瓶颈:
-
测试人力有限,只能覆盖小部分的需求,大量的需求未经过严格的性能测试,可能会带着内存问题发布到外网;
-
测试场景不足,无法反映外网海量用户的复杂情况,很难发现组合场景下的内存泄漏;
-
缺乏有效信息,对于用户上报的OOM问题,其堆栈往往无法体现真正的问题所在,解决的难度较大,常见问题还容易反复出现:
-
在Native层,Top1的Crash是vss超了3.7G后,在系统ui绘制时出现异常,随机出现某个场景,辅助定位信息少,下手困难;
-
在Java层,Top的OOM内存问题虽然有明确堆栈,但出现在线程创建,单点看都没问题,需要通盘考虑app全站资源使用问题。
-
基于K歌面临的现状,为了能够高效、可持续的优化内存相关性能问题,我们围绕内存优化提出一套监控与综合治理方案,目标一是要解决当前“冰冻三尺”的性能问题,二是要防止此类问题“卷土重来”。
二、方案
我们通盘考虑了研发全流程,建立的综合治理方案如下图:
1)开发阶段:
在开发阶段,我们提出了对于影响性能需求需要开发根据“自测清单”进行自测,一方面把性能问题拦截在开发阶段(发现越早修复代价越小),另外一方面也加强了每位开发的性能风险意识。同时我们为了开发的自检效率,搭建了内存/线程/fd检测、图片检测、So检测工具,并集成在我们的应用中。开发人员可以很方便的直接在应用内查看内存占用总值、所创建的图片占用内存大小,so库内存占用,是否有泄漏问题,我们要求泄漏问题必解,大内存块和频繁申请内存需要给出合理的解释。
2)测试阶段:
在以往的标准中,只有核心业务需求或者风险技术需求才会安排专项人力跟进性能测试,实际在高速的需求迭代过程中,任何变更(比如改个小bug)都可能引入内存问题。所以我们梳理并完善了核心场景自动化测试用例,并详细记录了用例执行过程中的内存、线程数、fd数等关键指标,制定各性能指标的基线,通过不同版本之间数据对比,发现指标异变。
3)灰度阶段:
之前在灰度阶段主要是通过crash率和用户反馈来衡量app的质量,我们时常面临的问题是当发现crash率上涨之后,靠关键字聚类可以主观感知是内存问题变得更严重了,但难以进一步分析得到准确的问题场景和原因。对此,我们实时监控了灰度用户的内存等性能指标,在用户设备的指标数据超出设定阈值或明确有内存泄漏等问题时将dump文件、用户操作路径等辅助信息上报到性能平台,在后台聚类分析异常问题关联场景并提bug单给对应开发人员修复。
4)外网阶段:
当应用发布到外网之后,app相对稳定,我们仍然需要监控性能指标异动,比如sdk的动态配置、插件、运营活动、web/hippy等动态业务都会实时影响app质量。外网监控除了基础信息的采集,对于稍有性能消耗的检测手段仅在有需要时打开抽样采集。以及当我们遇到用户反馈的一些疑难杂症,例如用户反复闪退、白屏等问题时,需要更多精准的信息来帮助我们分析问题,此时会定向采集相关信息。
三、内存监控实现原理
接下来将围绕内存的各个关键影响因素进行逐一分析,主要包括虚拟内存、Java堆、FD数量、线程数量、Native内存这几个方面,介绍其状态、限制或具体分析的手段,探索监控的可落地方案。
3.1 虚拟内存
当应用程序进行内存分配时,得到的是虚拟内存,只有真正去写这一内存块时,才会产生缺页中断,进而分配物理内存。虚拟内存的大小主要受CPU架构及内核的限制。
32位的CPU架构,其地址空间最大为4GB,内核占用了部分高地址,用户空间所能使用的地址最多为3GB,而对于arm64来说,用户态地址空间为512GB。实际情况是,作为32位的应用程序,普遍运行在64位CPU架构上,这种情况下,应用可独占4GB的低地址空间。
对于虚拟内存的当前使用状态,可通过读取/process/pid/status
中的VmSize
字段来得到。如果希望进一步分析,则可读取/process/pid/smaps
,这一文件记录了进程中所有的虚拟内存分配情况(vss过大直接导致了我们top1 crash,为此我们研究了下虚拟内存的特点,具体在系列文章《全民K歌内存篇2——虚拟内存浅析》有详细阐述,本篇不展开)。
综上,针对虚拟内存的监控及分析手段可总结为以下几个方面:
-
大小限制:大多情况下为 4GB(32位)或512GB(64位)
-
当前状态:读取
/proc/pid/status
并解释VmSize
字段 -
具体分析:读取
/proc/pid/smaps
,分析mapping及各个内存大小相关的字段
3.2 Java堆
Java堆的大小是系统为应用程序设置的,可通过设置AndroidManifest
中的application.largeHeap
属性来获取更大的堆空间限制。Android8.0之前的图片像素数据存放在Java层,当图片使用或缓存设计不合理,就很容易消耗掉大量的Java堆空间,从而引发OOM。而8.0及其之后,像素数据被修改到了Native层,Java堆的问题会有所缓解。
系统为Java堆的分析提供了较大的便利,其状态及大小限制都可以通过Runtime
接口进行获取,必要时还可获取Java的内存快照并结合MAT进行具体的分析:
-
大小限制: Runtime.getRuntime().maxMemory()
-
当前使用: Runtime.getRuntime().totalMemory() - Runtime.getRuntime().freeMemory()
-
具体分析: Dubug.dumpHprofData(String fileName)
3.3 FD数量
Linux把一切设备都视作文件,File Descriptor(文件描述符)为设备相关的编程提供了统一的方法。当我们执行IO、Socket及线程等相关操作时,都存在与之相对应的FD。进程的FD信息可通过读取/proc
下的虚拟文件来获取:
-
大小限制:读取进程状态
/proc/pid/limits
,并解释Max open files
字段 -
当前状态:读取进程文件
/proc/pid/fd
,计算文件数量 -
具体分析:遍历进程文件
/proc/pid/fd
,并通过Os.readlink
解释文件链接
3.4 线程数量
合理的线程使用可提高应用程序的运行效率,过度使用反而会增加CPU及内存的负担。为避免这一情况的发生,可结合进程状态及当前的线程列表进行分析:
-
当前状态:读取进程状态
/proc/pid/status
,并解释Threads
字段 -
具体分析:调用
Thread.getAllStackTraces()
获取当前所有线程的信息,包括线程名、调用栈及状态等
3.5 Native内存
通常说的Native内存是相对于Java堆而言的,Java堆区的内存有虚拟机代为申请和释放,Java层的业务代码无需关心。Native内存主要说的是由业务动态申请的内存,一般是业务so库,业务代码是c/c++实现的,常用的方式就是调用 malloc
函数申请内存,调用free释放内存。这些内存的申请都需要合理的释放,否则会导致内存不足。可结合Debug.getMemoryInfo()
以及/proc/pid/smap
文件来分析。
-
当前使用:读取
nativePss
,它是本进程内native层独占的内存和与其他进程共享内存的均摊的总和 -
具体分析:hook 业务每次
malloc
和free
函数,记录每次内存的申请和释放,并获取到对应的堆栈,最后进行统计分析
四、监控平台建设
完整的监控系统分两部分:客户端实时监控和后端统计分析。在客户端方面,包括可应用于外网的页面泄漏检测、FD数量、线程数量、图片监控及页面水位等,采集到的数据会统一上报到我们的后端性能分析平台,并进行分析、归类、聚合、展示及提单等,从而帮助我们快速地发现、解决问题。
4.1 客户端监控
4.1.1 内存基础监控
内存基础监控主要负责对客户端的整体内存状态进行监控,借助线上海量用户的复杂案例来帮助我们发现在开发及测试阶段难以发现的问题,其实现主要包括三大部分:监控、分析及上报。
监控模块主要实现对虚拟内存
、Java堆
、FD数量
、线程数量
、PSS
、Native Heap
的监控,根据系统限制或设置固定数值,定期查看其状态,当触发阈值后,将会进入到分析模块并采用与其相对应的分析手段。监控模块是一个以读取内存状态为主的轻量级操作,对用户影响较小,可实现线上运行。
分析模块会根据触发阈值的类型来采取相应的分析手段。比如说,虚拟内存达到了我们所设定3.6G,就会去读取/proc/pic/smaps
文件,而线程、FD则会读取相应的线程或FD列表,Java方面还会进行dump的操作及客户端分析。
Java堆是我们需要重点分析的对象,页面泄漏主要体现于此。这一部分的实现采用了快手KOOM的方案,利用系统
Copy On Write
的特性,fork子进程后进行dump,避免了进程的冻结,后续的页面泄漏分析也会在独立的Service中执行,尽量降低对主进程的影响。
最后是上报的模块,主要负责对接性能平台,把客户端生成的分析结果及业务数据一起上传到服务器,最后进行聚类展示及提单修复。
4.1.2 so库内存分析与监控
通过线上的监控,确实发现了存在native的内存泄漏,但进一步分析时遇到了阻碍,原因是native内存泄漏无法与代码so关联。我们调研了腾讯内部开源的loli_profiler
,将其分析手段集成到apk中,来解决native内存分配与so的关联问题,基本原理是 hook malloc和free两个函数,记录和分析内存的申请和释放(实现细节我们在《全民K歌内存篇3——native内存分析与监控》会详细阐述,本篇不多展开)。
预期中的整体分析流程如下图,APP运行时,开启监控开关,更新监控的配置,如要监控的so列表,内存阈值等,采集到数据后,将记录写入手机文件。然后将文件上传到质量平台,质量平台通过脚本自动化分析,发现内存问题并提单到工单系统,推动开发侧修复。由于该方案有一定的性能开销,若非必要我们不会打开线上的native分析方式,当前native问题的主要推进流程是:外网监控native内存总量->发现可能存在泄漏异常->上报用户日志、路径等关联信息->内网分析信息并复现场景->使用native内存分配与代码关联的分析工具抓到问题源->推进解决。
4.1.3 图片内存分析与监控
我们知道android系统在8.0之后,图片的内存申请是放到native层。基于此,我们展开对图片的检测与分析。基于bitmap_profiler实现图片的分析与监控,整体框架如下图,原理主要是hook了jni层的createBitmap
函数,实现细节请关注《全民K歌内存篇3——native内存分析与监控》。
4.2 后端分析
我们需要对客户端监控上报的数据进行进一步的分析处理。包括对客户端数据进行解析、分类、聚合及展示等。同时也会对接我们的工单系统,支持一键提单及状态更新等操作。从模块的角度,共有业务场景的内存分布、内存泄漏上报、内存泄漏聚类、大图聚类、dump文件分析及大内存监控等。
4.2.1 各个业务场景的内存分布
链路上报是我们数据上报中的重要组成部分,记录了用户的页面访问过程。我们把页面跳转时记录的内存数据记录在链路中进行上报,从而统计出应用运行过程中各个业务场景的内存状态。另外,还有自动化的脚本来模拟用户的使用,记录各个场景的内存增量。如下图监控,我们可以快速得知:7.17版本与7.16版本对比,线程数、NativePss有所下降,这是我们7.17版本针对线程创建做了优化的效果;但是Fd数在“大卡片”场景有所上涨,需要提单推进解决。
4.2.2 页面泄漏
页面泄漏用于展示客户端通过Java堆分析得到的结果,支持版本、账号、时间、堆栈等多维度的检索。内容上则主要包括泄漏的页面类名及堆栈,通过上报次数可判断问题的严重程度。如下图,我们抓到的内存泄漏清单,并附带有详细的堆栈信息。此时,只需要点击右侧的“提单”按钮,便可以提一个bug单并附带堆栈信息和原始dump文件给到对应的业务开发人员。
4.2.3 So库内存常驻分布
将客户端采集到的so库内存申请和释放记录原始文件,对采集到的信息进行多点聚合统计。如下图,可以观察到app在直播上下滑运行过程中每个so内存常驻量分布,其中常驻内存比较高的是liteavsdk.so和hwcodec.so,峰值有超20M,虽然未有泄漏的情况发生,但也存在优化的空间,可以降低内存常驻量。如此,便可以非常直观的监测到so库常驻量偏高和内存泄漏等异常情况。
4.2.4 图片监控
客户端将图片的信息上报到性能平台后,我们可以很方便的观察到应用内图片申请内存情况。如下图,我们通过对图片大小、尺寸的检索,即可发现大图、重复创建等问题,有异常则可一键提单给对应业务开发同学优化。
有时,只有图片的内容信息,仍然无法快速与业务场景进行关联。在客户端检测到图片创建的同时,我们也抓取了对应业务堆栈信息,当检测到图片问题时,一同上报到性能平台,极大提高业务同学查找问题效率。
4.3 典型实例分析
4.3.1 页面泄漏
android.os.FileObserver$ObserverThread.contextClassLoader
dalvik.system.PathClassLoader.runtimeInternalObjects
java.lang.Object[]
com.tme.karaoke.lib.resdownload.m.csY
kotlin.SynchronizedLazyImpl._value
com.tme.karaoke.lib.resdownload.q.ctl
java.util.LinkedHashMap.head
java.util.LinkedHashMap$LinkedHashMapEntry.value
com.tme.karaoke.lib.resdownload.o.ctk
java.util.LinkedHashSet.map
java.util.LinkedHashMap.table
java.util.HashMap$Node[]
java.util.LinkedHashMap$LinkedHashMapEntry.after
java.util.LinkedHashMap$LinkedHashMapEntry.key
com.tme.karaoke.lib.resdownload.n.ctc
com.tme.karaoke.lib.resdownload.f.csy
com.tencent.karaoke.module.giftpanel.animation.BaseAnimationResStrategy$checkResource$1.$result
com.tencent.karaoke.module.giftpanel.animation.BaseAnimationResStrategy$AnimationResResult.animationPair
android.util.Pair.first
com.tme.karaoke.lib_animation.animation.LowLittleAnimation.mContext
com.tencent.karaoke.module.detail.ui.DetailActivity
我们上报量比较多的一个页面泄漏是BaseAnimationResStrategy
导致的,这是一个对配置动画资源进行管理的策略类,当业务调用动画播放而资源尚未就绪时,会执行网络下载,这是一个耗时的异步操作。当页面退出后,仍被强引用持有,从而导致内存泄漏。这一类问题可通过修改为弱引用解决。
除此之外,我们的外网监控还发现了Handler、匿名内部类、单例等100多例长期存在外网的泄漏问题。
4.3.2 线程泄漏
Thread[android.media.AudioRecordingMonitor.RecordingCallback,5,main]:RUNNABLE|13687
Thread[android.media.AudioRecordingMonitor.RecordingCallback,5,main]:RUNNABLE|13774
Thread[android.media.AudioRecordingMonitor.RecordingCallback,5,main]:RUNNABLE|13867
// ...略过大量同名线程,共有258个
这是我们外网监控上报上来的其中一个线程泄漏问题,单个进程出现了258个同名的线程,是非常严重的线程泄漏,通过线程名我们锁定场景(我们有工具检测无名线程,规范上我们不允许无名线程存在,这会帮助快速锁定线程问题的业务场景),排查代码逻辑,发现是注册录音回调时创建了一个线程而没有解注册导致的。
mARecorder.registerAudioRecordingCallback(Executors.newSingleThreadExecutor(), mAudioRecordingCallback);
外网监控方案上线后,我们发现多个线程泄漏问题。其中,类似上述同名线程数超过200多个的严重泄漏问题就有3例。
4.3.3 So库内存案例
通过监控发现,直播native内存有持续上涨的现象,我们在内网打开so库内存分析工具并复现了该问题,快速定位到了是libwnscloudsdk.so`在直播场景内的常驻(申请但是未释放)量持续上涨,20分钟约常驻量增长3M,关联到此块内存申请的native代码后,发现确实存在内存泄漏问题,最终推动修复了该问题。(下图一表示该so内存常驻总量持续增长,图二表示该so的函数(xputf162utf8)重复申请内存2878次、每次申请267byte且都未释放)
>>> 重复次数2878 >>> NativeOriginRecord(soName=libwnscloudsdk.so, address=0x5ec68200, topAddress=0xb13b6db5, size=267, trace=0xb12c4000 0x5ec68200 0xb13b6db5 0xb12c00d4 0xb135fdaf) >>>>
+xputf162utf8(unsigned short const*, unsigned int, xp::strutf8&) >>> b13b6db5-b12c4000=000f2db5
+unknown:ffffffffffffc0d4 >>> b12c00d4-b12c4000=ffffffffffffc0d4
+WnsLogger::asyncWriteToDisk(std::__ndk1::vector<WnsLogItem*, std::__ndk1::allocator<WnsLogItem*> >&) >>> b135fdaf-b12c4000=0009bdaf
4.3.4 图片内存问题
通过图片检测工具,我们能够很明确得知道应用创建的bitmap占用内存大小,以及创建图片的堆栈信息。经过脚本自动统计和人工分析,主要归类为如下几类问题(详情请关注系列文章,本篇只列举问题分类)。
1)、原始图片过大:
图片的大小超过View的大小数倍时,而解码图片时按照图片尺寸来解码就很浪费了。(注:此类情况主要发生在非Glide组件创建图片的场景)
2)、重复创建:
我们抓到了相同的图片由不同的堆栈调用来创建,这里逐一分析是否有优化空间,尽量复用来节约内存;
3)、未及时recycle:
在我们的业务里,时常有bitmap拷贝行为,通过源bitmap对象获得变换后的bitmap对象,这里需要考虑源bitmap是否可以立即释放。
4)、Web动态框架问题:
K歌集成了腾讯浏览器自研的hippy框架,它是一套类ReactNative的移动端跨平台解决方案。我们业务大量使用了该动态框架,甚至运营可以直接配置上架页面,这里时常存在超大超长的不规范图片。hippy加载网络图片时透传url给到客户端,最终的解码由客户端来实现。透传时没有把View的宽和高带给客户端,客户端最终以原始图片尺寸来解码。在原始图片尺寸大于View时,会浪费不必要的内存。这里正在推进浏览器团队优化此类问题。
5)、图片引擎问题:
问题1:我们图片引擎是集成的Glide,设置的默认RGB_565格式没有生效,查看源码是原始设计API在26以上默认到ARGB_8888。
问题2:在Glide内部,当图比view小的时候,在ImageView的scaleType不是fitXY和centerInsider配置的时候,会根据View的尺寸来放大解码,从而拉高内存的占用。
问题3:业务繁杂,长期处于不可见状态的页面加载的图片一直未释放,其实这里可以适时释放掉,以降低内存水位。
问题4:发现K歌内还有少量业务还在使用旧图片加载引擎,需要彻底删掉。
五、自检工具
解决性能问题一般是有风险的,所以越早发现问题解决代价越小,我们通过建立自检清单来加强每位开发的性能规范意识,把大量的性能问题拦截到开发阶段,高效又安全,同时也能增加每位研发同学的性能风险意识。如下图,是我们的性能自检清单:
但是,自检无疑加重了开发的工作量。所以,我们将各项工具集成于客户端的包内,客户端/web开发、测试同学可以直接在app内部快速完成性能测试。我们集成了Native内存分析、图片列表、内存增量、线程、fd等多种能力。以下选取常用自检功能进行介绍。
5.1 内存增量
内存增量的工具提供了开始、结束打点的UI入口,使用者可在合适的时机进行触发,然后分析出内存的增量。以观看直播为例,我们可以在进入直播间前点一下开始打点
,观看直播并退出后,再点击结束打点
,工具将会根据这2个时间点的内存数据算出详细的内存增量,展示形式如下:
得到这样的增量结果后,我们可以轻易地判断出60M的PSS增量中,各个类型的内存成份分别增加了多少,具体的内容是什么,从而去判断这里增量是否合理。
5.2 So库内存分析
默认配置了K歌所有集成的业务so,在点击启动后开始检测,将每一次申请和释放内存的记录写入文件,我们提供了打点截段功能,可以仅记录该时间段内的native内存使用情况。文件导出到PC端或者上传到后台,通过脚本即可统计分析每个so的内存分配和回收情况,帮助我们定位native内存泄漏、大内存块申请、频繁申请释放等问题。
5.3 图片检测
图片检测工具提供了应用内的入口,开发者可以很方便的开启或关闭图片监控,观察到图片的大小、占用内存、堆栈等信息。帮助开发自测,检测图片是否合理。
六、当前收益与未来规划
截止7.16版本前,外网监控帮助我们快速发现页面/线程泄漏120例问题,以及通过分业务场景的内存水位/线程增长/fd增长统计标记出了资源消耗的重灾区。通过解决资源泄漏+优化高内存水位场景后,Crash降幅超过25%,白屏闪退反馈下降明显,应用稳定性得到了极大的提高。更重要的是我们建立起的综合治理方案:规范的自检流程、便捷的检测工具、严格的线上监控、标准的资源水位基线使得内存等性能问题真正达到了“防微杜渐”的效果,版本质量逐渐变得稳定可控。
未来我们将进一步优化完善监控与工具的建议,主要包括以下几个方面:
-
dump文件裁剪及性能优化;
-
实现并完善以页面为维度的监控方案 ;
-
主动探测页面、FD或线程泄漏;
-
更多维度监控如cpu、磁盘、存储使用率等;
-
外网性能指标波动告警,如发现动态配置、运营、web/hippy等非版本变更影响内存水位;
-
优化native分析工具的性能、减少对业务的性能影响,实现外网实时监控。
七、总结
在我们提出内存监控与质量综合方案之后,优化的工作变得高效、可持续、可量化,它有以下优势:
1、接入方便、使用灵活。所建设的工具都以模块化开发,打包成独立的aar并集成在apk中,无需依赖pc端等外部工具。并且,通过不同开关模式的配置,能够在开发、测试、灰度、外网各个流程中发挥各自的作用和价值;
2、线上监控,全面覆盖。支持在线采集线程数、FD数、图片bitmap、Java dump文件,通过抽样等方式数据上传到性能平台,实现后台自动分析数据,发现异常问题;
3、数据量化,评估科学。我们在核心场景通过测试脚本实现了内存数据检测自动化、固化了测试场景,在外网也有抽样采集数据,不仅节约了测试人力,同时也让数据更准确有效。通过不同版本之间的差异对比,在测试阶段及时帮助发现新版本由于业务迭代引入的问题。在灰度阶段,帮助我们评估版本质量;
4、流程闭环,持续优化。性能优化是需要长期持续跟进的,我们建立了“开发自测”、“自动化测试”、“外网监控”性能优化流程,并不断丰富工具能力,发现问题则提bug单给到对应开发人员,并提供有用的“堆栈”等信息,辅助开发修复优化,让优化工作能够真正的实现常态化。
引用
1、Memory Layout on AArch64 Linux:https://www.kernel.org/doc/Documentation/arm64/memory.txt
2、KOOM——高性能线上内存监控方案:https://github.com/KwaiAppTeam/KOOM
3、loli_profiler:https://git.code.oa.com/xinhou/loli_profiler
4、BitmapProfiler:https://git.code.oa.com/cainjiang/BitmapProfiler
作者简介
looperzeng:全民K歌研发三组Android开发
winghe:全民K歌研发二组Android开发
转自:https://my.oschina.net/u/3735058/blog/4967385