一次ART内联优化导致的APK玩崩

1,521 阅读15分钟

背景

在包体积优化中有一项就是开启 proguard 配置的 optimizations 选项,optimizations 开启后其中有一项优化 pass 就是在 R8 过程中对方法进行内联。

其中方法内联减少了 1MB+ 的 DEX 体积,收益非常可观,上线后,发现了再第二个版本推送期间top crash 里面出现了一些 VirtualMachineError ,且设备全部分布在 android 6.0 设备上。

Crash 堆栈现场

17555dc9735440e6bc6b3b0b60f78126~tplv-k3u1fbpfcp-jj-mark_0_0_0_0_q75_mosaic.png

从堆栈上看crash 是虚拟机内部抛出的,由于是新产生的问题,反编译apk观察代码前后差异,很容易发现 uploadNotificationChannels方法被内联了。那问题八九不离十就是内联引起的了,但是内联为什么会导致虚拟机抛出crash呢?从代码上看没什么问题啊。

方法内联

先通过一个简单栗子介绍下方法内联,内联前

public int  fun1(int a, int b){

    return fun2(a, b);

}

public int fun2(int a, int b){

    return a + b;

}

内联后

public int fun1(int a, int b){

    return a + b;

}

内联之后减少了fun2栈帧的生成、fun2 局部变量进栈出栈操作,以及从fun1 到 fun2 指令执行的切换开销,可以给我们带来一定的性能提升。

内联并不会改变任何原有结果,优化如果不能保证前后的一致性,那优化将毫无意义。既然内联优化是安全的,那为什么还会引发crash问题呢?

编译流程

要知道为什么内联会导致crash的问题,需要先对apk的编译流程进行简单介绍。

当前业界普遍认为编译器在功能上最好是三段式的

  • 第一段叫前端,其输入为源代码,输出为中间标识 IR (Intermediate Representation), IR 没有标准语法,各个编译器都可以自定义自己的 IR;前端将对输入的源码进行词法分析、语法分析、语义分析等工作;
  • 第二段叫优化器(Optimizer),优化器的输入是未经优化的 IR,输出的是优化后的 IR,常用的优化手段有循环优化、常量传播和折叠、无用代码消除、方法内联等;
  • 最后一段叫后端(Backend),其输入是优化后的IR,输出是目标机器的机器码。

现在结合 android app 来对这一过程进行简要说明。

上图是 Android 构建 apk 的流程图,程序员编写的源码通过 Gralde 等构建工具,可以很方便的构建出目标apk。这里的编译流程也是满足上文提到的三段式的,其输入是 java、kotlin 等格式的源码,输出为 Dex 字节码。其前端的 IR 是通过 javac、kotlinc 编译生成的 .class 字节码;优化器是 R8 ,其输入是 class 字节码,输出为优化后的 dex。

R8 编译器的优化是一遍遍执行一系列优化 pass 的过程,优化的力度可通过 proguard 的 optimizationpasses指定, 数值越大优化效果越好,同时编译耗时也越久。方法内联便是其中的一条 pass。R8 编译器内联优化后的产物为优化后的 class 字节码文件,最后再将优化后的class文件组装成 dex 字节码,之后通过 packager 打包形成 APK 文件。

APK 需要在 ART 虚拟机上运行(早期是Dalvik), 对 ART 虚拟机来说,它的编译优化器的输入是 APK 中的 dex 字节码,输出是优化后的 HInstruction。ART 中的 IR 统称为 HInstruction,HInstruction 被定义为一个类,不同的 IR 对应不同的 HInstruction 子类。

APK 从严格意义上来说它也并不是用户在设备上运行的最终产物,APK 只是一个程序运行的中间产物,APK 的运行需要依附于 android 设备的 ART 虚拟机来执行。ART 虚拟机同时具备解释执行和快速执行两种模式

  • 在解释执行模式下虚拟机会dex中的一条条指令通过一个巨大的类似 switch -case 的语句逐个解析,分配内存、创建对象、分配寄存器地址、计算结果等一系列流程来定位到真实物理地址上去
  • 在快速模式下,虚拟机直接执行的机器码指令,会直接访问到指令保存的、确定的物理地址上去

ART 编译 dex 为机器码的流程

  1. 构造控制流图 CFG (control flow Graph)

