原理介绍 | Apply Changes 背后的秘密

简介

在 Android 11 上,Android 运行时 (ART) 引入了一个名为 Structural Class Redefinition (类的结构性重定义) 的 JVMTI API 扩展。本文将介绍类的结构性重定义的功能,并介绍在实现该功能所遇到的问题,包含我们对问题的思考、权衡及解决方案。类的结构性重定义是一个运行时功能,它扩展了 Android 8 中引入的重定义类方法,Android Studio 里的 Apply Changes 可以通过它来改变类的自身结构,并可以在类中增加变量或者方法。

这可以被用在很多强大的功能中,例如扩展 Apply Changes 来支持向应用中增加新的资源。您可以 查看相关文档 了解 Android Studio ‘Apply Changes’ 功能的工作机制,以及在后续博客中了解其如何使用类的结构性重定义进行扩展。未来 Android Studio 会增加更加综合和功能强大的工具来适配这些新的特性。

JVMTI 是一个标准的 API,开发工具可以通过它在底层与运行时环境进行交互和控制。利用该功能实现了很多我们熟知的开发工具,从 Android Studio 中的 NetworkMemory 分析器,到调试器中的模拟框架,如 dexmaker-mockito-inlineMockK,再到 Layout 以及 Database 检查器。您可以在 Android 文档 中找到更多关于 Android JVMTI 的实现以及如何将其应用于您自己的工具中。

结构化重定义

类的结构性重定义基于 Android Oreo (8.0) 中增加的重定义类进行改进。在 Oreo 中,仅有类中已有的方法才能被修改。类中定义的对象布局以及字段集、方法集不能以任何方式进行修改。

类的结构性重定义对类的修改提供了更高的自由度,使已有类中添加全字段和方法成为了可能,对可能新增的字段及方法的类型没有任何限制。新增的字段初始值为 0 或 null,但是如果需要,JVMTI 代理可以使用 JVMTI 提供的其它方法为其初始化。和标准的类重定义一样,当前执行的方法将延用之前的定义,接下来的调用才会使用新定义。为了保障结构类重定义具有清晰一致的语义,如下修改将无法被执行:

  • 字段和方法被删除或者修改其属性
  • 类名被修改
  • 类的继承关系 (父类及实现的接口) 被修改

结合 Android Studio 的支持以后,类的结构性重定义可用于针对大多数编辑场景来实现 Apply Changes 功能。本文剩余部分将介绍我们是如何实现该功能,以及实现该新的运行时功能需要进行的考虑和权衡。

重中之重,性能无害

实现结构化重定义的主要挑战是不能让应用在发布模式下受影响。对于每个开发者来说,当他们的代码在调试模式下运行并且使用类似 Apply Changes 或者调试器这样的工具时,另一侧可能有数百万用户在他们的手机上运行这些应用。因此,一个首要的原则就是任何 ART 中新增的针对开发者的新特性都不可以在应用处于非调试模式的时候影响运行时性能。这意味着我们不能对运行时内部核心功能进行重大更改。例如我们不能修改对象的基本布局、内存申请、垃圾回收机制,不能改动类的加载和连接,以及 dex 字节码的执行。

包含 java.lang.Class 对象 (在 ART 中持有自身类型的静态字段) 在内所有对象,在加载之后就已经确定了其大小和布局。这样的特性使程序得以高效运行,如上图所示的 Parrot 类,我们可知任何一个 Parrot 对象都拥有 piningFor 字段,并保存在偏移量为 0x8 的位置。这意味着 ART 可以生成高效的代码,但与此同时,我们也无法在对象被创建之后修改对象的布局,因为增加新字段我们不仅仅修改了当前类的布局,同时影响了其所有子类。为了实现该功能,我们需要在无感且保证原子性的情况下,将原来的对象及实例替换成重定义的对应类。

我们需要深入运行时内部,才能在不影响性能的前提下实现类的结构性重定义。从根本上讲,对一个类进行结构化重定义有 4 个关键步骤:

  1. 使用新的类定义为每一个被修改的类型创建 java.lang.Class 的对象;

  2. 使用新定义的类型重新创建所有原有类型对象;

  3. 将所有原有对象替换/更新成与之对应的新对象;

  4. 确保所有编译后的代码及运行时状态相对于新类型布局而言都是正确的。

追求性能

和很多程序一样,ART 自身也是多线程的,一是因为所运行的 DEX 字节码本身带有的多线程特性 (潜在原因),二是为了避免程序在运行时出现暂停。在任何时刻,ART 都可能同步执行许多操作,如: 执行 Java 语言代码,执行垃圾回收,加载类、分配对象,执行 finalizer 或其它事情。

这意味着单纯地执行重定义行为是存在明显竞争的。举个例子: 如果在我们重新创建了所有旧对象后,一个新的实例被创建怎么办?因此,我们必须非常谨慎地执行每一个步骤,以确保不会遇到或者创建不一致的状态。我们需要保证每一个线程都能够了解到上图所示的是原子性的转换过程,并且所有操作是同步完成的。

