原理介绍 | Apply Changes 背后的秘密
简介
查看相关文档 https://medium.com/androiddevelopers/android-studio-project-marble-apply-changes-e3048662e8cd Network https://developer.android.google.cn/studio/profile/network-profiler Memory https://developer.android.google.cn/studio/profile/memory-profiler dexmaker-mockito-inline https://medium.com/androiddevelopers/mock-final-and-static-methods-on-android-devices-b383da1363ad MockK https://mockk.io/ Android 文档 https://source.android.google.cn/devices/tech/dalvik/art-ti
结构化重定义
类的结构性重定义对类的修改提供了更高的自由度,使已有类中添加全字段和方法成为了可能,对可能新增的字段及方法的类型没有任何限制。新增的字段初始值为 0 或 null,但是如果需要,JVMTI 代理可以使用 JVMTI 提供的其它方法为其初始化。和标准的类重定义一样,当前执行的方法将延用之前的定义,接下来的调用才会使用新定义。为了保障结构类重定义具有清晰一致的语义,如下修改将无法被执行:
字段和方法被删除或者修改其属性
类名被修改
类的继承关系 (父类及实现的接口) 被修改
重中之重,性能无害
包含 java.lang.Class 对象 (在 ART 中持有自身类型的静态字段) 在内所有对象,在加载之后就已经确定了其大小和布局。这样的特性使程序得以高效运行,如上图所示的 Parrot 类,我们可知任何一个 Parrot 对象都拥有 piningFor 字段,并保存在偏移量为 0x8 的位置。这意味着 ART 可以生成高效的代码,但与此同时,我们也无法在对象被创建之后修改对象的布局,因为增加新字段我们不仅仅修改了当前类的布局,同时影响了其所有子类。为了实现该功能,我们需要在无感且保证原子性的情况下,将原来的对象及实例替换成重定义的对应类。
使用新的类定义为每一个被修改的类型创建 java.lang.Class 的对象; 使用新定义的类型重新创建所有原有类型对象; 将所有原有对象替换/更新成与之对应的新对象; 确保所有编译后的代码及运行时状态相对于新类型布局而言都是正确的。
追求性能
和很多程序一样,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 对象。
完全停止类加载 https://cs.android.com/android/platform/superproject/+/android-r-beta-2:art/openjdkjvmti/ti_redefine.cc;l=2476 从一开始就停止新类的加载 https://cs.android.com/android/platform/superproject/+/android-r-beta-2:art/openjdkjvmti/ti_redefine.cc;l=2397;drc=android-r-beta-2 收集 https://cs.android.com/android/platform/superproject/+/android-r-beta-2:art/openjdkjvmti/ti_redefine.cc;l=1901;drc=android-r-beta-2 重新创建 https://cs.android.com/android/platform/superproject/+/android-r-beta-2:art/openjdkjvmti/ti_redefine.cc;l=1953;drc=android-r-beta-2
至此,我们收集了所有所需的类,这些类会被用来重新创建那些需要进行替换的实例。与处理类相似,我们需要暂停分配对象并等待所有线程确认,以确保我们的对象列表是最新的³。在此与处理类相似,我们收集所有旧的实例并对每个实例创建新版本。
确认 https://cs.android.com/android/platform/superproject/+/android-r-beta-2:art/openjdkjvmti/alloc_manager.cc;l=197;drc=android-r-beta-2 收集所有旧的实例 https://cs.android.com/android/platform/superproject/+/android-r-beta-2:art/openjdkjvmti/ti_redefine.cc;l=1727;drc=android-r-beta-2 创建新版本 https://cs.android.com/android/platform/superproject/+/android-r-beta-2:art/openjdkjvmti/ti_redefine.cc;l=1765;drc=android-r-beta-2
至此我们拥有了所有的新对象,剩余要做的就是从旧对象复制字段值并且真正替换到新对象中。因为一旦我们开始将新对象提供给线程或对象引用,它们将不再处于不可见状态,并且线程在运行时可以任意更改任何字段,我们需要在执行这最后几个步骤之前停止所有线程。只要其它所有线程都已经停止,我们便可以将字段值从旧对象复制到新对象。
停止所有线程
https://cs.android.com/android/platform/superproject/+/android-r-beta-2:art/openjdkjvmti/ti_redefine.cc;l=2508;drc=android-r-beta-2
将字段值从旧对象复制到新对象
https://cs.android.com/android/platform/superproject/+/android-r-beta-2:art/openjdkjvmti/ti_redefine.cc;l=2783;drc=android-r-beta-2
一旦完成上述操作,我们就可以遍历堆并使用重定义的新实例替换所有旧实例。现在所剩余的就是做一些杂项工作,以确保相关事项能够根据需要得到更新或清除,例如反射对象、各种运行时解析缓存等。我们还确保能够追踪足够的数据,以允许所有运行的代码在重定义开始时能够持续运行。
遍历堆 https://cs.android.com/android/platform/superproject/+/android-r-beta-2:art/openjdkjvmti/ti_redefine.cc;l=2929;drc=android-r-beta-2 使用重定义的新实例替换所有旧实例 https://cs.android.com/android/platform/superproject/+/android-r-beta-2:art/openjdkjvmti/ti_heap.cc;l=1664;drc=android-r-beta-2
总结
文章 https://medium.com/androiddevelopers/improving-app-startup-with-i-o-prefetching-62fbdb9c9020
[1] 在此之前,我们会执行一些检查,以确保所有的类都符合重定义条件,并且新的定义都有效,不过这些验证很枯燥。 [2] 从技术上来看,继续加载无关的类是安全的,但是由于加载类的工作方式,没有办法尽早区分这些情况以达到理想效果。 [3] 同样,分配对象与 art 虚拟机跨线程同步机制的交互有很多细节,这些细节使我们不能单纯地暂停重定义类实例的分配。
一本手册尽览 Android 11 最新特性与开发技巧
更有成功心得助您举一反三
☟ 即刻下载 ☟
推荐阅读