控制流分析需要用到控制流图,它采用数据结构中的图来表达相关信息,它由基本块(Basic block)和边(Edge)组成。基本块中的代码用于表达控制信息的流动,它并不拘泥于代码的具体形式。 另外,CFG 是针对于某个函数来构造的,一个函数最开始的语句构成一个入口基本块,一个 CFG 只有一个出口基本块。在 CFG 中,入口基本块的入度为0,出口基本块的出度为1。

一个函数可能有多个retrun语句,相当于有多个出口基本块。为了解决这个问题,CFG构造了一个空的基本块,让那些 retrun 基本块先指向这个空的基本块,然后再将这个空的基本块设置为出口基本块。

入度为0的基本块只有可能有两种情况,一种是入口基本块,另一只是永远不会被执行到的基本块。

ART 虚拟机中的 HBasicBlock 代表基本块、HBasicBlock 通过 graph_变量指向一个CFG对象,用 dex_pc_ 表示该基本块起始的 dex 字节码。

ART 虚拟机中的 HGraph 代表 CFG,其 blocks_ 数组代表了由代码分析出来的所有基本块。在本篇文章中,我们只需要对 CFG 的一些基本概念有所了解即可,CFG 的构建流程这里就不做过多介绍了。

  1. 分析处理 CFG

CFG 构造完成后,基本块之间的前驱-后继关系就弄清楚了,接下来就轮到分析和处理 CGF 了。在这个过程中主要是通过算法手段对 CFG 进行简化。通过建立支配树(Dominator Tree),优化 CFG 中成环的结点,在分析过程中采用的是逆后续遍历的深度优先搜索算法(DFS)来对 CFG 进行一遍遍遍历的。

  1. 数据流分析

数据流分析得到的信息对优化器后续开展对一系列优化工作非常有意义,在 ART 虚拟机中采用的是将 IR 转换成静态单赋值形式的方法来进行的,它通过对 IR 进行一系列处理使得数据流分析工作得以大大简化,同时有效改善了后续优化的执行效率。

构建和整理完 CFG 后,ART 优化器的下一步就是以 RPO (Reverse Post-Order,逆后序) 遍历基本块,然后将其所包含的 dex 字节码转换成对应的 HInstruction 。前面介绍过,HInstruction 是ART优化器输出的 IR。

  1. IR 优化

在 ART 的代码里,不同的优化方法被设计成不同的类,这些类有一个共同的基类 HOptimization。此外,ART 中还提供了 HGrpahVisitor 等用于遍历 CFG 的辅助类。

优化的入口函数为 RunOptimizations,可以看到里面定义了各种优化类,内连优化 HInliner 就在其中。

static void RunOptimizations(HGraph* graph,

                             CompilerDriver* driver,

                             OptimizingCompilerStats* stats,

                             const DexFile& dex_file,

                             const DexCompilationUnit& dex_compilation_unit,

                             PassInfoPrinter* pass_info_printer,

                             StackHandleScopeCollection* handles) {

  HDeadCodeElimination dce1(graph, stats,

                            HDeadCodeElimination::kInitialDeadCodeEliminationPassName);

  HDeadCodeElimination dce2(graph, stats,

                            HDeadCodeElimination::kFinalDeadCodeEliminationPassName);

  HConstantFolding fold1(graph);

  InstructionSimplifier simplify1(graph, stats);

  HBooleanSimplifier boolean_simplify(graph);



  HInliner inliner(graph, dex_compilation_unit, dex_compilation_unit, driver, stats);



  HConstantFolding fold2(graph, "constant_folding_after_inlining");

  SideEffectsAnalysis side_effects(graph);

  GVNOptimization gvn(graph, side_effects);

  LICM licm(graph, side_effects);

  BoundsCheckElimination bce(graph);

  ReferenceTypePropagation type_propagation(graph, dex_file, dex_compilation_unit, handles);

  InstructionSimplifier simplify2(graph, stats, "instruction_simplifier_after_types");

  InstructionSimplifier simplify3(graph, stats, "instruction_simplifier_before_codegen");



  IntrinsicsRecognizer intrinsics(graph, dex_compilation_unit.GetDexFile(), driver);



  HOptimization* optimizations[] = {

    &intrinsics,

    &fold1,

    &simplify1,

    &dce1,

    &inliner,

    // BooleanSimplifier depends on the InstructionSimplifier removing redundant

    // suspend checks to recognize empty blocks.

    &boolean_simplify,

    &fold2,

    &side_effects,

    &gvn,

    &licm,

    &bce,

    &type_propagation,

    &simplify2,

    &dce2,

    // The codegen has a few assumptions that only the instruction simplifier can

    // satisfy. For example, the code generator does not expect to see a

    // HTypeConversion from a type to the same type.

    &simplify3,

  };



  RunOptimizations(optimizations, arraysize(optimizations), pass_info_printer);

}
  1. 寄存器分配

