一、前言
当我们谈论Android应用程序的性能时,内存管理是一个至关重要的方面。当应用的内存存在不合理的使用时,就容易发生问题,如OOM、应用存活率低、界面卡顿等等。优化内存使用可以显著提高应用的响应速度、稳定性和用户体验。过去一段时间我们对司机端App的内存问题做了大量的治理,在此分享治理的过程,希望能够为其他团队提供一些经验和启发。
二、项目现状和治理成果 2.1 项目现状
线上OOM导致的崩溃率较高
线上OOM设备崩溃率最高为0.8‱,占整体崩溃率的20%,在整体Crash率中占据了大头。
高频OOM页面发生在核心页面、核心业务流程
在所有OOM崩溃上报的页面中,首页、车贴拍摄页占据了Top 2。首页作为APP的最重要的页面,频繁崩溃会严重影响用户体验;车贴拍摄页面作为公司的核心业务流程,一旦异常也会阻塞司机接单,导致频繁上报业务工单。
缺乏相关防劣机制、内存问题卡口
内存泄露的问题全靠研发自身的代码质量把控,线下没有任何的监控机制。经过多个版本的迭代,内存泄漏问题愈发严重。
2.2 治理成果
通过长时间的内存治理,截止最新版本,由于内存溢出导致的设备崩溃率从最高0.8‱降至0.01‱;线上的内存触顶率由0.64%下降至0.01%;核心页面、核心流程OOM崩溃率下降至0。并且在线下建立有效的防劣化机制,成功拦截上报了多处的内存泄漏问题带到线上。
三、治理策略
在做性能优化和技术优化前,制定好整体的策略方向往往会事半功倍。针对我们App当前的问题现状,制定了以下的优化策略:
3.1 治理阶段
高频OOM页面治理:从线上用户使用体感的角度考虑,高频OOM页面时最急需解决的,此部分的ROI最高,因此首先针对这些页面做专项的治理。
Java内存泄漏治理:单个内存泄漏和大对象占用虽然不会立刻导致OOM,但随着应用的使用时长增加,泄漏的增多一样会增加OOM的概率。此部分主要解决Java层的内存泄漏问题,包括页面内存泄漏和不合理的大对象引入。
Native内存泄漏治理:针对Native层导致的OOM,在我们项目中次数较少,而且由于C/C++不像JVM拥有自己的内存管理机制,在内存管理这块更为复杂,治理成本较高,此部分ROI较低,因此放在最后处理。
3.2 防劣阶段
经过上述的治理阶段之后,一般都能取到不错的成果,但治理是长期的,如果没有一套长久有效的防劣机制,往往很快就会打回原形。回过来看我们治理过程中的遇到的一些问题和实践,我们针对性的建立了一套多个维度的监控防劣机制。
四、治理实践 4.1 高频OOM页面专项治理
高频问题一般都会有相同的特征,针对这类问题我们处理的思路一般是:寻找共性特征->线下复现验证->定位问题点修复。这里分享一下我们两例高频OOM页面的排查过程。
4.1.1 首页OOM排查
寻找共性特征
我们通过抽查几例首页OOM前的离线日志,发现此类用户都命中了大量的新单推送弹窗展示,因此推测与这个弹窗的展示有关。
线下复现验证猜想
线下我们模拟线上频繁创建弹窗的场景:
在首页静止,并且每两秒触发一次新单推送弹窗展示。
运行8分钟左右
我们通过Android Studio提供的Profiler工具,可以看到运行8分钟后(约240条),内存就上涨50MB,并且没有下降的趋势,对比线上发生OOM的case,订单推送弹窗都是触发了2000+次以上,意味着上涨的内存会更多。基本可以确定线上首页的OOM问题是由这个弹窗大量展示引起。
定位问题点并修复
我们现在已经确定是订单推送弹窗导致的OOM,但还未确定这个弹窗为什么导致内存上涨了。一样是通过Android Profiler通过dump测试前后的内存快照信息:
可以发现float[]、SloverVariable[]、SloverVariable、ArrayRow布局相关的对象增长较多。
观察SloverVariable[]的Gc Root,发现一处可疑的引用:SloverVariable[]->OfferOrderNewPricePushDialog.mView->LifecycleRegistry.mObserverMap-> ... MainActivity。怀疑与Lifecycle的使用有关。
查看该弹窗代码发现该Dialog初始化时注册了Activity的Lifecycle监听,但未在dimiss时进行反注册,导致展示过的AppInPushNotificationDialog弹窗都没有被释放,又由于首页是常驻的,随着使用时长增加,推送弹窗会一直增加,内存会一直上涨到OOM。
定位到问题之后,修复方案就很简单了,只需一行代码,在弹窗消失的时候移除掉Lifecycle监听就能解决该问题。
4.1.2 车贴OOM排查
上面首页的问题我们依靠回捞的离线日志明显看出共性特征(大量的推送弹窗展示),但如果其他case并没有这些明显的业务信息呢?这种我们就比较难去排查原因。
比如这个车贴拍摄的场景,这是一个通过相机实时识别车辆上车贴情况的业务场景,在此类case的离线日志中并没有拿到一些有用的信息。为了能拿到OOM案发现场时的内存快照信息,我们增加了一个上报策略,配合内存海绵方案(OOM时清除堆内存大对象统计),在OOM时尝试dump内存快照信息上传到异常平台。
字节内存海绵方案:https://juejin.cn/post/7052574440734851085
寻找共性特征
在上报的内存快照文件中,发现byte数组的占比非常高,达到了总体90%以上。
可以发现byte数组大部分是由几个录制和图像相关的类创建并持有。可以推测跟相机的图像录制逻辑有关。
线下复现验证猜想
在线下模拟拍摄过程中,虽然没有复现OOM,但发现在拍摄过程该页面存在频繁的内存抖动和GC。
定位问题点并修复
以上持有byte数组引用的相关类是拍摄过程中图像录制所使用,与对应的业务开发沟通发现大部分录制对象没有进行复用和及时回收,而且识别功能的实现是1秒回调多帧去进行识别,这样就导致了页面会频繁创建对象和GC,从而引起内存抖动。
后续通过对象池复用等优化手段来减少录制对象的创建,大大减少了频繁GC,灰度上线验证后发现成功解决了该页面的OOM问题。
4.2 Java内存泄漏治理
依赖JVM的GC机制,能够将过期对象所占的内存空间释放,减少了内存占用。既然有GC机制了,为什么还会泄漏呢?因为GC回收是根据可达性来判断对象是否引用的,当GC动作发生时,如果一个对象被gc root对象持有,那么它是无法被回收的。
当一个对象所定义的生命周期结束了,仍有被GC ROOT对象所持有无法释放,那我们就认为这个对象发生内存泄漏。
在Android中页面承载了大部分的内容,页面都有自己的生命周期,所以我们可以从这两个角度去排查和治理Java的内存泄漏问题。
Activity、Fragment页面泄漏
不合理大对象占用未释放
工欲善其事,必先利其器。我们首先需要有方法去发现这些内存泄漏的问题(页面泄漏、大对象占用),在Java堆内存监控这一部分,行业上已经有不少成熟的方案和工具,在线上和线下都经过大量的稳定性验证,读者可以根据自身项目的情况选择接入使用。
名称公司原理简述特点githubLeakcanary开源WeakReference + GC + haha分析 接入成本低,适合线下使用。 https://github.com/square/leakcanaryKoom周期 + 组合阈值 + 子进程dump + shark 分析 + xHook流式裁剪。 监控功能齐全,适合线上使用。 https://github.com/KwaiAppTeam/KOOM/blob/master/koom-java-leak/README.zh-CN.mdMatrix腾讯ActivityLifecycleCallbacks 和弱引用来检测 Activity 的内存泄漏 + haha分析 适用于与组件内存泄漏、图片内存泄漏,特定场景。 https://github.com/Tencent/matrix/wiki/Matrix-Android-ResourceCanaryTailor字节内存快照裁剪压缩工具,利用 xHook 在写入hprof是流式裁剪。 轻量级别dump库,适用于oom、anr场景。 https://github.com/bytedance/tailor/blob/master/README_cn.md
4.2.2 实践总结
经过调研,前期我们选择线上接入Koom-Java Heap泄漏监控工具进行检测,后期则替换成部门自研的内存监控工具(性能更好,采集策略更合理)。通用的采集策略基本是定时轮询+内存超过阈值时触发采集,作为常规的线上内存泄漏排查策略什么问题。但为了能更精准的发现OOM时用户内存泄漏的情况,我们选择化主动为被动,新增应用OOM时才进行采集分析上报(配合上文提到的内存海绵方案),这种策略有以下两个好处:
采用定时轮询+内存超过阈值时dump内存的方式,会影响到没有内存问题的用户App性能,选择在OOM时才尝试dump能最小化地影响用户性能。
当内存超过阈值时不一定是有问题,但OOM时去dump发现问题的概率会更高。
通过上述的各种采集策略回捞数据后,发现了多个业务模块都存在Java内存泄漏、和不合理大对象占用的现象,其中不少还是核心业务模块,触发泄漏的频率较高。
以下是我们在治理泄漏问题过程遇到的一些比较典型的Case:
典型Java内存泄漏场景
Handler,Thread等匿名内部类隐式持有外部类导致泄漏。
使用单例对象时,定义为接口类型的成员变量(很容易隐式持有外部类引用)在赋值使用后,需要及时释放引用。
静态变量持有了Activity未及时释放。
注册的广播监听器、系统服务未及时反注册。
传给二方、三方SDK的Context是Activity/Fragment,SDK内部导致了泄漏,建议传Application。
典型不合理大对象占用场景
加载大图时,Bitmap使用之后未释放。如静态变量持有了Bitmap引用,在加载完图像之后没有释放引用。
大数组的占用。如Glide的缓存池就使用了集合数组进行维护,可以在选择合适的时机进行一定的释放。
不同于JVM的自动垃圾回收机制,C/C++层的对象内存申请和释放都需要开发者自行管理。大多数情况下,开发者都通过malloc和free函数来申请和释放,或者是new和delete关键字,它最终的也是由malloc和free的函数来实现。更高阶的实现方式,可以直接调用更底层的系统接口,如mmap。
比如以下例子,仅因为缺少一行代码就会发生内存泄漏:
#include
void leakFunc(){
int* p = new int(3);
// delete p; // 注意:这里如果不执行就可能会导致内存泄漏
}
int main() {
leakFunc();
}
就算我们能保证我们的代码质量不会出现这种错误。但大型的项目中都会引入不少的三方so文件,这一类的代码质量是不可控的,很容易就引入了Native泄漏。为了能把这些泄漏问题都暴露出来,我们可以使用一些工具去做监控和排查。
4.3.1 治理工具
针对C/C++内存分配和方法需要自行管理的特点,从入口和出口来做切入点,大部分工具的原理都是通过hook内存分配和释放的方法函数,去捕获泄漏对象。以下是一些行业比较主流的工具:
名称公司原理简述githubmalloc debugAndroid系统自带系统内部直接替换掉libc内部malloc,free等函数 无perfettoAndroid系统自带基于ftrace、atrace、heapprofd 无koomHook malloc/free + mark-and-sweep算法分析 https://github.com/KwaiAppTeam/KOOM/blob/master/koom-native-leak/README.zh-CN.mdraphael字节内部使用bytehook hook 多个内存分配、释放方法。开源版本使用 inline hook。 https://github.com/bytedance/memory-leak-detector
4.3.2 实践总结
相比于Java层的OOM,我们项目Native层的OOM较少,但也是存在一些上报。这一部分我们的思路是:
线上监控Native相关的泄漏
线下使用工具排查高频OOM场景
线上泄漏Native监控
KOOM Native检测工具利用mark-and-sweep算法去分析整个进程 Native Heap,能够做到自动分析泄漏对象,这个特点很适合作为线上监控能力。因此我们在线上接入了该工具,也发现了项目内多个so都存在不同程度的泄漏现象。
线下排查高频OOM场景
Native的内存问题我们不能只聚焦在泄漏监控修复这一维度,不合理的内存分配使用也很容易导致OOM。当我们把线上发现的泄漏都修复后,发现还有零星的Native OOM上报。通过分析排查这些场景虽然不存在内存泄漏,但存在不合理的内存申请。
比如有个case是在发送图片之后就容易报Native层的OOM,经过线下模拟场景发现在发送图片前后Native内存会发生突刺。
通过dump突刺的内存信息,发现突刺的原因是加载了一个大Bitmap,由于Android 8.0以后Bitmap得内存分配移到了Native heap,推测应该是大图直接加载导致。
经过代码排查发现此部分存在对图片作旋转的操作,直接加载了原图转化为Bitmap去执行,因此如果图片比较大就很容易造成内存突刺。
4.4 内存问题防劣化机制
经过一系列的治理手段,线上的OOM次数和App的平均内存占用已经有了大幅度的下降。但如果没有制定一套完善的防劣化监控机制,随着时间的推移内存问题很容易就会继续恶化。
4.4.1 项目现有的防劣手段
1、MTC(自动化测试平台)版本性能卡口报告
在QA侧会对每个版本的安装包做基础的性能测试评估,但测试场景较少,检查项比较单一,检测时长较短,很难发现内存问题。
2、线上APM监控
线上Apm内存已经有相关的监控,可以覆盖常见的Java内存泄漏和大对象。但为了防止影响App性能,触发采集的策略条件较多,无法覆盖较多的场景;而且纯依赖线上Apm的监控,问题已经带到线上影响了用户,问题发现比较滞后;并且各业务线由于Native层、线程泄漏较少,线上APM实现Native层泄漏监控功能的ROI不高,因此也缺乏Native层相关的监控。
4.4.2 线下内存防劣化机制
回顾我们在治理过程遇到的问题、场景和使用到的工具,我们做了一些整合和补充,建立了一套线下的内存监控体系:
4.4.2.1 监控层
1、Java内存、Native内存、线程泄漏监控
秉着不重复造轮子的前提,Java内存、Native内存、线程泄漏行业上都有比较成熟的方案和原理讲解,直接集成使用排查过程中使用到的工具,此部分不再详述。
2、内存抖动、频繁GC监控
这一部分主要是为了监控某些页面频繁GC的问题。可以利用JVMTI(ART TI) 提供的能力实现,JVMTI具有以下能力:
重新定义类。
跟踪对象分配和垃圾回收过程。
遵循对象的引用树,遍历堆中的所有对象。
检查 Java 调用堆栈。
暂停(和恢复)所有线程。
JVMTI提供了很多虚拟机行为的监听事件:
typedef struct {
....
/* 81 : Garbage Collection Start */
jvmtiEventGarbageCollectionStart GarbageCollectionStart;
/* 82 : Garbage Collection Finish */
jvmtiEventGarbageCollectionFinish GarbageCollectionFinish;
/* 83 : Object Free */
jvmtiEventObjectFree ObjectFree;
/* 84 : VM Object Allocation */
jvmtiEventVMObjectAlloc VMObjectAlloc;
} jvmtiEventCallbacks;
利用GarbageCollectionStart与GarbageCollectionFinish监听GC的开始与结束,通过【X时间内GC次数超过Y次】判断是否发生频繁GC。
通过VMObjectAlloc与ObjectFree监听对象分配,收集内存抖动期间分配的对象信息(类名路径、对象大小)。
3、页面内存上涨
这部分监控则是为了监控使用时长较长的一些页面(比如首页)内存持续上涨的问题。基本的实现思路是定时采集页面中内存的数据作为样本,然后去验证这组样本数据是否呈上涨趋势。通常我们要验证一组数据是否呈上涨趋势,可以采用**斜率法**的方式去进行判断计算。
页面的内存数据采集使用ActivityLifecycle + Debug.getPss()的方式实现。
数据上涨趋势判断则依赖斜率法去判断。
这样通过一定的采集策略配置,就能监控到内存稳定上涨的常驻页面。
4.4.2.2 上报反馈层
1、提高问题感知
当发现内存问题时,应用会通过吐司的形式告知研发问题类型,增加研发感知,并通过本地log输出详细的排查信息,帮助研发快速的修复内存问题。
2、问题分配闭环
为了避免漏网之鱼,除了本地的提醒告知之外,还会结合实时日志和自研的异常上报平台,自动统计上报内存问题。利用平台把问题分配到对应的研发修复,实现问题的闭环。
3、SDK内存问题卡口
在治理过程中,我们也发现了不少内存问题是二方、三分库引入的。SDK的引入和变更,目前内存这块是处于0卡口状态,只能依靠SDK研发本身的自测,而自测过程中更多关注的可能是Crash、Anr这种体感比较明显的问题,内存问题比较少去关注。因此我们打通了MTC(自动化测试平台)的性能报告,增加了一项SDK的内存问题报告,作为SDK变更的准入卡口。后续在试验稳定之后,会补充到版本的性能报告,为QA侧提供更多维度的内存性能检测项。
通过以上的防劣化机制,在平时开发和测试版本回归阶段,我们监控收集到了很多新增的、线上未捕捉到的内存问题,并通过本地提醒和异常平台上报告警的能力分配到各个业务开发进行修复,成功避免了问题带到线上。
五、总结
过去半年,我们对历史的内存问题做了一次比较长时间的专项治理,也取得了不错的成果。在此我们总结出以下经验:
专项治理前可以先制定好总体的策略和方向,从用户体感、投入成本的角度去决定治理的优先级,循序渐进进行治理。
避免重复造轮子。很多内存问题目前已经有很多成熟的通用方案和工具,选择适合的即可,我们应该把更多的精力放到问题排查上。
长治才能久安。线上的问题排查是很费时的,我们应该尽可能把问题在线下就暴露出来。因此做好防劣化的建设十分有必要。
目前Java层的OOM已趋于稳定,但Native层还有少部分的余量上报,后面会继续更深入地进行研究和治理。
结合MTC自动化测试平台实现更多内存相关的检测项能力,增加版本回归中性能报告的检测维度,减少人工自测的成本。
KOOM——高性能线上内存监控方案
ART TI
热门跟贴