Android Art Dex2Oat的探索实践

1. 背景

应用的启动时间是反应App性能的重要指标,将直接影响用户的使用体验和对App的主观印象。Google的官方文档中声明 “用户希望App启够足够快的开始启动,如果一个App启动时间过长,会令用户非常失望,并且可能会在应用商店中对App评价很低或者干脆卸载我们的App”。  另外有研究表明,页面加载时间每增加1秒,将减少7%的转化, 减少11%的页面浏览量,同时减少16%的客户满意度(见参考资料4)。应用启动时间属于一种特殊的页面加载,更快、更丝滑地操作App,具有重要的业务意义。美团App的启动时间在行业内属于中等偏下水平,具有优化空间与优化价值。在分类定位影响启动时间因素的过程中,我们发现以下两个问题:

1.1 问题1: 启动阶段D状态占比超过了50%

Linux的线程状态大致可分为R、D、S、R+四种状态: R表示线程运行在CPU上; R+表示线程在等待调度队列中, 尚未运行在CPU上; S表示可中断休眠, 一般由用户锁引起; D表示不可状态休眠, 一般由内核锁引起。我们将美团启动阶段的主线程按照状态占比进行分类,发现D状态占比在Nexus 6p(低端机)中超过了50%, 在Pixel 3xl占比为37%, 为了优化启动时间有必要对D状态的成因进行细化分析。通过内核打点,借助SimplePerf抓取竞争发生时堆栈信息,发现美团D状态大部分由于两个线程在竞争“mmap_sem”这把内核锁锁引起。将竞争发生时主线程与子线程的任务类型进行分类对比:

表1. 进入D状态时,主线程与子线程任务占比排行

主线程 子线程
主要原因 占比排行 主要原因 占比排行
do_page_fault 55% 线程创建操作 44%
get_user_pages_fast 27% 内存分配 20%
vm_mmap_pgoff 7% so加载 10%

将主线程的原因进一步细分,抓取对应原因的火焰图发现: do_page_fault的原因比较随机,类加载、资源加载、内存操作等都会触发PageFault中断;get_user_pages_fast是由用户态锁引起的,其中大约由2/3左右是类加载与链接过程。

1.2 问题2: Jit thread pool 占用CPU排行第一

通过监控团队工具检测线程活跃程度排行时发现“Jit thread pool” 线程在启动阶段CPU占用率排行第一(见表2,包含D时间片的原因参见资料5), 抢占了主线程的CPU资源, 需要调研减少启动阶段的Jit thread pool的CPU占用的方法。

表2. 线程R、R+、D时间片占比排行

线程名 占比
Jit thread pool 30%
com.sankuai.meituan 24%
Horn-ColdStartu 23%
Sniffer Runnabl 20%

从线程名Jit thread pool可以判定, Jit thread pool应该是ART虚拟机的运行JIT编译任务的线程, 基本判定是Java解释执行的锅, 从systrace也能得到验证:

image.png

图1. sysytrace中, Jit thread pool线程排满了任务

1.3 小结

从问题本身来看, 问题1暴露两个问题: 线程创建过多,复用率低;类加载与链接不仅增加了运行时任务,同样增加了主线程进入内核竞争环境的时机。 问题2的主要原因是任务量过大,触发了JIT编译。两者存在共同点是不存在明显的业务方,线程管控与优化任务量是一个缓慢见效的过程,思考从技术手段进行优化。Android本身针对这两个问题提出了AOT(Ahead Of Time)编译, 将Java字节码编译为机器码,再次运行时就跳过了类验证、记载过程, 同样不会触发运行时JIT编译,减少PageFault时机。不过从测试结果来看, 系统默认的AOT策略没有触发或者触发不够彻底,本文作为Android Art Profile的原理探索以及在美团中的实践记录。

2. 原理分析

2.1 AOT基本概念

了解AOT前, 需要先了解一点JIT的相关概念。我们知道C/C++编程成的机器码执行效率比Java解释执行字节码效率要高,为了提高Java的执行效率,Dalvik虚拟机中采用了JIT(Just In Time)技术,在运行时将高频方法编译成机器码。 这样后续再执行到这个方法时CPU会读取机器码直接执行, 但是JIT编译得到的机器码是存在内存中的, 下次冷启动这些机器码将会丢失。 对于类似服务端长期运行的Java应用来讲,JIT提效明显,但对于Android应用来讲,App可能需要反复重启,JIT并不友好, 甚至会有副作用存在。

