0%

当 Java 字节码遇到 ASM

前言

ASM 可以做什么

ASM 是一个 Java 字节码操控框架。它能被用来动态生成类或者增强既有类的功能。ASM 可以直接产生二进制 class 文件,也可以在类被加载入 Java 虚拟机之前动态改变类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。

实践

目标

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
29
package com.asm;

public class Music {

public void run() {
// 编译期在这里插入代码 System.out.println("asm insert before");
System.out.println("this is run");
// 编译期在这里插入代码 System.out.println("asm insert after");
}

public int getValue() {
System.out.println("this is run");
// 编译期在 return 语句之前插入 System.out.println("insert before return");
return 1;
}

public void put(String value) {
// 注意方法 desc
}

private void add(String value, Thread thread) {
// 注意方法 desc
}

protected Music fake(int[] nums, String[] values) {
// 注意方法 desc
return null;
}
}

实现如上代码注释的目标,在固定代码的前后,以及 return 语句之前插入逻辑,这基本上就可以满足实际的需求了。下面看看如何实现。

依赖

这里直接下载 asm-6.0.jar 文件放到lib 目录在依赖配置里添加即可。

实现

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 class App {

private static Music music;

private static final String PATH = "./out/production/JavaArt/com/asm/";

public static void main(String[] args) {


try {
ClassReader classReader = new ClassReader("com.asm.Music");
//
ClassWriter writer = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassVisitor visitor = new MyVisitor(writer);
classReader.accept(visitor, ClassReader.EXPAND_FRAMES);

byte[] result = writer.toByteArray();

File file = new File(PATH + "Music.class");
FileOutputStream outputStream = new FileOutputStream(file);
outputStream.write(result);
outputStream.close();


} catch (IOException e) {
e.printStackTrace();
}
}

这里都是常规操作,ClassReader 读取内容,产生事件,接收一个 Visitor 进行对事件做特殊操作,ClassWriter 最终再次消费。从编译路径读取要修改的Class 文件,通过 ASM 的访问者模式 API 进行操作,然后将操作完的结果再次覆写回去。这里重点看一下 MyVisitor 的实现。

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
private static class MyVisitor extends ClassVisitor {

MyVisitor(ClassVisitor cv) {
super(Opcodes.ASM6, cv);
}

@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
System.out.println("=====================");
System.out.println("acce== " + access);
System.out.println("name== " + name);
System.out.println("desc== " + desc);
System.out.println("sign== " + signature);
System.out.println("=====================");

MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);

if (name.equals("run")) {
mv = new MyMethodVisitor(Opcodes.ASM6, mv);
}

if (name.equals("getValue")) {
mv = new MyMethodVisitorWithReturn(Opcodes.ASM6, mv);
}

return mv;
}
}
asm API

asm 的 api 常规的有两种使用方式,树形和访问者模式,这里只说访问者模式。

