简述
本文内容将介绍 Java 字节码相关知识,以及如何通过 javaagent 技术加上 ASM 框架进行插桩。
本文提纲
- 字节码
- javaagent
- ASM 框架
- ASM + javaagent 插桩实战
系列文章:
1. JMCR 简介
2. JMCR 字节码插桩(一)
3. JMCR 字节码插桩(二)
4. JMCR 约束求解原理
5. JMCR 线程调度
一、Java 字节码
本节内容中的 Java 字节码介绍不会占用太多篇幅,若有兴趣,可以参考《深入理解Java虚拟机》。
对于一个 Java 编程人员,字节码这个概念一定不会陌生,作为 Java 语言和 机器码之间做翻译的中间语言,我们每一次编译一个 Java 类文件的时候都会生成一个 .class 文件。
而 .class 文件会被加载到虚拟机(JVM)内执行,这时我们的程序才运行起来。
首先来看一个字节码是什么样的,以HelloWorld编译后的字节码为例:
源代码:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello ByteCode!");
}
}
字节码本身就是一串字节流,如果使用 16进制编码的话,它看起来会是这样的 ——
Offset: 00 01 02 03 04 05 06 07 08 09 0A 0B 0C 0D 0E 0F
00000000: CA FE BA BE 00 00 00 34 00 1D 0A 00 06 00 0F 09 J~:>...4........
00000010: 00 10 00 11 08 00 12 0A 00 13 00 14 07 00 15 07 ................
00000020: 00 16 01 00 06 3C 69 6E 69 74 3E 01 00 03 28 29 .....<init>...()
00000030: 56 01 00 04 43 6F 64 65 01 00 0F 4C 69 6E 65 4E V...Code...LineN
00000040: 75 6D 62 65 72 54 61 62 6C 65 01 00 04 6D 61 69 umberTable...mai
00000050: 6E 01 00 16 28 5B 4C 6A 61 76 61 2F 6C 61 6E 67 n...([Ljava/lang
//...
更人性化一点的手段是使用 javap -v HelloWorld.class
命令进行查看,它将字节码按照其结构翻译成了英文:
字节码中主要包含下面几个部分
- 元信息,包括魔数(magic number)、Java版本号信息、类修饰符等
- 常量池,用于存放各种字符串常量信息
-
函数,包括函数的修饰符、函数操作栈大小、局部变量表大小、字节码指令、LineNumberTable等信息。
首先需要了解,Java的函数传参是通过栈实现的,然后我们主要关注一下 HelloWorld.class 中 main 函数的字节码指令——
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello ByteCode!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
第一条 getStatic #2,意味着把常量池中 2 号字符串(System.out)指向的变量值压入栈中
第二条 ldc #3,将常量池中3号字符串 Hello ByteCode!
的指针压入栈中
第三条 invokevirtual #4,意味着执行常量池中 4 号字符串(java/io/PrintStream.println:(Ljava/lang/String;)V)所指向的函数。println有一个参数,因此会从栈顶中取出字符串Hello ByteCode!
的指针,然后在栈顶取出函数执行者 Syste.out
。
阅读字节码可以帮我们打开新的大门,可以探寻一些编译器对于 Java 源码做的手脚,例如原始类型的拆箱装箱——
字节码文件是一个描述类的数据结构,有着严谨的结构,一定以 0x CAFEBABE 开头,然后是主版本号、次版本号,然后是类的描述等等,每一个类型都有着固定的大小,其偏移量可以通过确定的规则计算出来。 JVM 中的解释器通过顺序读取字节码指令,逐条进行解释,最终生成可执行的二进制文件。
思考一下,如果,我们在字节码被 JVM 解释之前,对其进行修改...
二、javaagent 技术
- 什么是javaagent
在 JDK 1.5 中,Java 引入了 java.lang.Instrument 包,该包提供了一些工具帮助开发人员在 Java 程序运行时,动态修改系统中的 Class 类型,而 javaagent 是其中的一个关键组件。这项技术多中 agentmain 多用于热部署,提供一个在运行时修改字节码并重新加载入虚拟机的功能;而 premain 则是在字节码在执行之前拦截并修改,再读入虚拟机中执行。
- javaagent premain 的使用
首先新建一个类,类中声明一个 premain
方法如下:
class Instrumentor{
public static void premain(String agentArgs, Instrumentation inst) {
inst.addTransformer(new ClassFileTransformer() {
public byte[] transform(ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer) throws IllegalClassFormatException {
return classfileBuffer;
}
});
}
}
这个类里面我们定义了premain函数,为 Inst 加上了一个字节码转换器,在一个字节码被加载如虚拟机之前,会执行一边 trasform 方法,这个方法中的 classFileBuffer 参数就是变化之前的字节码,而我们可以再函数里面进行一顿操作,返回的是转换后的,载入虚拟机的字节码,这里我们原封不动的返回,并没有做任何操作。
创建一个 MANIFEST.MF 文件,指定刚刚写好的Premain-Class
Manifest-Version: 1.0
Premain-Class: Instrumentor
Can-Redefine-Classes: true
Can-Retransform-Classes: true
Created-By: 1.8.0_101 (Oracle Corporation)
使用 IDEA 将这个 MANIFEST 打包成agent.jar
然后通过运行时加上 -javaagent agent.jar
参数即可。
三、ASM
现在我们已经有了字节码,可以使用 ASM框架 来操作和修改字节码。
ASM 库是一款基于 Java 字节码层面的代码分析和修改工具。ASM 可以直接生产二进制的 class 文件,也可以在类被加载入 JVM 之前动态修改类行为。Java class 被存储在严格格式定义的 .class 文件里,这些类文件拥有足够的元数据来解析类中的所有元素:类名称、方法、属性以及 Java 字节码(指令)。ASM 从类文件中读入信息后,能够改变类行为,分析类信息,甚至能够根据用户要求生成新类。
ASM 通过树这种数据结构来表示复杂的字节码结构,并利用 Push 模型来对树进行遍历,在遍历过程中对字节码进行修改。
在 ASM 中,提供了一个 ClassReader 类,这个类可以直接由字节数组或由 class 文件间接的获得字节码数据,它能正确的分析字节码,构建出抽象的树在内存中表示字节码。它会调用 accept 方法,这个方法接受一个实现了 ClassVisitor 接口的对象实例作为参数,然后依次调用 ClassVisitor 接口的各个方法。字节码空间上的偏移被转换成 visit 事件时间上调用的先后,所谓 visit 事件是指对各种不同 visit 函数的调用,ClassReader 知道如何调用各种 visit 函数。在这个过程中用户无法对操作进行干涉,所以遍历的算法是确定的,用户可以做的是提供不同的 Visitor 来对字节码树进行不同的修改。ClassVisitor 会产生一些子过程,比如 visitMethod 会返回一个实现 MethordVisitor 接口的实例,visitField 会返回一个实现 FieldVisitor 接口的实例,完成子过程后控制返回到父过程,继续访问下一节点。
下面来一个使用 ASM 生成字节码文件,并修改类的一些属性的例子:
import org.apache.xpath.compiler.OpCodes;
import org.objectweb.asm.*;
import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
public class HelloWorld implements Opcodes {
public static void main(String[] args) throws IOException {
ClassWriter cw = new ClassWriter(0);
MethodVisitor mv;
cw.visit(V1_7, ACC_PUBLIC + ACC_SUPER, "HelloWorld", null, "java/lang/Object", null);
cw.visitSource("HelloWorld.java", null);
//默认初始化构造器
{
mv = cw.visitMethod(ACC_PUBLIC, "<init>", "()V", null, null);
mv.visitCode();
Label l0 = new Label();
mv.visitLabel(l0);
mv.visitLineNumber(9, l0);
mv.visitVarInsn(ALOAD, 0);
mv.visitMethodInsn(INVOKESPECIAL, "java/lang/Object", "<init>", "()V");
mv.visitInsn(RETURN);
Label l1 = new Label();
mv.visitLabel(l1);
mv.visitLocalVariable("this", "LHelloWorld;", null, l0, l1, 0);
mv.visitMaxs(1, 1);
mv.visitEnd();
}
//public static void main方法
{
mv = cw.visitMethod(ACC_PUBLIC + ACC_STATIC, "main", "([Ljava/lang/String;)V", null, null);
mv.visitCode();
Label l0 = new Label();
mv.visitLabel(l0);
mv.visitLineNumber(12, l0);
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitLdcInsn("Hello Bytecode!");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V");
Label l1 = new Label();
mv.visitLabel(l1);
mv.visitLineNumber(13, l1);
mv.visitInsn(RETURN);
Label l2 = new Label();
mv.visitLabel(l2);
mv.visitLocalVariable("args", "[Ljava/lang/String;", null, l0, l2, 0);
mv.visitMaxs(2, 1);
mv.visitEnd();
}
cw.visitEnd();
byte[] code = cw.toByteArray();
File file = new File("HelloWorld.class");
FileOutputStream output = new FileOutputStream(file);
output.write(code);
output.close();
}
}
上述代码会生成一个 HelloWorld 字节码文件。内容与文章开头的例子一致。然后我们对这个字节码文件使用 ASM 再进行修改,在打印“Hello World”之前和之后,分别记录当前的时间,计算时间差并打印。
public class HelloWorld implements Opcodes {
public static void main(String[] args) throws IOException {
File file = new File("HelloWorld.class");
InputStream inputStream = new FileInputStream(file);
byte[] source = new byte[inputStream.available()];
int a = inputStream.read(source);
if (a == -1) {
System.err.println("文件读取问题");
System.exit(1);
}
ClassReader cr = new ClassReader(source);
ClassWriter cw = new ClassWriter(cr, ClassWriter.COMPUTE_FRAMES);
cr.accept(new ClassVisitor(ASM5, cw) {
@Override
public void visit(int version, int access, String name, String signature, String superName, String[] interfaces) {
super.visit(version, access, "HelloWorldInstrumented", signature, superName, interfaces);//更改类的名称为 HelloWorldInstrumented
}
@Override
public MethodVisitor visitMethod(int access, String name, String desc, String signature, String[] exceptions) {
MethodVisitor mv = super.visitMethod(access, name, desc, signature, exceptions);
mv = new AdviceAdapter(ASM5, mv, access, name, desc) {
@Overri![
](https://upload-images.jianshu.io/upload_images/8081626-b7627e062e0d0f11.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
de
public void visitMethodInsn(int opcode, String owner, String name, String desc) {
if (opcode == INVOKEVIRTUAL) {
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J");
mv.visitVarInsn(LSTORE, 1);
super.visitMethodInsn(opcode, owner, name, desc);
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;");
mv.visitMethodInsn(INVOKESTATIC, "java/lang/System", "currentTimeMillis", "()J");
mv.visitVarInsn(LLOAD, 1);
mv.visitInsn(LSUB);
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(J)V");
}
}
};
return mv;
}
}, ClassReader.EXPAND_FRAMES);
byte[] code = cw.toByteArray();
file = new File("HelloWorldInstrumented.class");
FileOutputStream output = new FileOutputStream(file);
output.write(code);
output.close();
}
}
转换后的字节码反编译结果如下:
具体详细的 ASM 的 Api 和使用的方法建议参考官网,而具体的细节,例如字节码指令的名称、方法的名称如果记不住的话,推荐一款 IDEA 的插件 —— ASM Bytecode Outline,安装后,可以右键查看类的字节码、以及如何通过ASM的方式构造这个类出来。
四、ASM + javaagent 插桩实战
其实在前面两个小节,我们已经分别讲述了两个工具的使用,接下来就是怎么结合的问题了。其实也很简单,在第二小节的javaagent示例中,在transform方法中直接将拦截到的字节流返回了,而第三小节中,ASM 框架的输入输出都是文件。不难想到,只需将 ASM 操作写到transform方法中,以参数为输入,输出到返回之中就行了。
public static void premain(String agentArgs, Instrumentation inst) {
inst.addTransformer((loader, className, classBeingRedefined, protectionDomain, classfileBuffer) -> {
ClassReader classReader = new ClassReader(classfileBuffer);
ClassWriter classWriter = new ClassWriter(classReader, ClassWriter.COMPUTE_FRAMES);
ClassVisitor myClassTransformer = new MyClassTransformer(classWriter); //自定义ClassVisitor
classReader.accept(myClassTransformer,ClassReader.EXPAND_FRAMES);
classfileBuffer = classWriter.toByteArray();
return classfileBuffer;
});
不妨尝试一下使用这个技术对于现有的程序进行插桩,达到每一次对于变量访问时,都会打印相关信息。
代码参考:https://github.com/tjuwhy/MyJMCR