为解决JIT的缺点, ART虚拟机引入了预编译机制(AOT)。Android系统用于实现AOT编译的组件是dex2oat, 由PackageManagerService系统服务调用。 在Android N之前, 在应用安装前, Android系统会将整个Apk的字节码编译成机器码,但是造成了安装速度过慢,浪费存储空间的缺点。在Android N(7.0)及之后, Android 系统采用AOT与JIT混合编译, 由此引出了dex2oat的编译模式: verify、quicken、space-profile、space、speed-profile、speed、everything。 其中quicken与speed-profile一般用于应用安装时的编译模式,相对编译速度较快,占用空间合理; speed-profile用于系统后台触发整个系统进行AOT编译的模式,可以理解为按照用户习惯进行特定优化。 具体每个模式的应用可由命令行 “adb shell getprop | get pm” 获取。接下来我们专注于speed-profile编译模式探索,AOT与JIT的交互关系如下图:

image.png

图2. AOT与JIT的交互关系

一句话描述就是: 运行时JIT将热点方法编译成机器码的同时将热点方法、热点类信息记录到Profile文件中,系统在合适的时机调度dex2oat根据Profile文件将字节码编译成机器码,下次启动时应用使用编译的机器码执行。具体流程如下:

2.2 Dex2Oat触发及编译流程

image.png

图3. Dex2Oat触发时序图

如图3所示, 系统触发的AOT编译均由PackageManagerService接口函数调用触发, 关键环节描述:

  • 第1步: 校验传入参数合法,安装包是否存在, 是否可执行Dex2Oat操作。 可以发现performDexOptMode是PMS服务中少有的没有加权限校验的方法, 这给我们主动触发Dex2Oat编译提供了可能
  • 第2步: 获取了应用安装包的路径信息, 校验并合并了Profile文件。 系统中存在多个Profile文件: 1. /data/misc/profiles/cur/0/com.sankuai.meituan/primary.prof 这个系统中某个用户使用过程中生成的Profile文件; 2. /data/misc/profiles/ref/com.sankuai.meituan/primary.prof 这个是编译时真实读取的文件。 在第2步中PackageManagerDexOptimizer会将两种Profile文件进行合并保存在位置2处, 位置1处的Profile文件被置为空, 等待下次启动后重新采样。
  • 第3步,第4步: 通过Binder协议调用Installd native服务dexopt服务, 实现由dexopt.cpp提供
  • 第5步: fork处一个子进程执行接下来的任务
  • 第6步: 在子进程中通过execv执行dex2oat命令, 至此控制权交给dex2oat.cc

image.png

图4中大致包含了dex2oat进行AOT编译的流程, 简要描述:

  • 第2步前后主要是环境准备,加载。 需要指出的是在步骤2.2中, Android系统会根据Profile信息进行简单类重排,将热区方法、类、字符串这些信息放置在一起。 不过Android系统只能针对单个dex进行类重排, 多个dex还是需要redex之类的编译时干预手段。这一步的主要产物是 base.vdex文件。
  • 第3步是具体的编译步骤,会遍历所有的方法,如果方法在Profile的hot method中, 会调用CompileMethod进行编译。
  • 第4步将编译的产物输出的odex中, 第5步将镜像文件写入到base.art中

2.3 Dex2Oat触发时机

系统本身触发Dex2Oat的时机有很多, 大致可以通过 adb shell getprop | grep 'dexopt' 获取:

[dalvik.vm.dexopt.secondary]: [true]
[pm.dexopt.ab-ota]: [speed-profile]
[pm.dexopt.bg-dexopt]: [speed-profile]
[pm.dexopt.boot]: [verify]
[pm.dexopt.first-boot]: [quicken]
[pm.dexopt.inactive]: [verify]
[pm.dexopt.install]: [quicken]
[pm.dexopt.shared]: [speed]
复制代码

我们比较关心的是bg-dexopt, 这是Android系统定时任务触发的, 由BackgroundDexOptService定义

// 天级任务, 必须是闲时, 必须是充电状态        
js.schedule(new JobInfo.Builder(JOB_IDLE_OPTIMIZE, sDexoptServiceName)
                    .setRequiresDeviceIdle(true)
                    .setRequiresCharging(true)
                    .setPeriodic(IDLE_OPTIMIZATION_PERIOD)
                    .build());
复制代码

2.4 产物文件格式

介绍Dex2Oat过程时提到了base.vdex、base.odex、base.art, primary.prof四种文件格式, 简单介绍下其格式:

