背景
当我们谈论稳定性的时候,通常指的是crash,android有java crash
& native crash
,iOS有NSException
& BSD signal
& Mach EXC
,业内通用的指标主要是session crash率(crash次数 / 启动数)和设备crash率(crash设备数 / 总设备数)。
对于上述指标,业界有多个成熟的监控平台,提供埋点、上报、展示到报警一站式服务,如Bugly
、Fabric
等。也有专业捕获crash的SDK,方便我们在此基础上按需定制监控平台,如KSCrash
、Breakpad
等,有这么完善的基础设施,稳定性的拼图似乎已经很完整了,果真如此么?很遗憾并不是,接下来我们开始讨论本文第一部分,退出率,请看下面这个案例。
退出率
从Wakeups说起
某日,多个大V主播反馈频繁崩溃(iOS),无法开播,要知道,大V的一次开播事故,影响的是百万级用户的用户体验,比一次普通的崩溃严重的多。相关同学立刻开始调查,结果没有一例crash上报,通过这次事件,我们意识到现有的crash监控系统是有漏洞的,正在无计可施之时,运营同学非常给力的拿到了主播的系统日志,其中关键的信息如下: Wakeups: 45001 wakeups over the last 142 seconds (316 wakeups per second average), exceeding limit of 150 wakeups per second over 300 seconds
这里的Wakeups是什么意思呢?查阅苹果官方文档[1],可以看到如下解释:
简单概括如下:Wakeups是“资源异常”下的一个子类,指的是频繁唤醒线程,消耗CPU资源并增加功耗,在超过阈值并处于FATAL CONDITION
的条件下会触发崩溃,通常见于线程间频繁交互的场景。
了解了原理,接下来就好办了,通过分析系统日志中记录的触发唤醒backtrace
,定位到问题发生的原因是粉丝们频繁的给大V发私信,导致高频的线程交互以及磁盘读写,这两个操作都会触发线程唤醒,最终使wakeups超出阈值。我们通过优化这两个操作,降低了线程唤醒频率,大V开播恢复了正常。至此,wakeups似乎圆满解决了,但作为有追求的程序员,不能满足于只解决眼前问题,如果用户不给我们反馈,或者不肯上传系统日志怎么办?我们需要能在线上监控到wakeups问题,要做到这一点,我们需要深入源码,了解操作系统是怎么做的。
Wakeups是怎么触发的
图1
图1是通过阅读XNU源码总结的系统监控wakeups的流程图,task_ledgers
是内核维护的当前进程的”账本“,保存了各种系统资源的使用情况。当发生频繁唤醒时,会通过init_task_ledgers
注册的回调函数task_wakeups_rate_exceeded
进行处理,若参数warning的值为1,说明wakeups超出警戒线,开启遥测收集唤醒线程的堆栈,若warning的值为2,说明wakeups回落到警戒线以下,关闭遥测,若warning的值为0,说明wakeups超出阈值,调用SENDING_NOTIFICATION__THIS_PROCESS_IS_CAUSING_TOO_MANY_WAKEUPS
触发EXC_RESOURCE
,当满足fatal条件时,调用task_terminate_internal
终止进程。
void init_task_ledgers(void) {
// ...
// 注册wakeups回调
ledger_set_callback(t, task_ledgers.interrupt_wakeups,
task_wakeups_rate_exceeded, NULL, NULL);
}
Wakeups的阈值定义由以下几个部分组成
#define TASK_WAKEUPS_MONITOR_DEFAULT_LIMIT 150 /* wakeups per second */
#define TASK_WAKEUPS_MONITOR_DEFAULT_INTERVAL 300 /* in seconds. */
/*
* Level (in terms of percentage of the limit) at which the wakeups monitor triggers telemetry.
*
* (ie when the task's wakeups rate exceeds 70% of the limit, start taking user
* stacktraces, aka micro-stackshots)
*/
#define TASK_WAKEUPS_MONITOR_DEFAULT_USTACKSHOTS_TRIGGER 70
如果300秒内的总wakeup数超过45000(300 * 150),则判断为超出阈值,若超出阈值的70%,则判定为超出警戒线,开启遥测。
/*
* Types of warnings that trigger a callback.
*/
#define LEDGER_WARNING_ROSE_ABOVE 1
#define LEDGER_WARNING_DIPPED_BELOW 2
void task_wakeups_rate_exceeded(int warning, __unused const void *param0, __unused const void *param1) {
if (warning == LEDGER_WARNING_ROSE_ABOVE) {
#if CONFIG_TELEMETRY
/*
* This task is in danger of violating the wakeups monitor. Enable telemetry on this task
* so there are micro-stackshots available if and when EXC_RESOURCE is triggered.
*/
telemetry_task_ctl(current_task(), TF_WAKEMON_WARNING, 1);
#endif
return;
}
#if CONFIG_TELEMETRY
/*
* If the balance has dipped below the warning level (LEDGER_WARNING_DIPPED_BELOW) or
* exceeded the limit, turn telemetry off for the task.
*/
telemetry_task_ctl(current_task(), TF_WAKEMON_WARNING, 0);
#endif
if (warning == 0) { SENDING_NOTIFICATION__THIS_PROCESS_IS_CAUSING_TOO_MANY_WAKEUPS();
}
}
SENDING_NOTIFICATION__THIS_PROCESS_IS_CAUSING_TOO_MANY_WAKEUPS
的核心逻辑如下:
// 获取wakeup信息
ledger_get_entry_info(task->ledger, task_ledgers.interrupt_wakeups, &lei);
// fatal判定
fatal = task->rusage_cpu_flags & TASK_RUSECPU_FLAGS_FATAL_WAKEUPSMON;
// 写日志
// 触发EXC_RESOURCE异常
// 终止进程
if (fatal) {
task_terminate_internal(task);
}
从中可以得出一个重要结论: 只有fatal
的EXC_RESOURCE
才会触发崩溃,否则只会生成相关日志。
我们来看fatal flag的定义:
/* flags for rusage_cpu_flags */
#define TASK_RUSECPU_FLAGS_FATAL_WAKEUPSMON 0x10 /* wakeups monitor violations are fatal */
flag的相关赋值在task_wakeups_monitor_ctl
函数中:
if (task->rusage_cpu_flags & TASK_RUSECPU_FLAGS_FATAL_WAKEUPSMON) {
*flags |= WAKEMON_MAKE_FATAL;
}
// ...
if (*flags & WAKEMON_MAKE_FATAL) {
task->rusage_cpu_flags |= TASK_RUSECPU_FLAGS_FATAL_WAKEUPSMON;
}
可以看到TASK_RUSECPU_FLAGS_FATAL_WAKEUPSMON
和WAKEMON_MAKE_FATAL
陷入了“鸡生蛋、蛋生鸡”的死循环中,源码中其他地方也没有这两个标志位的相关赋值,猜测可能是通过未公开代码或其他硬编码方式进行了置位,如果有了解的同学欢迎联系我们,感激不尽。
怎么监控wakeups
图2
图2是线程唤醒的流程图,可以看到,每次唤醒线程并对wakeup计数,都会调用ulock_wake
,只要hook这个函数,计数并获取backtrace
,就可以监控wakeups。如果我们只关心wakeup数,有更简便的方法,通过task_info
获取task_power_info_v2_t
结构体里的task_interrupt_wakeups
字段,即为当前进程的wakeup总数,定期查询计算增量即可,为了验证上述方案的准确性,我们本地模拟线程唤醒触发wakeup异常,并将自己统计的wakeup数和系统生成的WAKEUPS日志文件中的wakeup数进行对比,误差不到千分之一。
抓取堆栈
图3
图3是hook ulock_wake
获取backtrace
的流程图,通过记录ulock_wake
调用时的wakeup数和时间戳,在超出阈值时抓取backtrace
并记录到文件中。ulock_wake
是内核代码,不能通过常规的Method Swizzling或者fishhook来hook,需要通过Tweak在越狱机上hook。因此,wakeup相关堆栈目前只支持在越狱手机上进行抓取。至此,wakeups问题圆满解决,但我们并没有满足,而是进一步想到,除了wakeups,还有其他监控不到的稳定性问题么?
退出率定义
经过认真思考,我们认识到从前忽略了一个重要的基本事实,即应用的启动数和应用的退出数是守恒的。每次启动必然会有对应的退出,只要将所有的退出类型都枚举出来并监控上报,且总数能和启动数吻合,就能覆盖所有的稳定性问题。 基于以上思想,我们提出了退出率的概念,将退出分为以下十大类,每一类的退出率定义为 退出次数 / 启动次数。
图4
其中前五种退出类型是显著影响用户体验的问题,需要重点关注,crash(不含OOM)和OOM对应的是开头提到的通用指标;前台系统强杀指的是设备总内存紧张,应用在前台被系统强杀,比如iOS的jetsam,android的low memory killer,也包括其他一些资源问题,比如上文讲的wakeups;watchdog指的是卡顿引起的系统强杀,典型的即为iOS的watchdog和android的ANR;exit指的是我们主动在代码中自杀,通常情况下不应该有这样的逻辑存在。后五种退出类型绝大多数情况下是正常的退出行为,对用户体验无影响,我们只关注其中异常的情况,比如UI错乱导致的用户强杀,危险代码导致的系统重启等。
图5
图5是退出率占比的饼图,用户强杀和后台回收占据了绝对主导,重点关注问题已经全部归类到”其他“里,总占比不足1%,这和我们万分位的crash率也是吻合的。
图6
再来看下重点关注问题的退出占比(图6),可以看到前台系统强杀远远高于crash和OOM,颠覆了我们的认知。 那么非重点关注部分,是否都是正常退出的行为呢,看图7和图8:
图7
图8
俗话说,细节是魔鬼,如果我们分页面去看退出率(注意此处定义和前文不同,是退出次数 / PV)的分布,会发现一个很有意思的现象,不同页面的分布差异很大,比如LIVE_PUSH这个页面,用户退出率高达63.2%,说明这个页面用户体验较差,经调查是卡顿过高导致的,PASSWORD页面全部的退出都是由crash导致的,说明这个页面存在严重的bug。至此,我们的稳定性监控体系通过引入退出率统计而获得了完善。 接下来以一个比较重要的退出类型OOM为例,详细展开讨论我们是如何做优化的,即本文的第二部分,Android OOM治理。
Android OOM治理
图9
回顾前文,OOM在稳定性重点关注问题中的占比非常高,和占比最高的前台系统强杀也有很高的相关性,而OOM问题的定位又特别困难,通常需要投入大量的人力和时间,进行人工复现,灰度收集数据,提交记录二分法暴力验证等等。占比高又定位困难,可以说OOM治理是稳定性治理皇冠上的明珠。 提到OOM,肯定绕不开神器LeakCanary
,其原理也是面试题中的常客,作为Android内存泄漏监控的开创者,多年来一直为广大app保驾护航,解决了OOM治理从0到1的问题。那么直接接入LeakCanary
上线不香么?还真不行,LeakCanary
虽然非常优秀,但也存在以下几点硬伤:
-
无法线上部署
- 主动触发GC,造成卡顿
- Dump内存镜像造成app冻结
- 解析镜像成功率低
- 不具备上报能力
-
适用范围有限
- 只能定位Activity&Fragment泄漏
- 无法定位大对象、频繁分配等问题
-
自动化程度低
需要人工埋点
无法对问题聚类
既然没有现成的轮子可用,只能自己动手,丰衣足食,经过一番努力,我们打造了一套可以线上部署、兼顾线下、配置灵活、适用范围广泛、高度自动化,埋点、监控、解析、上报、分发、跟进、报警一站式服务的闭环监控系统。
图10
其核心流程为三部分:
- 监控OOM,发生问题时触发内存镜像的采集,以便进一步分析问题
- 采集内存镜像,学名堆转储,将内存数据拷贝到文件中,以下简称dump hprof
- 解析镜像文件,对泄漏、超大对象等我们关注的对象进行可达性分析,解析出其到GC root的引用链以解决问题
为完成这样一套监控系统,我们攻克了以下技术难题
-
监控
- 主动触发GC,会造成卡顿
-
采集
- Dump hprof,会造成app冻结
- Hprof文件过大
-
解析
- 解析耗时过长
- 解析本身有OOM风险
接下来我们一一展开分析。
解决GC卡顿
为什么LeakCanary
需要主动触发GC呢?LeakCanary
监控泄漏利用了弱引用的特性,为Activity创建弱引用,当Activity对象变成弱可达时(没有强引用),弱引用会被加入到引用队列中,通过在Activity.onDestroy()
后连续触发两次GC,并检查引用队列,可以判定Activity是否发生了泄漏。但频繁的GC会造成用户可感知的卡顿,为解决这一问题,我们设计了全新的监控模块,通过无性能损耗的内存阈值监控来触发镜像采集,具体策略如下:
- Java堆内存/线程数/文件描述符数突破阈值触发采集
- Java堆上涨速度突破阈值触发采集
- 发生OOM时如果策略1、2未命中 触发采集
- 泄漏判定延迟至解析时
阈值监控只要在子线程定期获取关注的几个内存指标即可,性能损耗可以忽略不计;内存快速上涨用来定位对象频繁分配的问题;OOM作为最后兜底的策略,走到这里说明我们的阈值设计有漏洞,没有拦截住所有可能触发OOM的场景;最后,我们将对象是否泄漏的判断延迟到了解析时。还是以Activity
为例,我们并不需要在运行时判定其是否泄漏,Activity
有一个成员变mDestroyed
,在onDestory
时会被置为true
,只要解析时发现有可达且mDestroyed
为true
的Activity
,即可判定为泄漏(由于时序问题,这里可能有极小概率会发生误判,但不影响我们解决问题),其他关注的对象可以根据其特点设计规则。用一张图总结:
图11
解决Dump hprof冻结app
Dump hprof是通过虚拟机提供的API dumpHprofData
实现的,这个过程会“冻结”整个应用进程,造成数秒甚至数十秒内用户无法操作,这也是LeakCanary
无法线上部署的最主要原因,如果能将这一过程优化至用户无感知,将会给OOM治理带来很大的想象空间。
面对这样一个问题,我们将其拆解,自然而然产生2个疑问: 1.为什么dumpHprofData
会冻结app,虚拟机的实现原理是什么? 2.这个过程能异步吗? 我们来看dumpHprofData的虚拟机内部实现 art/runtime/hprof/hprof.cc
// If "direct_to_ddms" is true, the other arguments are ignored, and data is
// sent directly to DDMS.
// If "fd" is >= 0, the output will be written to that file descriptor.
// Otherwise, "filename" is used to create an output file.
void DumpHeap(const char* filename, int fd, bool direct_to_ddms) {
CHECK(filename != nullptr);
Thread* self = Thread::Current();
// Need to take a heap dump while GC isn't running. See the comment in Heap::VisitObjects().
// Also we need the critical section to avoid visiting the same object twice. See b/34967844
gc::ScopedGCCriticalSection gcs(self,
gc::kGcCauseHprof,
gc::kCollectorTypeHprof);
ScopedSuspendAll ssa(__FUNCTION__, true /* long suspend */);
Hprof hprof(filename, fd, direct_to_ddms);
hprof.Dump();
}
可以看到在dump前,通过ScopedSuspendAll
(构造函数中执行SuspendAll
)执行了暂停所有java线程的操作,以防止在dump的过程中java堆发生变化,当dump结束后通过ScopedSuspendAll
析构函数进行ResumeAll
。
解决了第一个问题,接下来看第二个问题,既然要冻结所有线程,子线程异步处理是没有意义的,那么在子进程中处理呢?Android的内核是定制过的Linux, 而Linux fork子进程有一个著名的COW(Copy-on-write
,写时复制)机制,即为了节省fork子进程的内存消耗和耗时,fork出的子进程并不会copy父进程的内存,而是和父进程共享内存空间。那么如何做到进程隔离呢,父子进程只在发生内存写入操作时,系统才会分配新的内存为写入方保留单独的拷贝,这就相当于子进程保留了fork瞬间时父进程的内存镜像,且后续父进程对内存的修改不会影响子进程,想到这里我们豁然开朗。说干就干,我们写了一个demo来验证这个思路,很快就遇到了棘手的新问题:dump前需要暂停所有java线程,而子进程只保留父进程执行fork操作的线程,在子进程中执行SuspendAll触发暂停是永远等不到其他线程返回结果的(详见thread_list.cc
中行SuspendAll
的实现,这里不展开讲了),经过仔细分析SuspendAll
的过程,我们发现,可以先在主进程执行SuspendAll
,使ThreadList
中保存的所有线程状态为suspend
,之后fork,子进程共享父进程的ThreadList
全局变量,可以欺骗虚拟机,使其以为全部线程已经完成了暂停操作,接下来子进程就可以愉快的dump hprof了,而父进程可以立刻执行ResumeAll
恢复运行。
这里有一个小技巧,SuspendAll
没有对外暴露Java层的API,我们可以通过C层间接暴露的art::Dbg::SuspendVM
来调用,dlsym
拿到“_ZN3art3Dbg9SuspendVMEv
”的地址调用即可,ResumeAll
同理,注意这个函数在android 11以后已经被去除了,需要另行适配。Android 7之后对linker做了限制(即dlopen
系统库失效),快手自研了kwai-linker组件,通过caller address替换和dl_iterate_phdr
解析绕过了这一限制。 至此,我们完美解决了dump hprof冻结app的问题,用一张图总结:
图12
解决hprof文件过大
Hprof文件通常比较大,分析OOM时遇到500M以上的hprof文件并不稀奇,文件的大小,与dump成功率、dump速度、上传成功率负相关,且大文件额外浪费用户大量的磁盘空间和流量。我们因此想到了对hprof进行裁剪,只保留分析OOM必须的数据,另外,裁剪还有数据脱敏的好处,只上传内存中类与对象的组织结构,并不上传真实的业务数据(诸如字符串、byte
数组等含有具体数据的内容),保护用户隐私。
开发镜像裁剪,有两个衡量指标:一是裁剪率,即在不影响问题分析的前提下,裁剪掉的内容要足够多;二是裁剪性能损耗,如果性能不达标引发耗电、成功率低引入新的问题,就会使得内存镜像获取得不偿失。
照例,我们将问题拆解:
- hprof存的内容都是些什么?数据如何组织的?哪些可以裁掉?
- 内存中的数据结构和hprof文件二进制协议的映射关系?
- 如何裁剪?
想要了解hprof的数据组织方式,推荐阅读openjdk官方文档[2],Android在此基础上做了一些扩展,这里简要介绍一下核心内容:
- 文件按byte by byte顺序存储,u1,u2,u4分别代表1字节,2字节,4字节。
- 总体分为两部分,
Header
和Record
,Header
记录hprof的元信息,Record
分很多条目,每一条有一个单独的TAG代表类型。
我们关注的Record
类型主要是HEAP DUMP
,其中又分五个子类,分别为GC ROOT
、CLASS DUMP
、INSTANCE DUMP
、OBJECT ARRAY DUMP
、PRIMITIVE ARRAY DUMP
。图13以PRIMITIVE ARRAY DUMP
(基本类型数组)为例展示Record中
包含的信息,其他类型请查阅官方文档。内存中绝大部分数据是PRIMITIVE ARRAY DUMP
,通常占据80%以上,而我们分析OOM只关系对象的大小和引用关系,并不关心内容,因此这部分是我们裁剪的突破口。
图13
Android对数据类型做了扩展,增加了一些GC ROOT
// Android.
HPROF_HEAP_DUMP_INFO = 0xfe,
HPROF_ROOT_INTERNED_STRING = 0x89,
HPROF_ROOT_FINALIZING = 0x8a, // Obsolete.
HPROF_ROOT_DEBUGGER = 0x8b,
HPROF_ROOT_REFERENCE_CLEANUP = 0x8c, // Obsolete.
HPROF_ROOT_VM_INTERNAL = 0x8d,
HPROF_ROOT_JNI_MONITOR = 0x8e,
HPROF_UNREACHABLE = 0x90, // Obsolete.
HPROF_PRIMITIVE_ARRAY_NODATA_DUMP = 0xc3, // Obsolete.
还有一个HEAP_DUMP_INFO
,这里面保存的是堆空间(heap space)的类型,Android对堆空间做了划分,我们只关注HPROF_HEAP_APP即可,其余也是可以裁剪掉的,可以参考Android Studio中Memory Profiler的处理[3]。
enum HprofHeapId {
HPROF_HEAP_DEFAULT = 0,
HPROF_HEAP_ZYGOTE = 'Z',
HPROF_HEAP_APP = 'A',
HPROF_HEAP_IMAGE = 'I',
};
接下来讨论如何裁剪,裁剪有两种办法,第一种是在dump完成后的hprof文件基础上裁剪,性能比较差,对磁盘空间要求也比较高,第二种是在dump的过程中实时裁剪,我们自然想要实现第二种。看一下Record
写入的过程,先执行StartNewRecord
,然后通过AddU1/U4/U8
写入内存buffer,最后执行EndRecord
将buffer写入文件。
void StartNewRecord(uint8_t tag, uint32_t time) {
if (length_ > 0) {
EndRecord();
}
DCHECK_EQ(length_, 0U);
AddU1(tag);
AddU4(time);
AddU4(0xdeaddead); // Length, replaced on flush.
started_ = true;
}
void EndRecord() {
// Replace length in header.
if (started_) {
UpdateU4(sizeof(uint8_t) + sizeof(uint32_t),
length_ - sizeof(uint8_t) - 2 * sizeof(uint32_t));
}
HandleEndRecord();
sum_length_ += length_;
max_length_ = std::max(max_length_, length_);
length_ = 0;
started_ = false;
}
void HandleFlush(const uint8_t* buffer, size_t length) override {
if (!errors_) {
errors_ = !fp_->WriteFully(buffer, length);
}
}
这个过程中有两个hook点可以选择,一是hook AddUx
,在写入buffer的过程中裁剪,二是hook write
,在写入文件过程中裁剪。最终我们选择了方案二,理由是AddUx
调用比较频繁,判断逻辑复杂容易出现兼容性问题,而write
是public API,且只在Record写入文件的时候调用一次,厂商不会魔改相关实现,从hook原理上来讲,hook外部调用的PLT/GOT
hook也比hook内部调用的inline
hook要稳定得多。
用一张图总结裁剪的流程:
图14
解决hprof解析的耗时与OOM
解析hprof文件,对关键对象进行可达性分析,得到引用链,是我们解决OOM最核心的一步,之前的监控和dump都是为解析做铺垫。解析分两种,一种是上传hprof文件由server解析,另一种是在客户端解析后上传报告(通常只有几KB
)。最终我们选择了端上解析,这样做有两个好处:
- 节省用户流量
- 利用用户闲时算力,降低server压力,这样也符合分布式计算理念。
照例,我们依然将问题拆解:
- 哪些对象需要分析,全部分析性能开销太大,很难在端上完成,并且问题没有重点也不利于解决。
- 性能优化,作为一个debug组件,要在不影响用户体验的情况下完成解析,对性能有非常高的要求。
关键对象判定
回顾前文,我们只解析关键对象的引用链,并写入分析报告中上传,判定的准确性和覆盖度决定了分析的质量。
我们将关键对象分为两类,一类是根据规则可以判断出对象已经泄露,且持有大量资源的,另外一类是对象shallow / retained size
超过阈值。
Activity/fragment
泄露判定即为第一种: 对于强可达的activity
对象,其mDestroyed
值为true时(onDestroy
时赋值),判定已经泄露。类似的,对于fragment
,当mCalled
值为true且mFragmentManager
为null时,判定已经泄露 。 我们可以用同样的思路合理制定规则,来处理我们核心的业务组件,比如无处不在的presenter
。
Bitmap/window/array/sufacetexture
判定为第二种 检查bitmap/texture
的数量、宽高、window
数量、array
长度等等是否超过阈值,再结合hprof中的相关业务信息,比如屏幕大小,view
大小等进行判定。
性能优化
一开始我们尝试了LeakCanary
的解析引擎HAHA
(Android Studio解析引擎perlib的Android移植版),解析过程中非常容易OOM,且解析速度极慢,500M的hprof文件,内存峰值达到2G,绝大多数Andriod设备的Java堆内存上限只有512M,即使顶配的macbook解析耗时都在3分钟以上,如此性能,在端上解析成功率低到发指。一度使我们想放弃现有的轮子,用C重写解析库,恰好此时LeakCanary
发布了新的解析引擎shark
[4],号称内存峰值可以降低10倍,解析速度可以提升6倍。我们实验了一下,发现小的demo hprof基本能达到其宣称的性能,线上真实环境拿到的包含百万级对象hprof文件,性能会急剧下降,分析时间突破10分钟。因此,我们需要进一步优化,优化之前,先来研究一下HAHA
和shark
的原理。
为什么HAHA
内存峰值高,速度慢呢,概括起来主要是以下几点:
- 没做懒加载,hprof内容全部load到内存里。
-
domanitor tree
[5]全量计算,实际上我们只关心关键对象的retained size
。 - 频繁触发GC,java的集合类没有针对计算密集型任务做优化,含有大量冗余的装箱、拆箱、扩容、拷贝等操作,大量创建对象,频繁触发GC,GC反过来进一步降低对象分配速度,陷入恶性循环。
Shark
是如何优化的呢? Shark
是LeakCanary
2.0推出的全新解析组件,其设计思想详见作者的介绍[6],主要做了以下几项优化:
- 索引,
shark
低内存开销的最根本原因就是通过索引做到了内存懒加载,遍历hprof时存储对象在hprof中的位置,并为其建立索引方便按需解析。 - 数据结构上做了深度优化,主要是使用了更高效的
map
,有2个:第一是对于key
和value
都是基础类型或字符串的使用hppc
做map
,第二是对于value
不是基本类型的,使用SortedBytesMap
存储内容。
具体的索引有:实例索引、类索引、字符串索引、类名索引、数组索引:
/**
* This class is not thread safe, should be used from a single thread.
*/
internal class HprofInMemoryIndex private constructor(
private val positionSize: Int,
private val hprofStringCache: LongObjectScatterMap<String>,
private val classNames: LongLongScatterMap,
private val classIndex: SortedBytesMap,
private val instanceIndex: SortedBytesMap,
private val objectArrayIndex: SortedBytesMap,
private val primitiveArrayIndex: SortedBytesMap,
private val gcRoots: List<GcRoot>,
private val proguardMapping: ProguardMapping?,
val primitiveWrapperTypes: Set<Long>
) {
/**
* Code from com.carrotsearch.hppc.LongLongScatterMap copy pasted, inlined and converted to Kotlin.
*
* See https://github.com/carrotsearch/hppc .
*/
class LongLongScatterMap constructor(expectedElements: Int = 4) {
/**
* A read only map of `id` => `byte array` sorted by id, where `id` is a long if [longIdentifiers]
* is true and an int otherwise. Each entry has a value byte array of size [bytesPerValue].
*
* Instances are created by [UnsortedByteEntries]
*
* [get] and [contains] perform a binary search to locate a specific entry by key.
*/
internal class SortedBytesMap(
private val longIdentifiers: Boolean,
private val bytesPerValue: Int,
private val sortedEntries: ByteArray
) {
所谓hppc
是High Performance Primitive Collection
[7]的缩写,shark
使用kotlin
将其重写了。hppc
只支持基本类型,所以没有了装、拆箱的性能损耗,相关集合操作也做了大量优化,其benchmark
可以参考[8]。
再来看一下一个普通的对象在虚拟机中的内存开销有多大(ps:这还只是截图了一部分,一个int4个字节,1个long8个字节):
图15
前文提到,基于shark
在解析大hprof时,性能依然不够理想,需要做进一步的优化。 先来分析一下shark
的使用场景和我们解析需求的差异:
-
LeakCanary
中shark
只用于解析单一泄漏对象的引用链,而我们要分析大量对象的引用链。 -
Shark
对于结果的要求非常精准,而我们是线上大数据分析,允许丢弃个别对象的引用链。 -
Shark
对于镜像中的对象所有字段都进行解析,用于查询字段的值,而我们并不关心基础类型的值。
经过一番探索与实践,中途还去研究了MAT的源码,我们对其主要做了以下几点优化:
- GC root剪枝,由于我们搜索Path to GC Root时,是从GC Root自顶向下BFS,如
JavaFrame
、MonitorUsed
等此类GC Root可以直接剪枝。 - 基本类型、基本类型数组不搜索、不解析。
- 同类对象超过阈值时不再搜索。
- 增加预处理,缓存每个类的所有递归super class,减少重复计算。
- 将object ID的类型从
long
修改为int
,Android虚拟机的object ID大小只有32位,目前shark
里使用的都是long
来存储的,OOM时百万级对象的情况下,可以节省10M内存。
另外,还有几项实验中的调优项:
- 将
shark
改用c++重写,从GC日志来看,大hprof解析时,GC还是十分频繁的,改用c++会降低这部分开销。 - 扩大okio segment池的大小,空间换时间,用更多的内存、来提升高频访问解析对象的性能。
经过以上优化,将解析时间在shark
的基础上优化了2倍以上,内存峰值控制在100M以内。 用一张图总结解析的流程:
图16
分发与跟进
解析结果上传到server以后,还要做反混淆,聚类等工作。通过关键对象以及引用链,将问题聚合后自动分发给研发同学,分发的原则是引用链中最近提交代码的owner。图17&18摘录了跟进系统的关键信息:
图17
图18
收获与展望
总结下我们在以上两个项目中的收获以及未来的展望:
成果
- 通过退出率完善了稳定性体系
- 大幅降低OOM率
- 大幅提高解决OOM问题效率,减少人力投入
- 内存优化专项沉淀出开源项目KOOM
收获
- 遇到未知问题刨根问底,从wakeups异常问题,我们想到了监控所有未知的稳定性问题,提出了退出率的概念,并通过分页面监控进一步完善了监控体系。
- 抓头部问题,在我们的实践中,入口页面的泄漏对OOM的影响是最大的,一旦发生crash率会翻倍上涨。
- 大需求合入前做对比灰度,OOM问题很难定位到是哪个需求引入的,这方面我们有过惨痛教训,而在需求合入前通过对比灰度数据(base分支 vs base+需求),可以很精确的预估出实际上线后对大盘的影响,屡试不爽,因此我们要求大需求合入前必须有对比灰度数据验证过程。
- 从根源上解析问题,在此安利一下64位,谷歌已经于去年要求app升级64位,32位的地址空间只有4G,进程刚启动就要占2~3G,即使没有泄露问题并小心使用内存,在很多重度场景中依然容易触发OOM,快手升级64位以后crash率降低了一半左右。
- 方案选型做好可用性和性能的平衡。
- 问题难以解决时思考是否能绕过,比如GC的耗时我们很难优化,但是我们的目标是监控OOM,用阈值的方式也可以实现。
Roadmap
- 退出率进一步完善,还有极小部分的退出我们没有合理分类,还有一部分退出类型我们上报的信息不足,难以区分是用户正常行为还是体验问题,需要继续完善。
- 引用链解析性能优化。
- 完善问题判定规则,目前我们还会采样上报一部分hprof文件人工分析,用于完善我们的规则。
- Native OOM监控。