(译)Instant Run Instrumentation

官方关于instant run插桩技术的介绍。

翻译自官方文档

汇总一下文中提到的几个类:

注:不太明白如何翻译instrumenttation这个词,查了一下这个词指的应该是获取计算机软件或者硬件状态的数据的技术,一般翻译为插桩

本文介绍了instant run hot swap的插桩操作,翻译自instant run hot swap instrumentation

另一篇相关的文章是instant run

背景

Instant run的一个目标是可以在旧的Android设备上运行(低至Android ICS),所以不会考虑那些要求修改运行时环境(Runtime Environment)的技术方案,比如说,修改Android VM以支持类似标准JDK中的允许重新加载类的javaagent技术。

任何使用不同class loader加载同一个类的不同版本的技术方案都无法解决是这几个问题:

  • 类的问题 - Java语言中使用类的完全限定名以及加载该类的class loader来识别类(package name + simple class name)。所以相同完全限定名的类被不同的class loader加载后其实是不同的Java类,它们之间并不能强制转型,会出现ClassCastException
  • 实例的问题 - 修改前的类的实例并不能作为修改后的类的实例来使用,新的实例是新的类的,这里也有混用时类型无法匹配的问题

因此instant run的解决方案要考虑Java语言的内存模型以及类型系统的限制:

  1. 类不能被重载 (译者注:JVM运行期间类不能被重载,reload)
  2. 类的实例必须都是同一类型,即使类的实现被修改
  3. 并不能真的创建更新后的类的实例(因为类之间的引用关系已经被JVM提前解析了,这种引用关系是不能变的)

(译者注:这里讲到的限制都有一个前提,即并不重启JVM)

最终的解决方案有点难以置信地简单:不要想着类会变会被修改,所有实例都是最初的类的实例。如果一个类被更新了,它所有的方法实现重定向到新的实现即可。

换句话说,一旦类加载了,这个类的所有实例都来自唯一的FQCN(full qualitied class name,完全限定类名)+ class loader。一旦这个类更新了,它立马变成一个(保留字段值的)空壳,这个空壳用于重定向每个方法调用到新的方法实现。

当然,为了保证上述过程能成功进行,原始的类必须具备可更新的能力(即,可以重定向到新的方法实现)。而更新后的类也必须具备接收重定向方法调用的能力。通过这个例子来看看它的工作原理:

1
2
3
4
5
6
7
8
9
10
11
package com.google.android.foo;


public class A {
int intField;


public int someMethod() {
return intField;
}
}

为了让这个类可更新,将它增强为这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package com.google.android.foo;


public class A {
static IncrementalChange $change = null;
int intField;


public int someMethod() {
if ($change != null) {
return $change.access$dispatch("someMethod_()I", this)
}
return intField;
}
}

如你所见,如果那个静态字段$change没被设置,类的行为并不会发生改变(当然,还是有一些影响,if()判断会带来轻微的性能损失)。还要注意的是,是这个增强后的类而不是原始的类被打包到初始的APK中。

现在假如用户修改了A的实现,构建系统会对A类进行必要的增强以让其成为修改前的类的新实现。由于修改后的类并不会被用来创建新的实例,所以增强过程中我们可以安全地移除它其中的全部实例字段和静态字段。此外,还可以将其所有方法转换成静态方法。

这是修改后的A类:

1
2
3
4
5
6
7
8
9
10
11
package com.google.android.foo;


public class A {
int intField;


public int someMethod() {
return intField*2;
}
}

这是增强后的A类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package com.google.android.foo;


public class A$Override implements Dispatch {


public static int someMethod(A sender) {
return sender.intField*2;
}


public static Object dispatch(A sender, String methodName, String signature) {
if (methodName.equals("someMethod")) {
return someMethod(sender);
}
}
}

(译者注:注意区别两个不同的增强过程。一个增强过程是让初始的类具备可更新的能力,另一个增强过程是让修改的类可以作为原始类的代理)

现在要做的工作就是将A$Override类写入到A类的$change字段。这个工作是在运行期间使用新的classloader完成的(不妨称这个classloader为overriding classloader,它用于加载A$Override类)。overriding classloader的父加载器是原始的A类的classloader,所以A$Override对A可见(译者注:注意这里类的可见性问题)。运行时的关系图如下:

overriding-class-loader

可支持的更新操作

目前,Hot Swap支持修改任意方法实现(包括构造方法,静态方法以及实例方法),以及结构变化(比如增加或删除一个方法,修改类结构,修改静态初始化块),

这里涉及到两种插桩。一种是初始插桩,它将最初并不支持hot swap的类增强为可支持hot swap。另一种是增量插桩,它会对修改后的类进行增强,增强的类用于运行期间注入到类的$change字段。