ART 是基于寄存器的虚拟机,前面的 IR 经过多轮优化后就需要考虑到寄存器的分配和指派问题。因为虚拟的寄存器没有个数限制,但是真实 cpu 却不可能提供无限个数的寄存器,所以寄存器分配要解决 IR 里使用无限个数的虚拟寄存器和物理上却只有有限个数的寄存器之间的矛盾。

当物理寄存器不够用时,优化器需要生成额外代码将物理寄存器里的数据先保存到内存,从而给其他指令腾出该寄存器,用完之后又可能需要将内存里的数据重新加载到物理寄存器。显然,数据在内存和寄存器之间倒腾的次数越多,越影响程序性能。

ART 中寄存器的分配采用的是线性扫描分配法(Linear Scan Register Allocation on SSA Form)。

Android 6.0 ART 虚拟机的内联

ART 虚拟机中的内联优化执行过程即是对一条条 HInstruction 执行 tryinline 的过程。graph 代表的是dex 生成的 CGF,HInstruction 可以先简单理解为一条条的方法指令。TryInline 会先对一些不满足内联条件的 HInstruction 进行过滤,满足内联条件的 HInstruction 则会通过 TryBuildAndInline尝试进行内联。

这里简要列举出一些 ART 虚拟机中的内联条件,源码在这里 cs.android.com/android/pla…

  1. apk是 Debugable 的,不内联
  2. Native 代码不内联;这个很好理解,native 代码无需在寄存器中分配内存地址
  3. 超过内联指定的行数方法不内联
  4. 被 try 修饰的方法不内联
  5. static 方法不内联
  6. 未通过 verify 校验的class 不内联
  7. 被ART虚拟机标记为不内联的方法不会内联

ART 虚拟机除了完成成对 IR 优化,还需要对寄存器分配内存地址,同时android 6.0 中采取的是 AOT (ahead of time)的编译模式,也就是在apk安装阶段会尽可能将代码进行机器码化。ART 虚拟机将 IR 机器码后的产物为 OAT 文件,OAT是一种对 ELF 文件的拓展,它主要是在 ELF 格式的基础上 拓展了一块区域用来存放 DEX 数据。

我们设备上运行的apk程序在执行过程中,会通过 ELF 的 trampoline 表来寻找目标方法地址,对于已经机器码化的代码则会直接跳转到汇编指令,对于需要解释执行的则会降级到 dex 方法区域进行对象创建、调用、最终也还是需要执行到具体的汇编指令上去。

Android 6.0 内联为什么会产生 Crash

新安装的用户触发内联并不会引起crash,但是当app升级的时候有概率出现crash。而 crash 产生的真正原因则与 ART 虚拟机的 dex2oat 、内联优化 都有关系。

首次安装的时候,用户设备里面代码都没有oat化,apk安装过程中会通过dex2oat来对进行优化,优化过程需要 ART 虚拟机的介入,HInliner 执行,最后基于优化后的代码分配寄存器地址,生成OAT机器码。apk 运行的时候,运行的是 oat 文件,oat 文件符合 ELF 文件的标准,所以对方法调用来说会执行到 plt 表上去,通过 plt 上的 trampoline code 寻找目标地址,由于方法内连,trampoline 会指向被内联方法的返回结果目标地址上,这种情况下寄存器分配、内存地址都是正常的,也就不会有什么问题。

但是当apk升级的时候,覆盖安装应该是不需要和首次安装那样对全部代码进行dex2oat(这个只是个人猜测,从安装耗时来说确实覆盖安装快很多,实际情况要对 PMS 首次/覆盖 安装apk 以及 dex2oat 进行深入了解才能有准确结论),当内连对方法和调用的方法不在同一个dex 中时候,并且其中一方重新生成了oat 导致寄存器地址变更,方法依旧是以quick模式执行,这时候去找的地址信息就错乱了,就可能引起crash。如果是非 quick 模式,也就是会退到解释执行模式,trampoline code 则会指向 oat 文件中的 dex_pc 块的代码去执行,重新分配寄存器,也不会有问题。

