文章目录
一. 字节码增强技术
通过对字节码结构的学习,这为我们了解字节码增强技术的实现打下了一定基础。所谓字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码的技术。这种技术在 AOP(面向切面编程)、框架开发、性能监控等方面有着广泛的应用。本文将介绍字节码操作库。
1. ASM
本章采用 JDK1.8 编译环境
对于需要手动操纵字节码的需求,可以使用 ASM,它可以直接生成。
1.1. 介绍
ASM 是一种通用的 Java 字节码操作和分析框架。它可以直接生产 .class 字节码文件,也可以在类被加载入 JVM 之前动态修改类行为(如下图所示)。ASM 的应用场景有 AOP(Cglib 就是基于 ASM)、热部署、修改其他 jar 包中的类。当然,设计到如此底层的步骤,实现起来也比较麻烦。接下来,将介绍 ASM 的两种 API,并用 ASM 来实现一个比较粗糙的 AOP。
1.2. ASM API
1.2.1. 核心 API
ASM Core API 可以类比解析 XML 文件中的 SAX 方式,不需要把这个类的整个结构读出来,就可以用流式来处理字节码文件。好处是非常节省内存,但是编程难度较大。然而出于性能考虑,一般情况下编程都使用 Core API。在 Core API 中有以下几个关键类:
- ClassReader:用于读取已经编译好的 .class 文件
- ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
- 各种 Visitor 类:如上所述,CoreAPI 根据字节码从上到下依次处理,对于字节码文件不同的区域有不同的 Visitor,比如用于访问方法的 MethodVisitor、用于访问类变量的 FieldVisitor、用于访问注解的 AnnotationVisitor 等。
为了实现 AOP, 重点要用的是 MethodVisitor。
1.2.2 树形 API
ASM Tree API 可以类比解析 XML 文件中的 DOM 方式,把整个类的结构读取到内存中,缺点是消耗内存多,但是编程比较简单。
TreeAPI 通过各种 Node 类来映射字节码的各个区域,类比 DOM 节点,就可以很好地理解这种编程方式。
1.3. 简单实现 AOP
利用 ASM 的 CoreAPI 来增强类。这里不纠结于 AOP 的专业名词如切片、通知,只实现在方法调用前、后增加逻辑,通俗易懂且方便理解。
首先定义需要被增强的 Base 类:
public class Base {
public void process() {
System.out.println("process");
}
}
为了利用 ASM 实现 AOP,需要定义两个类:一个是 MyClassVisitor 类,用于对字节码的 visit 以及修改;另一个是 Generator 类,在这个类中定义 ClassReader 和 ClassWriter,其中的逻辑是,ClassReader 读取字节码,然后交由 MyClassVisitor 类处理,处理完成后由 ClassWriter 写字节码并讲究的字节码替换掉。 Generator 类较为简单,先看一下它的实现,如下所示,然后重点解释 MyClassVisitor 类。
import jdk.internal.org.objectweb.asm.ClassReader;
import jdk.internal.org.objectweb.asm.ClassVisitor;
import jdk.internal.org.objectweb.asm.ClassWriter;
public class Generator {
public static void main(String[] args) throws IOException {
ClassReader classReader = new ClassReader("code/Base");
ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS);
ClassVisitor classVisitor = new MyClassVisitor(classWriter);
classReader.accept(classVisitor, ClassReader.SKIP_FRAMES);
byte[] bytes = classWriter.toByteArray();
File f = new File("target/classes/code/Base.class");
FileOutputStream fos = new FileOutputStream(f);
fos.write(bytes);
fos.close();
System.out.println("done");
}
}
MyClassVisitor 继承自 ClassVisitor,用于对字节码的观察。它还包含一个内部类 MyMethodVisitor,继承自 MethodVisitor 用于对类内方法的观察,它的整体代码如下:
import jdk.internal.org.objectweb.asm.ClassVisitor;
import jdk.internal.org.objectweb.asm.MethodVisitor;
import jdk.internal.org.objectweb.asm.Opcodes;
public class MyClassVisitor extends ClassVisitor implements Opcodes {
public MyClassVisitor(ClassVisitor cv) {
super(ASM5, cv);
}
/**
* 访问类的标头
* @param version 类版本。次要版本存储在 16 个最高有效位中, 以及 16 个最低有效位中的主版本。
* @param access 类的访问标志(参见 Opcodes)。此参数还指示 该类已弃用 Opcodes.ACC_DEPRECATED 或 A Record Opcodes.ACC_RECORD。
* @param name 类的内部名称(参见 Type.getInternalName())。
* @param signature 此类的签名。如果类不是 generic 的 one,并且不扩展或实现泛型类或接口。
* @param superName 超类的内部 of name (参见 Type.getInternalName())。 对于接口,超类是 Object。可以为 null,但仅适用于 Object 类。
* @param interfaces 类接口的内部名称(参见 Type.getInternalName())。可能为 null。
*/
@Override
public void visit(int version, int access, String name, String signature,
String superName, String[] interfaces) {
cv.visit(version, access, name, signature, superName, interfaces);
}
/**
* 访问类的方法。每次调用此方法时,它都必须返回一个新的 MethodVisitor 实例(或 null),即,它不应返回一个先前的 返回的访客。
* @param access 方法的访问标志(参见 Opcodes)。此参数还指示 该方法是合成的和/或已弃用的。
* @param name 方法的名称。
* @param desc 方法的描述符。
* @param signature 方法的签名。
* @param exceptions 方法的异常类的内部名称
* @return 一个对象来访问方法的字节码,如果这个类是 null。 访客对访问此方法的代码不感兴趣。
*/
@Override
public MethodVisitor visitMethod(int access, String name, String desc,
String signature, String[] exceptions){
MethodVisitor mv = cv.visitMethod(access, name, desc, signature, exceptions);
if (!name.equals("<init>") && mv != null) {
mv = new MyMethodVisitor(mv);
}
return mv;
}
class MyMethodVisitor extends MethodVisitor {
public MyMethodVisitor(MethodVisitor mv) {
super(Opcodes.ASM5, mv);
}
/**
* 开始访问方法的代码
*/
@Override
public void visitCode() {
super.visitCode();
/*
访问现场指令
参数:
opcode- 要访问的 ORDER 类型的操作码。
owner- 字段的 owner 类的内部名称。
name- 字段的名称。
descriptor- 字段的描述符。
*/
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream");
/*
访问 LDC 指令
参数:
value- 要在堆栈上加载的常量。
*/
mv.visitLdcInsn("start");
/*
访问方法指令
参数:
opcode- 要访问的 ORDER 类型的操作码。
owner- 方法的 owner 类的内部名称。
name- 方法的名称。
descriptor- 方法的描述符。
isInterface- 如果方法的 owner 类是一个接口。
*/
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false);
}
/**
* 访问零操作数指令。
* @param opCode 要访问的操作码。
*/
@Override
public void visitInsn(int opCode) {
if ((opCode >= Opcodes.IRETURN) && (opCode <= Opcodes.RETURN)
|| opCode == Opcodes.ATHROW) {
mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream");
mv.visitLdcInsn("end");
mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintSystem", "println", "(Ljava/lang/String;)V", false);
}
mv.visitInsn(opCode);
}
}
}
利用这个类就可以实现对字节码的修改。详细解读其中的代码,对字节码做修改的步骤是:
- 首先通过 MyClassVisitor 类中的 visitMethod 方法,判断当前字节码读到哪一个方法了。跳过构造方法
<init>
后,将需要被增强的方法交给内部类 MyMethodVisitor 来进行处理。 - 接下来,进入内部类 MyMethodVisitor 的 vistCode 方法,它会在 ASM 开始访问某一个方法的 Code 区时被调用,重写 visitCode 方法,将 AOP 中的前置逻辑就放在这里。MyMethodVisitor 继续读取字节码指令,每当 ASM 访问到无参数指令时,都会调用 MyMethodVisitor 中的 visitInsn 方法。我们判断了当前指令是否为无参数 “return” 指令,如果是在它前面添加一些指令,也就是将 AOP 的后置逻辑放在该方法中。
- 综上,重写 MyMethodVisitor 中的两个方法,就可以实现 AOP 了,而重写方法时就需要用 ASM 的写法,手动写入或者修改字节码。通过调用 methodVisitor 的 visitXXXInsn() 方法就可以实现字节码的插入,XXX 对于相应的操作码助记符类型,比如
mv.visitLdcInsn("end")
对应的操作码就是 Idc “end”,即将字符串 “end” 压入栈。完成这两个 visitor 类后,运行 Generator 中的 main 方法完成对 Base 类的字节码增强,增强后的结果可以在编译后的 target 文件夹中找到 Base.class 文件进行查看,可以看到反编译后的代码已经改变了。然后写一个测试类 MyTest,在其中 new Base(),并调用 base.process() 方法,可以看到下图所示的效果:
1.4. ASM 工具
对于新版本的 IDEA 存在问题,可以使用这个插件替代:Releases · kamiiiel/asm-intellij-plugin (github.com)
利用 ASM 手写字节码时,需要利用一系列 visitXXXInsn() 方法来写对应的助记符,然后通过 ASM 的语法转换为 visitXXXInsn() 这种写法。第一步将源码转化为助记符就已经够麻烦了,不熟悉字节码操作集合的话,需要我们将代码编译后再反编译,才能得到源代码对应的助记符。第二步利用 ASM 写字节码时,如何传参也很令人头疼。ASM 社区也知道这个问题,所以提供了工具 ASM ByteCode Outline。
安装后,右键选择 “Show Bytecode OutLine”,在新标签页中选择 “ASMified” 这个 tab,如下图所示,就可以看到这个类中的代码对应的 ASM 写法了。
2. Javassist
ASM 是在指令层次上操作字节码的,阅读上文后,给我们的直观感受是在指令层次上操作字节码的框架实现起来比较晦涩。故除此之外,我们再简单介绍另外一类框架:强调源代码层次操作字节码的框架 Javassist。
利用 Javassist 实现字节码增强时,可以无须关注字节码刻板的结构,其优点就在于编程简单。直接使用 java 编程的形式,而不需要了解虚拟机指令,就能动态改变类的结构或者动态生成类。其中最重要的是 ClassPool、CtClass、CtMethod、CtField 这四个类:
-
CtClass(compile-time Class):编译时类信息,它是一个 Class 文件在代码中的抽象表现形式,可以通过一个类的全限定名来获取一个 CtClass 对象,用来表示这个类。
-
ClassPool:从开发视角来看,ClassPool 是一张保存 CtClass 信息的 HashTable,key 为类名,value 为类名对应的 CtClass 对象。当我们需要对某个类进行修改时,就是通过
pool.getCtClass("className")
方法从 pool 中获取到相对应的 CtClass。 -
CtMethod、CtField:这两个比较好理解,对应的是类中的方法和属性。
了解这四个类后,我们可以写一个小 Demo 来展示 Javassist 简单、快速的特定。我们依然是对 Base 中的 process() 方法做增强,在方法调用前后分别输出 “start” 和 “end”。
首先我们引入相关依赖:
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.28.0-GA</version>
</dependency>
实现代码如下,我们需要做的就是从 pool 中获取到相应的 CtClass 对象和其中的方法,然后执行 method.insertBefore 和 insertAfter 方法,参数为要插入的 java 代码,再以字符串的形式传入即可,实现起来也极为简单。
public class JavassistTest {
public static void main(String[] args) throws NotFoundException, CannotCompileException, InstantiationException, IllegalAccessException {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("code.Base");
CtMethod m = cc.getDeclaredMethod("process");
m.insertBefore("{ System.out.println(\"start\"); }");
m.insertAfter("{ System.out.println(\"end\"); }");
Class c = cc.toClass();
Base h = (Base) c.newInstance();
h.process();
}
}
二. 运行时类的重载
2.1. 问题引出
如果不了解 Java 类加载机制,可以先查看:Java 类加载机制
上一章节重点介绍了两种不同类型的字节码操作框架,且都利用它们实现了较为粗糙的 AOP。其实,为了方便大家理解字节码增强技术,在上文中我们避重就轻将 ASM 实现 AOP 的过程分为了两个 main 方法:第一个是利用了 MyClassVisitor 对已编译好的 class 文件进行修改,第二个是 new 对象并调用。这期间并不涉及到 JVM 运行时对类的重加载,而是在第一个 main 方法中,通过 ASM 对已编译类的字节码进行替换,在第二个 main 方法中,直接使用已替换好的新类信息。另外在 Javassist 的实现中,我们也只加载了一次 Base 类,也不涉及到运行时重加载类。
如果我们在一个 JVM 中,先加载一个类,然后又对其进行字节码增强并重新加载会发生什么呢?模拟这种情况,只需要我们在上中 Javassist 的 Demo 中 main() 方法的第一行添加 Base b = new Base()
,即在增强前就先让 JVM 加载 Bean 类,然后在执行到 cc.toClass()
方法时会抛出错误,如下图所示。跟进 cc.toClass()
方法中,我们会发现它是在最后调用了 ClassLoader 的 navite 方法 defineClass() 时报错。也就是说,JVM 是不也允许在运行时动态重加载有一个类的。
显然,如果只能在类加载前对类进行强化,那字节码增强技术的使用场景就变得很窄了。我们期望的效果是:在一个持续运行并已经加载了所有类的 JVM 中,还能利用字节码增强技术对其中的类行为做替换并重新加载。为了模拟这种情况,我们将 Base 类做改写,在其中编写 main 方法,每五秒调用一次 process() 方法,在 process() 方法中输出一行 “process”。
我们的目的就是,在 JVM 运行中的时候,将 process() 方法做替换,在其前后分别打印 “start” 和 “end”。也就是在运行中时,每五秒打印的内容由 “process” 变为打印 “start process end”。那么如何解决 JVM 不允许运行时重加载类信息的问题呢?为了达到这个目的,我们接下来一一来介绍需要借助的 Java 类库。
public class Base {
public static void main(String[] args) {
String name = ManagementFactory.getRuntimeMXBean().getName();
String s = name.split("@")[0];
// 打印当前 pid
System.out.println("pid:" + s);
while (true) {
try {
Thread.sleep(5000L);
} catch (InterruptedException e) {
break;
}
process();
}
}
public static void process() {
System.out.println("process");
}
}
2.2. Instrument
Instrument 是 JVM 提供的一个可以修改已加载类的类库,专门为 Java 语言编写的插桩服务提供支持。它需要依赖 JVMTI 的 Attach API 机制实现,JVMTI 这一部分,我们将在下一小节进行介绍。在 JDK1.6 以前,instrument 只能在 JVM 刚启动开始加载类时生效,而在 JDK1.6 之后,instrument 支持在运行时对类定义的修改。要使用 instrument 的类修改功能,我们需要实现它提供的 ClassFileTransformer 接口,定义一个类文件转换器。接口中的 transform() 方法会在类文件被加载时调用,而在 transform 方法里,我们可以利用上文中的 ASM 或 Javassist 对传入的字节码进行改写或替换,生成新的字节码数组后返回。
我们定义一个实现了 ClassFileTransformer 接口的类 TestTransformer,依然在其中利用 Javassist 对 Base 类中的 process 方法进行增强,在前后分别打印 “start” 和 “end”,代码如下:
import java.lang.instrument.ClassFileTransformer;
public class TestTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.println("Transforming " + className);
try {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("code.Base");
CtMethod m = cc.getDeclaredMethod("process");
m.insertBefore("{ System.out.println(\"start\"); }");
m.insertAfter("{ System.out.println(\"end\"); }");
return cc.toBytecode();
} catch (Exception e){
e.printStackTrace();
}
return new byte[0];
}
}
现在有了 Transformer,那么它要如何注入到正在运行的 JVM 呢?还需要定义一个 Agent,借助 Agent 的能力将 Instrument 注入到 JVM 中。我们将在下一小节介绍 Agent,现在要介绍的是 Agent 中用到的另一个类 Instrumentation。在 JDK1.6 之后,Instrumentation 可以做启动后的 instrument、本地代码(Native Code)的 Instrument,以及动态改变 ClassPath 等等。我们可以向 Instrumentation 中添加上文中定义的 Transformer,并指定要被重加载的类,代码如下所示。这样,当 Agent 被 Attach 到一个 JVM 中时,就会执行类字节码替换并重载入 JVM 的操作。
2.3. JVMTI & Agent & Attach API
上一小节中,我们给出了 Agent 类的代码,追根溯源需要先介绍 JPDA(Java Platform Debugger Architecture)。如果 JVM 启动时开启了 JPDA,那么类是允许被重新加载的。在这种情况下,已被加载的旧版本类信息可以被卸载,然后重新加载新版本的类。正如 JPDA 名称中的 Debugger,JPDA 其实是一套用于调试 Java 程序的标准,任何 JDK 都必须实现该标准。
JPDA 定义了一整套完整的体系,它将调试体系分为三部分,并规定了三者之间的通信接口。三部分由低到高分别是 Java 虚拟机工具接口(JVMTI),Java 调试协议(JDWP)以及 Java 调试接口(JDI),三者之间关系图下图所示:
回归正题,我们可以借助 JVMTI 的一部分能力,帮助动态重载类信息。JVM TI(JVM TOOL INT ERFACE,JVM 工具接口)是 JVM 提供的一套对 JVM 进行操作的工具接口。通过 JVM TI,可以实现对 JVM 的多种操作,它通过接口注册各种事件钩子,在 JVM 事件触发事,同时触发预定义的钩子,以实现对各个 JVM 事件的相应,事件包含类文件信息、异常产生与捕获、线程启动和结束、进入和退出临界区、成员变量修改、GC 开始和结束、方法调用进入和退出、临界区竞争与等待、VM 启动与退出等等。
而 Agent 就是 JVMTI 的一种实现,Agent 有两种启动方式,一是随 Java 进程启动而启动,经常见到的 java -agentlib
就是这种方式; 二是运行时载入,通过 attach API,将模块(jar 包)动态地 Attach 到指定进程 id 的 Java 进程内。
Attach API 的作用是提供 JVM 进程间通信的能力,比如说我们为了让另外一个 JVM 进程把线上服务的线程 Dump 出来,会运行 jstack 或 jmap 的进程,并传递 pid 的参数,告诉它要对哪个进程进行线程 Dump,这就是 Attach API 做的事情。在下面,我们将通过 Attach API 的 loadAgennt() 方法,将打包好的 Agent jar 包动态 Attach 到目标 JVM 上。具体实现起来的步骤如下:
- 定义 Agent,并在其中实现 AgentMain 方法,如上一小节中定义的代码块中的 TestAgent 类。
- 然后将 TestAgent 类打成一个包含 MANIFSSET.MF 的 jar 包,其中 MANIFEST.MF 文件中将 Agent-Class 属性指定为 TestAgent 的全限定名,如下图所示:
- 最后利用 Attach API,将我们打包好的 jar 包 Attach 到指定的 JVM pid 上,代码如下:
public class Attacher {
public static void main(String[] args) throws IOException, AttachNotSupportedException, AgentLoadException, AgentInitializationException {
VirtualMachine vm = VirtualMachine.attach("1900");
vm.loadAgent("target/classes/Agent.jar");
}
}
- 由于在 MANIFEST.MF 中指定了 Agent-Class,所以在 Attach 后,目标 JVM 在运行时会走到 TestAgent 类中定义的 agentmain() 方法,而在这个方法中,我们利用 Instrumentation,将指定类的字节码通过定义的类转换器 TestTransformer 做了 Base 类的字节码替换(通过 Javassist),并完成了类的重新加载。由此,我们达成了 “在 JVM 运行时,改变类的字节码并重新载入类信息” 的目的。
以下为运行时重新载入类的效果:先运行 Base 中的 Main() 方法,启动一个 JVM,可以在控制台看到每隔五秒输出一次 “process”。接着执行 Attacher 的 main() 方法,并将上一个 JVM 的 pid 传入。此时回到上一个 main() 方法的控制台,可以看到现在每隔五秒输出 “process” 前后会分别输出 “start” 和 “end”,也就是说完成了运行时的字节码增强,并重新载入了这个类。
2.4. 使用场景
至此,字节码增强技术的可使用范围就不再局限于 JVM 加载类前了。通过上述几个类库,我们可以在运行时对 JVM 中的类进行修改并重载了。通过这种手段,可以做的事情就变得很多了:
- 热部署:不部署服务而对线上服务做修改,可以做打点、增加日志等操作。
- Mock:测试时对某个服务做 Mock。
- 性能诊断工具:比如 bTrace 就是利用 Instrument,实现无侵入地跟踪一个正在运行的 JVM,监控到类和方法级别的状态信息。
三、总结
字节码增强技术相当于是一把打开运行时 JVM 的钥匙,利用它可以动态地对运行中的程序做修改,也可以跟踪 JVM 运行中程序的状态。此外,我们平时使用的动态代理、AOP 也与字节码增强密切相关,它们实质上还是利用各种手段生成符合规范的字节码文件。综上所述,掌握字节码增强后可以高效地定位并快速修复一些棘手的问题(如线上性能问题、方法出现不可控的出入参需要紧急加日志等问题),也可以在开发中减少冗余代码,大大提高开发效率。