文件类型 描述
.vdex 保存已经验证后的dex文件, 后续加载时可以跳过dex验证这一步。内部保存了apk中的多个dex的原始数据, 存储的是字节码在图4的2.2步骤中生成该文件, 生成时会根据Profile文件进行简单的dex重排
.odex optimized dex, 保存的是已经经过编译的机器码
.art Image文件, 内部包含大量的mirror object, 在App启动过程中会将此art文件直接map到内存中使用, 会跳过类加载, 类验证过程。 同时记录已经编译的机器码地址。也仅有profile类的类的编译模式才会生成.art文件。
primary.prof Art虚拟机的Profile文件, 记录了热点类, 热区方法, 方法的inline cache信息。不包含机型特有信息。

其中primary.prof记录的信息仅与安装包有关, 与具体设备无关(与Android版本有关) 。 其格式内容由profile_compilation_info.cc描述,不同版本的primary.prof有略微不同, 给出Android 8.1中的primary文件格式:

/**
 * Serialization format:
 *    magic,version,number_of_dex_files,uncompressed_size_of_zipped_data,compressed_data_size,
 *    zipped[dex_location1,number_of_classes1,methods_region_size,dex_location_checksum1
 *        num_method_ids,
 *        method_encoding_11,method_encoding_12...,class_id1,class_id2...
 *        startup/post startup bitmap,
 *    dex_location2,number_of_classes2,methods_region_size,dex_location_checksum2, num_method_ids,
 *        method_encoding_21,method_encoding_22...,,class_id1,class_id2...
 *        startup/post startup bitmap,
 *    .....]
 * The method_encoding is:
 *    method_id,number_of_inline_caches,inline_cache1,inline_cache2...
 * The inline_cache is:
 *    dex_pc,[M|dex_map_size], dex_profile_index,class_id1,class_id2...,dex_profile_index2,...
 *    dex_map_size is the number of dex_indeces that follows.
 *       Classes are grouped per their dex files and the line
 *       `dex_profile_index,class_id1,class_id2...,dex_profile_index2,...` encodes the
 *       mapping from `dex_profile_index` to the set of classes `class_id1,class_id2...`
 *    M stands for megamorphic or missing types and it's encoded as either
 *    the byte kIsMegamorphicEncoding or kIsMissingTypesEncoding.
 *    When present, there will be no class ids following.
 **/
复制代码

在primary.prof不同版本的格式中, 在Android 7.0, 7.1版本没有inline_cache, 其他内容大同小异, 感兴趣的同学可以看下维基百科的inline cache定义。为了互相转换不同版本的Profile文件, 监控组提供了命令行工具用于查看转换primary.prof文件。

2.5 Profile的生成规则

从Dex2Oat的流程与二进制格式分析, 可以发现Profile文件的内容决定了编译的效果。我们需要了解ART虚拟机本身生成Profile文件的生成规则以及生成时机。其处理逻辑由profile_saver.cc实现(这里描述Android 8.1的逻辑, Android 7.x没有启动5s的时机)。

  • 时机1: App启动5s时, 记录所有已加载的类, 以及执行过一次的方法为hot method, 并标记为startup。
  • 时机2: App运行过程中, 每隔40秒检查一次。 执行超过10000次(敏感线程、主线程执行超过1000次)的方法有可能被存为hot method, 并标记为post startup。之所以是可能, 因为需要看JIT编译是否为这个Art Method生成ProfileInfo。

可以发现,Profile中包含的类信息均为启动过程中出现的类, 包含的热区方法大部分是启动过程中, 执行过程会有少量的高频方法会被标记为热区方法。

至于方法调用如何计数是由JIT模块实现, 因为Android N之后混合编译的存在, Art Method的方法调用分为以下四种:

  1. 解释执行-> 解释执行
  2. 解释执行-> 机器码执行
  3. 机器码执行-> 解释执行
  4. 机器码执行-> 机器码执行

对于被调用函数, 需要计数的是case1与case3, 所有解释执行均为调用interpreter_common.h中DoXxxInvoke方法, 内部会分别调用JIT的AddSample方法进行计数统计。

3. 方案与实践