ClassVistor 是一个抽象类,这就是 asm API 的风格,对于类、方法提供了一系列的 XXXVisitor 抽象类,开发者通过继承这些抽象类或者他的子类,实现特定的方法,在这些方法里可以获取到关于大量关于这个类的信息,有了这些信息,就可以操纵这个类了。比如这里的visitMethod方法,我们可以看一下日志:

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
29
30
31
32
33
34
35
36
=====================
acce== 1
name== <init>
desc== ()V
sign== null
=====================
=====================
acce== 1
name== run
desc== ()V
sign== null
=====================
=====================
acce== 1
name== getValue
desc== ()I
sign== null
=====================
=====================
acce== 1
name== put
desc== (Ljava/lang/String;)V
sign== null
=====================
=====================
acce== 2
name== add
desc== (Ljava/lang/String;Ljava/lang/Thread;)V
sign== null
=====================
=====================
acce== 4
name== fake
desc== ([I[Ljava/lang/String;)Lcom/asm/Music;
sign== null
=====================

和一开始定义的 Music.java 文件对比一下,对于这些字段及其含义应该很容易理解了。init 方法就是默认构造函数的名字。

  • accc 是方法的访问控制符的定义;
  • name 就是方法名,
  • desc 就是方法签名,简单来说就是方法参数和返回值的特定字符串。可以看到规律,
    • V 就是代表返回值时 void;
    • I 是 int;
    • 返回值如果是特定的类,需要些完整包名,同时以 L 打头
  • 括号内就是方法参数
    • 没有的话就什么都不写
    • 有的话,还是以 L 打头的类完整包名
    • 对于数组以 [ 打头,
    • 多个参数之间用分号;进行分隔,需要注意的是,即便只有一个参数,也要写分号

总结如下表

注意 boolean 类型是 Z,以后在字节码里看到 Z 可不要一脸懵逼哦😯。还有 long 也是比价特殊,对应类型是 J。

看完这些,再回到上面的代码里,可以看到对于 run 和 getValue 方法,返回了特定的 MyMethodVisitor ,而不是调用父类的。再次重申一下,这就是 asm Visitor 模式的 API 使用方式

下面就来看看,对于这两个方法,做了什么处理。

run 方法的 Visitor
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
29
30
31
32
33
34
35
36
37
38
39
40
private static class MyMethodVisitor extends MethodVisitor {

MyMethodVisitor(int api, MethodVisitor mv) {
super(api, mv);
}

@Override
public void visitCode() {
super.visitCode();
System.out.println("start hack before");
hack(mv, "asm insert before");
}


@Override
public void visitInsn(int opcode) {
if (opcode == Opcodes.RETURN) {
System.out.println("start hack after");
hack(mv, "asm insert after");
}
super.visitInsn(opcode);
}
}

private static void hack(MethodVisitor mv, String msg) {
mv.visitFieldInsn(
Opcodes.GETSTATIC,
Type.getInternalName(System.class),
"out",
Type.getDescriptor(PrintStream.class)
);
mv.visitLdcInsn(msg);
mv.visitMethodInsn(
Opcodes.INVOKEVIRTUAL,
Type.getInternalName(PrintStream.class),
"println",
"(Ljava/lang/String;)V",
false
);
}
  • visitCode 当一个方法开始被访问时调用,因此在这里插入了第一个目标代码
  • visitInsn 当一个没有参数的方法的每一条指令被执行的时候,就会调用这个方法,这里当执行的方法返回这条指令时(注意是方法自身返回,可以理解为从方法调用栈弹出)进行拦截,在其之前插入我们的逻辑。具体的插入逻辑都在 hack 方法里。这么一长串代码实现的功能类似如下:
1
2
3
private static void hack(String msg) {
System.out.println(msg);
}

是的,就是这么简单。其实这也是使用 ASM 进行 AOP 最难也最最核心的地方。在哪个类做 hack,在哪个方法内做 hack,在方法的哪个位置做 hack,ASM 发展时至今日已经提供了非常多的方法和 API,可以供开发者调用,但是如何用字节码实现一些特定逻辑,就比较难了。这里说难呢,其实也不难,我们可以借助类似asm-bytecode-outline这样的插件非常方便的帮助我们生成 java 代码对应的字节码。上面的 hack 方法其实就是用这个插件生成的。

getValue 方法的 MethodVisitor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
private static class MyMethodVisitorWithReturn extends MethodVisitor {

public MyMethodVisitorWithReturn(int api, MethodVisitor mv) {
super(api, mv);
}

@Override
public void visitInsn(int opcode) {
System.out.println("opcode==" + opcode);
if (opcode == Opcodes.IRETURN) {
hack(mv, "insert before return");
}
super.visitInsn(opcode);
}
}

这里唯一需要关注的一点就是 hack 结点的获取,由于这个方法有返回值了,因此这个 return 拦截的 code 值需要改变为 Opcodes.IRETURN。 这里的 I 是不是似曾相识,其实就是参数类型。

验证结果

好了,写了半天,到底有没有生效呢?我们可以运行一下,看看效果。

1
2
3
4
5
6
7
public static void main(String[] args) {

... 插桩过程 ...

music = new Music();
music.run();
}

输出:

1
2
3
asm insert before
this is run
asm insert after

同时可以打开 Music.class 文件验证一下

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
package com.asm;

public class Music {
public Music() {
}

public void run() {
System.out.println("asm insert before");
System.out.println("this is run");
System.out.println("asm insert after");
}

public int getValue() {
System.out.println("this is run");
System.out.println("insert before return");
return 1;
}

public void put(String value) {
}

private void add(String value, Thread thread) {
}

protected Music fake(int[] nums, String[] values) {
return null;
}
}

完美,这就是 asm 插桩的实现。

这里在说一下关于 hack 结点(也就是插桩位置)的获取。这里我们也可以使用 asm-commons.jar 包里提供的 AdviceAdapter 这样的类更加方便的获取到 hack 结点。其本质也是按 visitor 方法执行的顺序进行了二次封装,提供了诸如 onMethodEnter/onMethodExit 这样对开发者更友好的 API,方便我们更加关注于具体 hack 代码的生成,而不是纠结于 hack 结点的获取。

总结

在为 AOP 编程中最被推崇的 asm ,对于开发者来说,可以借助第三方插件帮我们实现插桩代码后,似乎已经变得非常简单。

但是我们想一下,插件实现的插桩代码是不是有执行效率问题,是不是有潜在 bug,是不是完全符合我们的需求。而要解决这些问题,依旧需要我们去了解 虚拟机是如何加载字节码的,字节码中的代码又有怎样的规则,GETSTATIC,INVOKEVIRTUAL,pop,ALOAD_0……,这些指令到底是什么意思。想要真正用好 asm ,发挥其最大的威力,不仅要知其然,还要知其所以然。

最后, 《深入理解 Java 虚拟机》真是一本值得读很多很多次的书。

引用

asm 官方网站(文档还是官方的稳)

asm 仓库全家桶

AOP 的利器:ASM 3.0 介绍

深入字节码 – 使用 ASM 实现 AOP

美团热更方案ASM实践

加个鸡腿呗.