Android 7.0+ 是如何修复的

从7.0 开始,如果方法是 InvokeStaticOrDirect 类型则直接通过 GetResolvedMethod 从dexcache获取 method信息,否则通过 FindVirtualOrInterfacetarget 去寻找目标 method。

InvokeStaticOrDirect 怎么理解,简单来说可以理解为 java 中通过 static 或者 final 修饰的方法。

从上面crash的堆栈来看,代码将会执行到 FindVirtualOrInterfacetarget 分支上去,FindVirtualOrInterfacetarget 也会对 接口方法和 虚方法的调用分别执行不同的前置流程处理,如果前置条件校验都通过,最终则会走到 TryBuildAndInline 上去

TryBuildAndInline 中,首先就是对 ProxyMethod 进行内联限制,而 Retrofit 正好是通过动态代理的方式实现的,满足了 isProxyMethod 的条件,所以 7.0 之后这类方法就不再进行内联优化了。

在 Retrofit 的 issues 中也能找到同类问题的反馈 github.com/square/retr… Android 虚拟机的 bug,只不过 Retrofit 的场景正好更容易让问题复现,我们的方法内联优化也是一样的道理。

在我们项目中,不管是直接使用封装的 RestClient 来进行的请求,以及继承于 BaseFacade 来进行的请求,都用到了 Retrofit ,而 Retrofit 则正好是这类问题触发的"大户人家",本着具体问题具体分析的原则,当前的问题就变成了如何避免项目中由于使用到 Retrofit ,且恰巧 Retrofit 定义的接口类和调用类由于方法内联,导致接口类和调用类分离到了不同包名下,导致最终打成apk的时候可能被分配到不同dex中,再经过 ART虚拟机处理成不同oat文件在某些场景下可能概率性引发crash的问题。

项目中的解决方案

最简单的解决方法自然是关闭R8内联优化,但是这并不能解决问题的本质,因为程序员完全可以将代码写成内联优化后的样子,这种情况下该出现的问题还是会出现。

第二种解决方案是关闭 ART 虚拟机的 quick 模式,全部代码退回到 解释执行上去,有些热修方案采取的是这种模式来工作的,但是这对 apk 的性能损耗较大,而我们用户群体中,虽然 6.0 用户所占比例本身较少且6.0设备性能相对降低,但是我们依旧希望给用户尽量带来更好的体验,退回到解释执行在性能上有点无法接受。非万不得已,我们会尽量避免此方案。

第三种方案则是采取避免和绕开 ART 虚拟机的内联条件来进行干预。目前我们选取的正是这种方案。具体做法如下:

  1. 针对采用 RestClient 进行网络请求的接口,采取的是破坏 ART 虚拟机内联条件,因为项目中的 RestClient 是单例,所以通过 try-catch 块的条件即可满足破坏 ART 虚拟机内联的条件。

  1. BaseFacade 是我们项目中存在的一个网络库请求封装的基类,项目中存在大量继承自 BaseFacade 的子类,由于 BaseFacade 封装的网络请求方法大部分是以 static 修饰的,在生产 apk 的过程中,如果没有开启方法内联优化,这些 static 方法都将被保留,再到 ART 的内联优化的时候,由于是 static 修饰的 ART 并不会对这部分代码进行内联,所以在 proguard 方法内联开启之前不会有crash。但当 proguard 将内联优化开启后,某些继承于 BaseFacade 的请求体在满足内联的条件下,是会将这些 static 方法进行内联的,是可能导致最终被内联后的方法没有了 static 修饰,从而满足了 ART 虚拟机的内联条件,进行内联,也就有概率出现crash。

针对 BaseFacade 系列的方法,只需 keep 住这些 static 方法,那么自然也就不会被 ART 虚拟机内联了。

-keepclassmembers class * extends ***.facade.BaseFacade{

   public static <methods>;

}

小结

这个问题看似是一个人畜无害的优化引发的,但从问题的出现到解决的过程中其实经历了好几个迭代,最开始处理的时候只是将问题方法进行keep住,但是后续迭代又出现了其他的类似问题。这个时候才开始深入的对这一块进行了解,期间学习了 ART 相关的一些知识,尝试跟踪阅读 ART 源码,随着了解的深入也越来越体会到 ART 中很多设计的强大之处。