La práctica de exploración de Android Art Dex2Oat

1. Antecedentes

El tiempo de inicio de una aplicación es un indicador importante que refleja el rendimiento de la aplicación, lo que afectará directamente la experiencia del usuario y la impresión subjetiva de la aplicación. El documento oficial de Google establece que " Los usuarios quieren que la aplicación se inicie lo suficientemente rápido como para iniciarse. Si una aplicación tarda demasiado en iniciarse, los usuarios se sentirán muy decepcionados y es posible que tengan una evaluación baja de la aplicación en la tienda de aplicaciones o simplemente desinstalen nuestra aplicación ". Investigaciones adicionales han demostrado que cada aumento de 1 segundo en el tiempo de carga de la página reduce las conversiones en un 7 %, las visitas a la página en un 11 % y la satisfacción del cliente en un 16 % (consulte la Referencia 4). El tiempo de inicio de la aplicación pertenece a un tipo especial de carga de página, y es de gran importancia comercial operar la aplicación de manera más rápida y fluida. El tiempo de lanzamiento de la aplicación Meituan se encuentra en el nivel medio e inferior de la industria, con espacio para la optimización y el valor de optimización. En el proceso de categorizar y ubicar los factores que afectan el tiempo de inicio, encontramos los siguientes dos problemas:

1.1 Problema 1: La proporción de estado D en la fase de arranque supera el 50%

El estado del subproceso de Linux se puede dividir aproximadamente en cuatro estados: R, D, S y R+: R significa que el subproceso se está ejecutando en la CPU; R+ significa que el subproceso está esperando en la cola de programación y aún no se ha ejecutado en la CPU. CPU; S significa suspensión interrumpible, generalmente causada por el bloqueo del usuario; D significa suspensión sin estado, generalmente causada por el bloqueo del kernel. Clasificamos el hilo principal de la fase de inicio de Meituan según la proporción del estado y descubrimos que la proporción del estado D en el Nexus 6p (máquina de gama baja) supera el 50 %, y la proporción en el Pixel 3xl es del 37 %. Es necesario optimizar el tiempo de arranque Se realiza un análisis detallado de las causas del estado D. A través de la administración del kernel y el uso de SimplePerf para capturar la información de la pila cuando ocurre la competencia, se encuentra que el estado D de Meituan es causado principalmente por dos subprocesos que compiten por el bloqueo del kernel "mmap_sem". Clasifique y compare los tipos de tareas del subproceso principal y el subproceso secundario cuando ocurre la competencia:

Tabla 1. Al ingresar al estado D, se clasifican las tareas del subproceso principal y subproceso

Hilo principal hilo infantil
razón principal Clasificación de proporciones razón principal Clasificación de proporciones
do_page_fault 55% operación de creación de hilos 44%
get_user_pages_fast 27% asignación de memoria 20%
vm_mmap_pgoff 7% así que carga 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文件信息的格式化输出需与业务信息关联。

Supongo que te gusta

Origin juejin.im/post/7083098907676246046
Recomendado
Clasificación