初始插桩

初始插桩用于准备可支持hot swap的类,这些类在VM生命周期中可以被更新。这种可更新能力带来的开销应当最小化,因为它影响应用中的每个方法和类。

让方法可更新

当一个方法被覆盖,其$change值被设置为当前的$Override类。每个方法调用都被重定向到新的方法实现,重定向过程会传递原始调用的参数并返回非void方法的返回结果。

在初始插桩期间,所有的静态方法及实例方法都会按照以下伪代码这种方式被增强:

1
2
3
4
5
6
7
8
9
10
11
if ($change != null) {
Object[] parameters = new Object[method.getTypeParameters().length];
parameters[0] = this;
for (int i=0; i < method.getTypeParameters().length; i++) {
parameters[i] = box(argsI);
}
Object res = $change.access$dispatch(method.signature, parameters);
if (method.getReturnType != Void) {
return unbox(res);
}
}

让构造方法可被更新

基于以上相同的思路,构造方法也可插桩。注意以下几个难点:

  1. 直到super.<init>方法被调用后才能在构造方法以外的地方使用this关键字,否则它未被初始化
  2. 只有构造方法代码才能调用super.<init>,这个调用不能放在其他类中进行
  3. 当用户有类似super.A(Utils.myStaticMethod("a", "b"), 2+3)这种代码时,一些代码才可以出现在super.<init>调用之前

因此将构造方法分成3个阶段:

  1. super.<init>之前的阶段,这个阶段只包括用于创建super构造方法参数列表的必要代码
  2. 使用参数来自上一步的参数调用super.<init>
  3. super.<init>之后的阶段。(注意这个阶段可以在构造方法以外的地方使用this)

以这段代码为例:

1
2
3
4
Public A(int i, int j, String str) {
super(Utils.getContext(), i*j, str);
this.intField = i;
}

支持更新的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public A(int i) {
Dispatch localChange = $change;
If (localChange != null) {
Object[] args = new Object[];
args[0] = args; // so init$args implementation can change
// the super parameter values.
args[1] = box(i);
localChange.init$args(args);
// push back arguments on the stack from the args as the
// init$args function may have changed it.
a = args[1];
b = args[2];
c = args[3];
} else {
a = Utils.getContext();
b = i*j;
c = str
}
super.<init>(a, b, c);
if (localChange != null) {
Object[] args = new Object[2];
Args[0] = this;
Args[1] = box(i);
localChange.init$body(args);
return;
}
this.intField = i;
}

调用super

调用super方法也需要特殊处理。需要特殊处理的原因在于VM中的检查器会校验对invokdespecial指令对父类方法的调用是由包含目标方法的子类代码发起的。

进一步说,使用反射起作用。即使找到父类方法的引用并且调用它,生成的代码也只不过是对你传入的实例进行invokevirtual调用。所以invokevirtual会调用对象的实例方法而不是预期中的父类的方法。

为了解决这个问题,我们在可更新的类中生成一个跳转方法access$super()。这个方法提供对父类方法的访问。所以剩下要做的就是在incremental transformation过程将所有super.method()调用转换成对access$super()的调用。

跳转方法的伪代码如下:

1
2
3
4
5
6
7
8
9
10
Object access$super(String name, object[] args) {
switch(name) {
"firstMethod.(Ljava/lang/String;Ljava/lang/Object;)Ljava/lang/Object;":
return super.firstMethod((String)arg[0], arg[1]);
"secondMethod.(Ljava/lang/String;I)V":
return super.secondMethod((String)arg[0], (int)arg[1]);
default:
Throw new InstantRunException("... not found");
}
}

字节码插桩

初始指插桩

ASM Java bytecode visitor执行这种插桩,对类的修改如下:

  1. 增加一个静态的$change字段
  2. 将所有的非private方法修改为public,以便它们可以被$override类直接访问而不是通过反射方式访问
  3. 修改所有方法的实现:先检查是否有新版本的实现被设置到$change字段,如果有的话将调用分发到$change
  4. 将所有的非private字段修改为public,以便它们可以被$override类直接访问而不是通过反射方式访问)
  5. 提供access$super方法(细节见上文)
  6. 提供access$constructor(细节见上文)

增量插桩

ASM Java bytecode visitor创建$override类,引入如下变更:

  1. 移除所有构造方法和字段声明。由于不会创建$override类的实例,所以用不到构造方法和字段
  2. 将所有方法修改为静态(因为这个类没有实例)
  3. 修改Dispatcher方法的实现逻辑
  4. 使用反射方式访问原始实例的private字段
  5. 使用调用$Override.method的方式来调用私有方法