支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」

前言

《支付宝客户端架构解析》系列将从支付宝客户端的架构设计方案入手,细分拆解客户端在“容器化框架设计”、“网络优化”、“性能启动优化”、“自动化日志收集”、“RPC 组件设计”、“移动应用监控、诊断、定位”等具体实现,带领大家进一步了解支付宝在客户端架构上的迭代与优化历程。

本节将介绍支付宝 Android 客户端启动速度优化下的「垃圾回收」具体思路。

应用启动时间是移动 App 一个重要的用户体验环节,相对于普通的移动 App,支付宝过于庞大,必然会影响启动速度,一些常规的优化手段在支付宝中已经做得比较完善了,本篇文章尝试从 GC 的层面来进一步优化支付宝的启动速度。

背景

相对于 C 语言来说,Java 语言有一些特性,例如开发人员不用考虑内存的分配和回收,然而,进程内存管理又是必不可少的环节,妥协的结果是 Java 语言的设计者们把对象分配和回收放到了 Java虚拟机,这里希望明确一个概念:GC 是有代价的,这个代价包括:阻塞 Java 程序的执行,占用 CPU 资源,占用额外内存等,谷歌的工程师意识到了 GC 对应用的影响,所以把 GC 的日志默认输出到了 Logcat,我们经常能够看到 Logcat 里输出以下几种 GC 日志:

  1. GC_EXPLICIT:Dalivk 给开发人员提供的主动触发 GC 的 API,读者可以参看 Google Maps 的设计来体会这个 API 的用法
  2. GC_FOR _ALLOCK:是分配对象失败时触发的 GC,这个 GC 会将应用所有的 Java 线程暂停运行,直到 GC 结束。
  3. GC_CONCURRENT:是 Java 虚拟机根据堆的当前状态触发的 GC,这个 GC 在 Dalvik 单独 GC 线程里运行,在部分时间里不影响应用 Java 线程的运行。

支付宝启动是一个典型的关键路径场景,我们希望看到尽可能少的 GC_ CONCURRENT(如果可能,GC_ FOR_ ALLOCK 也应该缩减到最少),然而,通过 Logcat 我们会看到非常糟糕的 GC 行为—大量的 GC_ FOR_ ALLOCK 以及触目惊心的 Java 线程被 WAIT_ FOR_ CONCURRENT_ GC 阻塞,如下图所示,通过简单统计这些GC消耗的时间,我们能够得出GC严重影响应用启动时间的结论。

设计思路

支付宝是 Android 系统的一个应用程序,如何能够通过影响 Dalvik 的 GC 行为来缩短启动时间呢?这个问题可以分解为两步:

  • 支付宝是否能影响自身 Dalvik 的行为
  • 如何改进 Dalvik,缩短启动时间

第一个问题答案是肯定的,Android 系统的设计思路是每个 Android 应用程序都有独立的 Dalvik 实例,应用启动后可以修改自己的进程空间里的代码和数据,因此支付宝通过修改内存中的 Dalvik 库文件 libdvm.so 影响 Dalvik 的行为。

第二个问题的难点在于投入产出比:修改进程空间的代码和数据是面向二进制,难度远远大于源代码,也就是说稍微复杂的 Dalvik 改进工作是不可能的。

基于以上两点,提出了一种设想:启动时 GC 抑制,允许堆一直增长,直到开发人员主动停止 GC 抑制或者 OOM 停止 GC 抑制,这是一种"空间换时间"策略,用更多的内存消耗来换取启动时间的缩短,这种策略可行有两个前提:一是设备厂商没有加密内存中的 Dalvik 库文件,二是设备厂商没有改动 Google 的 Dalvik 源码(或者少量的改动),理论上通过白名单的方式可以覆盖所有设备,但是实现和维护成本都非常高。

GC 抑制的实现

GC 抑制的前提是 Dalvik 比较熟悉,知道如何改变 GC 的行为,解决方案大致如下:首先在源码级别找到抑制GC的修改方法,例如改变跳转分支,其次,在二进制代码里找到 A 分支条件跳转的"指令指纹",以及用于改变分支的二进制代码,假设为 override_A,应用启动后扫描内存中的 libdvm.so,根据"指令指纹"定位到修改位置,然后用 override_A 覆盖,这里需要注意的是,"指令指纹"的定义需要有一些编译器和 arm 指令集知识,实现 GC 抑制主要实现了以下 4 个部分:

  • 取消 softlimit 检测
  • 取消 GC 线程的唤醒
  • 取消 GC 例程函数
  • OOM 停止 GC 抑制的实现

1. 取消 softlimit 检测:

