背景
前段时间在项目中看到有使用了字节码技术,便想着系统的了解下这块的知识,所以查阅了些资料,简单梳理总结下。之前也有同事分享过解构java文件的字节码文章,本篇就不再对java字节码本身做探讨,仅对asm和Javassist做简单介绍。
字节码简介:
字节码(Bytecode)是一种包含执行程序、由一序列 op 代码/数据对 组成的二进制文件。字节码是一种中间码,它比机器码更抽象,需要直译器转译后才能成为机器码的中间代码。
通常情况下它是已经经过编译,但与特定机器码无关。字节码通常不像源码一样可以让人阅读,而是编码后的数值常量、引用、指令等构成的序列。
字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。
字节码的典型应用为Java bytecode。字节码在运行时通过JVM(JAVA虚拟机)做一次转换生成机器指令,因此能够更好的跨平台运行。
使用场景:
由于字节码增强可以在完全不侵入业务代码的情况下植入代码逻辑,所以可以用它来做一些酷酷的事,比如下面的:
- cglib代理;
- 热部署;
- 调用链跟踪埋点;
- 动态插入log;
- 测试代码覆盖率跟踪;
- …
用字节码增强实现AOP:
AOP是我们实际业务场景中比较常用的思想,主要的实现有cglib,Aspectj,Javassist,java proxy等。现在,我们也手动自己用字节码来实现一下AOP。
ASM实现AOP:
ASM简介:
ASM字节码处理框架是用Java开发的而且使用基于访问者模式生成字节码及驱动类到字节码的转换,通俗的讲,它就是对class文件的CRUD,经过CRUD后的字节码可以转换为类。ASM的解析方式类似于SAX解析XML文件,它综合运用了访问者模式、职责链模式、桥接模式等多种设计模式,相对于其他类似工具如BCEL、SERP、Javassist、CGLIB,它的最大的优势就在于其性能更高,其jar包仅30K。Hibernate和Spring都使用了cglib代理,而cglib本身就是使用的ASM,可见ASM在各种开源框架都有广泛的应用。
准备工作
我们定义一个Base.java测试类,仅有一个run()方法和一个main()方法,我们的目标是在run()方法前后利用asm织入自定义类logUtil.java中的静态方法:
public class Base {
public void run() {
System.out.println(" run ");
}
public static void main(String[] args) {
Base base = new Base();
base.run();
}
}
logUtil.java工具类
public class LogUtil {
public static void before(){
System.out.println(" before log some thing...");
}
public static void after(){
System.out.println(" after log some thing...");
}
}
ASM动态修改操作流程:
在 ASM 中,提供了一个ClassReader
类,这个类可以直接由字节数组或由 class 文件间接的获得字节码数据,它能正确的分析字节码,构建出抽象的树在内存中表示字节码。它会调用accept方法,这个方法接受一个实现了ClassVisitor
接口的对象实例作为参数,然后依次调用 ClassVisitor接口的各个方法。字节码空间上的偏移被转换成 visit 事件时间上调用的先后,所谓 visit 事件是指对各种不同 visit 函数的调用,ClassReader
知道如何调用各种 visit 函数。在这个过程中用户无法对操作进行干涉,所以遍历的算法是确定的,用户可以做的是提供不同的 Visitor 来对字节码树进行不同的修改。ClassVisitor
会产生一些子过程,比如visitMethod会返回一个实现MethordVisitor接口的实例,visitField会返回一个实现FieldVisitor接口的实例,完成子过程后控制返回到父过程,继续访问下一节点。因此对于ClassReader
来说,其内部顺序访问是有一定要求的。
各个 ClassVisitor
通过职责链模式,可以非常简单的封装对字节码的各种修改,而无须关注字节码的字节偏移,因为这些实现细节对于用户都被隐藏了,用户要做的只是覆写相应的 visit 函数。
ClassAdapter
类实现了 ClassVisitor
接口所定义的所有函数,当新建一个 ClassAdapter
对象的时候,需要传入一个实现了 ClassVisitor
接口的对象,作为职责链中的下一个访问者 (Visitor),这些函数的默认实现就是简单的把调用委派给这个对象,然后依次传递下去形成职责链。当用户需要对字节码进行调整时,只需从ClassAdapter
类派生出一个子类,覆写需要修改的方法,完成相应功能后再把调用传递下去。这样,用户无需考虑字节偏移,就可以很方便的控制字节码。
ASM 的最终的目的是生成可以被正常装载的 class 文件,因此其框架结构为客户提供了一个生成字节码的工具类 —— ClassWriter
。它实现了ClassVisitor接口,而且含有一个toByteArray()函数,返回生成的字节码的字节流,将字节流写回文件即可生产调整后的 class 文件。一般它都作为职责链的终点,把所有 visit 事件的先后调用(时间上的先后),最终转换成字节码的位置的调整(空间上的前后)。
以上过程,可以简单总结如下:
ClassReader
(读取)→ ClassVisitor
(链式修改)→ ClassWriter
→ (保存写入)
ASM织入:
首先看入口类Generator.java
这里是利用ASM提供的API,读取并修改字节码后输出的使用示例,重点在MyClassAdapter和MyClassAdapter中
public class Generator {
public static void main(String[] args) throws Exception {
//读取
ClassReader classReader = new ClassReader(Base.class.getName());
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
//处理
ClassAdapter classAdapter = new MyClassAdapter(classWriter);
classReader.accept(classAdapter, ClassReader.SKIP_DEBUG);
byte[] data = classWriter.toByteArray();
//输出
File f = new File("D:/studyspace/***/bytecode/Base.class");
FileOutputStream fout = new FileOutputStream(f);
fout.write(data);
fout.close();
System.out.println("now generator cc success!!!!!");
}
}
MyClassAdapter.java
在ClassAdapter 中指定,仅在遍历到run()方法时进行操作。
public class MyClassAdapter extends ClassAdapter {
public MyClassAdapter(ClassVisitor classVisitor) {
super(classVisitor);
}
@Override
public MethodVisitor visitMethod(final int access, final String name,
final String desc, final String signature, final String[] exceptions) {
MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
// 仅对于 "run" 方法 施加操作
if (name.equals("run") && mv != null) {
// 使用自定义 MyMethodAdapter,实际改写方法内容
mv = new MyMethodAdapter(mv);
}
return mv;
}
}
MyMethodAdapter.java
在MethodAdapter 中指定需要插入的方法。api的使用可以翻阅其官方文档。
public class MyMethodAdapter extends c{
public MyMethodAdapter(MethodVisitor methodVisitor) {
super(methodVisitor);
}
/**
* 它会在ASM开始访问某一个方法的Code区时被调用,重写visitCode方法,将AOP中的前置逻辑就放在这里
*/
@Override
public void visitCode() {
//织入自定义的log静态方法
mv.visitCode();
visitMethodInsn(Opcodes.INVOKESTATIC, "com/lx/soil/bytecode/LogUtil", "before", "()V");
}
/**
* 每当ASM访问到无参数指令时,都会调用visitInsn方法。我们判断了当前指令是否为无参数的“return”指令
* 如果是就在它的前面添加一些指令,也就是将AOP的后置逻辑放在该方法中。
* 方法在返回之前,记录log
*
* @param opcode
*/
@Override
public void visitInsn(int opcode) {
if ((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN) || opcode == Opcodes.ATHROW) {
visitMethodInsn(Opcodes.INVOKESTATIC, "com/lx/soil/bytecode/LogUtil", "after", "()V");
}
mv.visitInsn(opcode);
}
}
字节码修改后运行结果:
logUtil.java中的方法已经成功被织入,就和我们使用的AOP效果一样,而且对业务代码完全无侵入。
附:
ASM中各个api操作时序图:
使用Javassist实现AOP:
Javassist简介
Javassist 是一个开源的分析、编辑和创建Java字节码的类库。其主要的优点,在于简单,而且快速。直接使用 java 编码的形式,而不需要了解虚拟机指令,就能动态改变类的结构,或者动态生成类。相对于ASM,Javassist的API要更加简单易懂一些。
与其他字节码编辑类库不同的是,Javassist提供了两个层次的API:源码级别和字节码级别。如果用户使用源码级别的API,可以在不了解Java字节码规范的情况下编辑类文件,整个源码级别的API按照Java语言风格进行设计。你甚至可以将源文本插入到指定的字节码中,Javassist会将源文本进行即时编译。另外一方面,字节码级别的API允许用户像使用其他字节码编辑类库一样编辑类文件
Javassist 中最为重要的是 ClassPool,CtClass ,CtMethod 以及 CtField 这几个类。
- ClassPool:一个基于 Hashtable 实现的 CtClass 对象容器,其中键是类名称,值是表示该类的 CtClass对象
- CtClass:CtClass 表示类,一个 CtClass (编译时类)对象可以处理一个 class 文件,这些 CtClass 对象可以从 ClassPool 获得。
- CtMethods:表示类中的方法。
- CtFields :表示类中的字段。
下面使用Javassist实现和上面asm一样的功能:
public class App {
public static void main(String[] args) throws NotFoundException, CannotCompileException, IllegalAccessException, InstantiationException, IOException {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get(Base2.class.getName());
CtMethod m = cc.getDeclaredMethod("run");
m.insertBefore("{ com.***.bytecode.javassist.LogUtil.before();}");
m.insertAfter("com.***.bytecode.javassist.LogUtil.after();");
cc.writeFile("D:/workspace/bytecode");
new Base2().run();
}
}
可以看到javassist实现AOP的代码比asm的实现简洁了许多,其性能稍弱于ASM,但比反射要高很多,综合性价比还是挺高的。
动态修改字节码:
上文中我们已经自己动手实现了简陋的AOP。显然,我们上面asm和javassist对字节码的操作,都是在静态的增强class文件,即只能对还未加载到jvm中的类进行增强,如果字节码的增强发生在jvm加载完类之后,那么jvm是不会在运行时自己动态重载类的。如果我们需要在jvm加载完之后对类进行增强,需要怎么做呢?
Byte Buddy会是一个不错的选择。
Byte Buddy是一个代码生成和操作库,用于在Java应用程序运行时创建和修改Java类,无需编译器的帮助。与Java类库附带的代码生成实用程序不同,Byte Buddy允许创建任意类,并且不限于实现用于创建运行时代理的接口。此外,Byte Buddy提供了一个方便的API,可以使用Java代理或在构建过程中手动更改类。
引入依赖
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy</artifactId>
<version>1.8.0</version>
</dependency>
<dependency>
<groupId>net.bytebuddy</groupId>
<artifactId>byte-buddy-agent</artifactId>
<version>1.8.0</version>
</dependency>
使用:
public static void main(String[] args) {
//1. 在修改class前调用
Service3.run();
//2.使用javassist修改class
this.javassistReplace();
//3.重载
ByteBuddyAgent.install(); //【1】
new ByteBuddy() //【2】
.redefine(Service3.class)
.name(Service3.class.getName())
.make()
.load(Service3.class.getClassLoader(), ClassReloadingStrategy.fromInstalledAgent());
System.out.println("jvm重新加载class");
System.out.println("再次调用业务方法");
Service3.run();
}
运行结果:
可以看到,我们成功在jvm运行期间对Service3.class文件进行了修改,并且让jvm重载了修改后的class。
在上面的代码中,我们仍旧使用javassist修改class文件,仅调用ByteBuddy重载class,核心API部分就是【1】【2】,API非常简单,使用者完全不用知道内部逻辑就可以完成class的重载。
其实ByteBuddy本身也支持对字节码进行修改,不过api没有javassist的简洁,使用稍显复杂,更多信息,详见其官网:
Byte Buddy – runtime code generation for the Java virtual machine
除了byte buddy,instrument也可以实现动态替换class文件。 instrument是 JVM 提供的一个可以修改已加载类文件的类库,专门为Java语言编写的插桩服务提供支持。它需要依赖JVMTI的Attach API机制实现。1.6以前,instrument 只能在 JVM 刚启动开始加载类时生效,之后,instrument更是支持了在运行时对类定义的修改。这里不再对instrument做更多介绍。
总结:
字节码增强技术可以在不侵入业务代码的情况下植入想要实现的独立逻辑,借由这种特性,可以做到很多酷酷的事。我们可以利用字节码增强技术让代码变得更简洁,但同时也要注意,不能过度使用字节码增强,过于分散的逻辑,一不小心就会让后期排查问题变成一场噩梦。
参考:
https://run-zheng.github.io/2019/11/12/【译】javassist使用指南一/
https://tech.meituan.com/2019/09/05/java-bytecode-enhancement.html