通过跟踪Dex2Oat的执行流程, 我们发现PackageManagerService的performDexOpt函数不存在权限调用检查, 为App本身触发Dex2Oat编译提供了可能。简单分析Dex2Oat可以存在以下应用点:

  • 增加Dex2Oat的执行时机: 在系统生成Profile文件后, 由于系统编译任务有条件限制,并不会实时编译,甚至不会当天编译。 我们可以手动触发编译行为, 尽可能提早Dex2Oat执行的时机,理论上可以加速App升级前几天的执行性能。
  • 自定义Profile文件加速: 由于Profile文件不存在机型特有信息, 我们可以在发版前收集下发新版的Profile文件,从而是线上的App尽可能使用AOT能力。
  • 由于Android App自更新现象普遍存在,可以在升级前下发Profile文件,从而加速第一次启动。实验结果是对应目录没有权限,而且升级时会删除ref/primary.hprof这个文件。 此方案废弃。
  • Dex2Oat可以通过命令行调用, 加速插件Jar包执行效率, 由于美团没有插件化,此方案不在调研。

3.1 本地验证Dex2Oat效果

从原理上分析,执行Dex2Oat可以减少D状态的时间,减少JIT的线程CPU负载占用。首先需要本地验证,本地通过执行 adb shell cmd package compile -m 'speed-profile' com.sankuai.meituan触发Dex2Oat编译,然后通过监控工具测量JIT线程CPU占比情况, 如下图所示JIT编译线程的CPU占用排行从第1下降到了第8, 不过仍存在18%, 应该跟Profile文件中包含的热点信息有限有关。

线程名 占比
com.sankuai.meituan 23%
fifo-pool-threa 25%
HeapTaskDaemon 19%
Horn-ColdStartu 24%
Jit thread pool 18%
MRNBackgroundWo 24%
Sniffer Runnabl 25%

同时计算D状态的时间,发现D状态总时间下降了50%左右, 得出本地实验效果证明触发Dex2Oat能够有效优化背景中出现两个问题的结论。

3.2 灰度验证1: 增加Dex2Oat的执行时机

实验方案: App首次安装启动时, 监听Profile文件改变, 当App进入后台后, 手动触发Dex2oat编译。

由于Android P对私有API的反射限制, 改为通过命令行执行cmd package compile -m 'speed-profile' com.sankuai.meituan

通过独立灰度的AB测试, 已冷启动时间为衡量标准, 得到以下结果:

时间 第几天 对照组 实验组 优化时间 百分比
20200813 第一天 3931.0 3589.0 342ms 8.7%
20200814 第二天 3851.0 3613.0 238ms 6.1%
20200815 第三天 3848 3595 253ms 6.6%
20200816 第四天 4010.0 3939.0 71ms 1.8%

点评App同样接入了该功能, 优化效果约200-300ms。

3.3 灰度验证2: 下发Profile文件对启动时间的影响

实验方案: 针对下发Profile的猜想, 此次实验采用简单方法,在发版前取一台性能比较好的手机多次运行后导出primary.prof文件, 通过监控组的工具将primary.prof转化为其他版本的格式。

实验结果: 通过独立灰度的AB测试, 已冷启动时间作为衡量标准, 与实验1结果相近

3.4 总结

从灰度结果来看,我们通过直接触发Dex2oat编译在前3天效果逐渐减少, 到第4天时几乎与对照组相同, 符合系统会后台触发Dex2Oat的预期,美团的发版周一大约是2周一个版本,效果可以接受,最终由监控组的mtboost组件对外提供优化功能。

至于灰度2结果与灰度1结果大致相同的原因怀疑1. 下发的primary.prof与系统本身生成的primary.prof内容大致相同。 2. 冷启动过程中需要的类与方法已经被编译成机器码, 下发Profile文件大体对启动时间优化有限, 下发Profile的方案可能更适合2级页面加速。

4. 未来方向

从优化方案与工具能力两方面来讲,就优化来讲:

  • 系统本身Dex2Oat的类重排只能重排单个Dex文件, 无法在多个Dex间进行重排,我们可以手机Profile信息,将经常用到的类打到一个Dex中,将减少pagefault的数量,增加缓存命中率。
  • 下发Profile文件方案有采用的潜力,但是目前通过手机线下测试的Profile文件, 缺点是内容不可控,无法进行对比测试。 需要针对Dex自动生成Profile文件,进行AB测试,精准优化App的运行效率。
  • Dex2Oat命令可以通过命令行直接调用,如果对App进行插件化。可以通过执行Dex2Oat加速插件的执行。

就工具而言,目前文章中出现的线下数据大部分由监控组自研工具呈现,存在优化方向:

  • 用于分析线程状态占比、线程CPU负载、线程数量的脚本工具目前仅支持美团自动化运行,可接入到其他App中。
  • 用于定位D状态细化原因的脚本由于需ROM与内核支持,目前需要线下运行,需JOB化,简化接入手段。
  • Profile文件信息的格式化输出需与业务信息关联。

猜你喜欢

转载自juejin.im/post/7083098907676246046