取消 softlimit 检测的目的是最大限度的分配对象,下图为 softlimit 检查对应的 arm 指令片段,位于 dvmHeapSourceAlloc 函数中,OXE057 对应于"return NULL"的分支,如果我们想永远不进入"return NULL"分支,可以改变 cmp 指令的结果,在具体实现里我们把"0X42"作为"指令指纹"来识别而且修改为 "cmp r0, r0",这样就可以实现取消 softlimit 检查。

   7616c: 42a1 cmp r1, r4
   7616e: d901 bls.n 76174 <_Z18dvmHeapSourceAllocj+0x20>
   76170: 2400 movs r4, #0
   76172: e057 b.n 76224 <_Z18dvmHeapSourceAllocj+0xd0>
   76174: f8df 90bc ldr.w r9, [pc, #188] ; 76234    <_Z18dvmHeapSourceAllocj+0xe0>
   76178: 6a28 ldr r0, [r5, #32]
   7617a: f853 3009 ldr.w r3, [r3, r9]
   7617e: 7d1a ldrb r2, [r3, #20]
void* dvmHeapSourceAlloc(size_t n)
{
...
if (heap->bytesAllocated + n > hs->softLimit) {
/*
* This allocation would push us over the soft limit; act as
* if the heap is full.
/
return NULL;
复制代码

2. 取消GC线程的唤醒

取消 GC 线程唤醒的目的是防止 GC 线程频繁唤醒导致的线程抖动。下图是对应的 C++ 代码和 arm 指令片段,这段代码同样位于 dvmHeapSourceAlloc 函数中。在具体实现里我们会依次扫描 libdvm.so 的 dynstr、dynsym、rel.plt 和 plt 区域获取 pthreadcondsignal@plt 的地址,然后遍历 dvmHeapSourceAlloc 中的所有分支跳转,计算跳转目的地址。

如果发现 pthreadcondsignal@plt 和当前分支跳转目的地址配置,擦除这条指令即可。

   if (heap->bytesAllocated > heap->concurrentStartBytes) {
/
* We have exceeded the allocation threshold. Wake up the
* garbage collector.
*/
dvmSignalCond(&gHs->gcThreadCond);
}
7621c: 6800 ldr r0, [r0, #0]
7621e: 30b4 adds r0, #180 ; 0xb4
76220: f7a9 ed0e blx 1fc40 
76224: 4620 mov r0, r4
76226: e8bd 83f8 ldmia.w sp!, {r3, r4, r5, r6, r7, r8, r9, pc}
复制代码

3. 取消GC例程函数

取消 GC 例程函数采用钩子技术来实现,我们将 GC 抑制封装成了两个 native 接口 doStartSuppressGCdoStopSuppressGC;并且进一步封装为 JNI 接口,便于开发者在 Java 里调用。一般的应用方式是,开发者通过日志看到支付宝在某个场景会触发大量的 GC 且这个 GC 影响用户体验(响应时间慢或者动画卡顿),然后在这个场景前后插入 doStartSuppressGCdoStopSuppressGC

以支付宝冷启动场景为例,我们在容器 Quinox 的 attachBaseContext 函数里插入 doStartSuppressGC,在首页加载结束时插入 doStopSuppressGC

4. OOM 停止GC抑制的实现

如果仅仅考虑在支付宝启动过程中抑制 GC,不需要考虑 OOM 停止 GC 抑制的实现,因为支付宝启动不足以触发 OOM。但是我们希望 GC 抑制成为一个基础模块,能够应用到更多场景中。如果程序在调用 doStopSuppressGC 前触发了 OOM,则需要在 OOM 发生前停止 GC 抑制。和前面简单的改变分支跳转方向不同,需要在 OOM 发生前注入一个新的的分支跳转,这个新分支的代码由我们来实现。新分支主要功能是,调用 doStopSuppressGC,然后去掉注入的新分支,最后跳回 Dalvik 执行 OOM。

实现同样采用传统的钩子技术。在钩子函数 dvmCollectGarbageInternal 里:

  • 当条件不满足时直接返回,达到取消 GC 的目的;
  • 条件满足时,取消钩子且执行原来的 dvmCollectGarbageInternal

实现中使用了开源的二进制注入框架:github.com/crmulliner/… 。

这里需要注意的是,在热点函数里使用这个框架提供的 pre_hookpost_hook 的性能开销非常大。

本文里的设计只会用到一次 pre_hook,所以不存在性能问题。 看到的这里读者可能会问,这种通过“指令指纹”的方式靠谱么?我的答案是,漏判不影响正确性,误判理论上存在但概率极小(误判指“指令指纹”定位到错误代码位置)。即使误判发生了,我们还有最后一层保障——基础架构组同学实现的容灾机制。当误判导致程序异常无法完成正常启动时,重启支付宝而且在后续的启动中直接放弃 GC 抑制。

效果

上图的启动时间的数据是在内部的 Android 4.x 测试设备上获得的(没有标注 release 表示 debug 版本)。从图表上来看,支付宝客户端的启动时间缩短了 15%~30%。

小结

通过本节内容,我们初步了解了支付宝在 Android 客户端启动性能优化下的「垃圾回收」机制和具体实践,由于篇幅限制,很多技术要点我们无法一一展开。而相应的技术内核,我们同样应用在了 mPaaS 并对外输出,欢迎大家上手体验:

tech.antfin.com/docs/2/4954…

关于 Android 端启动性能优化的设计思路和具体实践,同样期待你们的反馈,欢迎一起探讨交流。

往期阅读

《支付宝客户端架构解析:iOS 容器化框架初探》

《支付宝客户端架构解析:Android容器化框架初探》

《开篇 | 模块化与解耦式开发在蚂蚁金服 mPaaS 深度实践探讨》

《口碑 App 各 Bundle 之间的依赖分析指南》

《源码剖析 | 蚂蚁金服 mPaaS 框架下的 RPC 调用历程》

《支付宝移动端动态化方案实践》

关注我们公众号,获得第一手 mPaaS 技术实践干货

你可能感兴趣的:(支付宝客户端架构解析:Android 客户端启动速度优化之「垃圾回收」)