对此,直接的解决方案为: 当我们开始执行重定义时,停止一切操作。然后我们按上述方式执行重新定义 (创建新的类和对象,然后替换旧的对象)。这样带来的好处是,我们无需付出任何实际投入就可以获得所需的原子性。当发现不一致时,所有的代码都会暂停,因此不一致的状态不会显露出来。可惜的是,这种方法有几个问题。

其一,这会大大降低处理速度。可能需要重新创建大量的对象,重新加载大量的类 (例如,如果需要编辑 java.util.ArrayList 类,可能有数千个实例与之相关)。更严重的问题是,在所有线程都停止的情况下,分配对象是不可能的,这是为了防止死锁,例如,我们在分配内存之前去等待一个已经暂停的 GC 线程先完成回收工作。这种限制深入到 ART 及其 GC 的设计中。简单地删除此限制来修改它是不可行的,尤其是为了一个仅在调试中使用的特性。又因为结构化重定义的主要操作是重新分配所有重定义的对象,所以去掉限制显然是不可接受的。

那么我们现在该怎么办呢?就 Java 代码而言,我们仍需要确保任何的改变需要立刻完成,但是我们无法让所有的操作都停止。这里我们可以利用 Java 语言的特性,线程无法直接获得堆以及关键的类加载状态,并且重要的 GC 管理线程永远不会分配或加载类。这意味着,我们暂停运行时其它操作的唯一步骤是替换过程。我们可以在其余代码仍在运行的情况下分配所有的类及新对象,因为这些线程没有任何新对象的引用,并且这些代码仍是原始代码,所以不会暴露不一致的状态。

如果您对具体实现感兴趣,可以访问相关链接。Android 开源项目 (AOSP) 代码搜索工具正式发布 这篇文章可以探索 Android 及 AOSP 是如何创建的。

由于我们允许应用代码继续运行,因此需要注意的是全部的状态不会因为我们的操作而改变。为此,我们必须按顺序仔细关闭运行时的每个部分,以确保我们可以收集所需的所有信息,并且在运行期间该信息不会失效。为了达到我们的目的,在重定义的时候,我们需要一个完整的列表包含所有重定义¹的类及其子类的 java.lang.Class 对象,需要一个对应的重定义的类的 Class 对象列表,需要一个包含该类全部实例的完整列表和一个包含全部重定义对象的完整列表。

由于加载新类的情况非常少 (并且我们需要新的 Class 对象以分配重定义的实例),我们可以先开始收集被重定义类的列表,并为重定义的类型创建新的 Class 对象。为确保这个列表完整且有效,我们需要在创建这个列表前 完全停止类加载²。为此,我们需要 从一开始就停止新类的加载,同时需等待正在进行的类定义完成。一旦完成,我们就可以安全地 收集重新创建 所有重定义类的 Class 对象。

至此,我们收集了所有所需的类,这些类会被用来重新创建那些需要进行替换的实例。与处理类相似,我们需要暂停分配对象并等待所有线程 确认,以确保我们的对象列表是最新的³。在此与处理类相似,我们 收集所有旧的实例 并对每个实例 创建新版本

至此我们拥有了所有的新对象,剩余要做的就是从旧对象复制字段值并且真正替换到新对象中。因为一旦我们开始将新对象提供给线程或对象引用,它们将不再处于不可见状态,并且线程在运行时可以任意更改任何字段,我们需要在执行这最后几个步骤之前 停止所有线程。只要其它所有线程都已经停止,我们便可以 将字段值从旧对象复制到新对象

一旦完成上述操作,我们就可以 遍历堆使用重定义的新实例替换所有旧实例。现在所剩余的就是做一些杂项工作,以确保相关事项能够根据需要得到更新或清除,例如反射对象、各种运行时解析缓存等。我们还确保能够追踪足够的数据,以允许所有运行的代码在重定义开始时能够持续运行。

总结

有了结构化重定义的功能,许多全新的、更强大的调试和开发工具就应运而生。我们已经探讨过了 Apply Changes 的改进,并且 Android 领域里许多团队正在研究基于此功能开发其它强大的工具。这只是我们在每个 Android 版本发布时添加的许多改进和新特性中的一部分。欢迎您阅读我们最近的一篇 文章,关于我们如何使用 IO prefetching 来改进 Android 11 应用程序的启动时间。

[1] 在此之前,我们会执行一些检查,以确保所有的类都符合重定义条件,并且新的定义都有效,不过这些验证很枯燥。

[2] 从技术上来看,继续加载无关的类是安全的,但是由于加载类的工作方式,没有办法尽早区分这些情况以达到理想效果。

[3] 同样,分配对象与 art 虚拟机跨线程同步机制的交互有很多细节,这些细节使我们不能单纯地暂停重定义类实例的分配。