【Java JVM】字节码编码
【Java JVM】字节码编码
Metadata
title: 【Java JVM】字节码编码
date: 2022-12-14 14:39
tags:
- 行动阶段/完成
- 主题场景/编程
- 笔记空间/KnowladgeSpace/ProgramSpace
- 细化主题/Java/JVM
categories:
- Java
keywords:
- Java/JVM
description: 字节码编码
简介
计算机是不能直接运行 java 代码的,必须要先运行 java 虚拟机,再由 java 虚拟机运行编译后的 java 代码。这个编译后的 java 代码,就是本文要介绍的 java 字节码。
- Java 代码间接翻译成字节码,储存字节码的文件再交由运行于不同平台上的 JVM 虚拟机去读取执行,从而实现一次编写,到处运行的目的。
- JVM 也不再只支持 Java,由此衍生出了许多基于 JVM 的编程语言,如 Groovy, Scala, Koltin 等等。
Java 字节码文件
class 文件本质上是一个以 8 位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑的排列在 class 文件中。jvm 根据其特定的规则解析该二进制数据,从而得到相关信息。
Class 文件采用一种伪结构来存储数据,它有两种类型:无符号数和表。
Class 文件的结构属性
示例
举例
collapse: closed
以文本的形式打开生成的 class 文件,内容如下:
cafe babe 0000 0034 0013 0a00 0400 0f09 0003 0010 0700 1107 0012 0100 016d 0100 0149 0100 063c 696e 6974 3e01 0003 2829 5601 0004 436f 6465 0100 0f4c 696e 654e 756d 6265 7254 6162 6c65 0100 0369 6e63 0100 0328 2949 0100 0a53 6f75 7263 6546 696c 6501 0009 4d61 696e 2e6a 6176 610c 0007 0008 0c00 0500 0601 0010 636f 6d2f 7268 7974 686d 372f 4d61 696e 0100 106a 6176 612f 6c61 6e67 2f4f 626a 6563 7400 2100 0300 0400 0000 0100 0200 0500 0600 0000 0200 0100 0700 0800 0100 0900 0000 1d00 0100 0100 0000 052a b700 01b1 0000 0001 000a 0000 0006 0001 0000 0003 0001 000b 000c 0001 0009 0000 001f 0002 0001 0000 0007 2ab4 0002 0460 ac00 0000 0100 0a00 0000 0600 0100 0000 0800 0100 0d00 0000 0200 0e
- 文件开头的 4 个字节 (“cafe babe”) 称之为
魔数
,唯有以 “cafe babe” 开头的 class 文件方可被虚拟机所接受,这 4 个字节就是字节码文件的身份识别。 - 0000 是编译器 jdk 版本的次版本号 0,0034 转化为十进制是 52, 是主版本号,java 的版本号从 45 开始,除 1.0 和 1.1 都是使用 45.x 外, 以后每升一个大版本,版本号加一。也就是说,编译生成该 class 文件的 jdk 版本为 1.8.0。
反编译字节码文件
使用到 java 内置的一个反编译工具 javap 可以反编译字节码文件, 用法:
javap <options> <classes>
其中<options>
选项包括:
-help --help -? 输出此用法消息
-version 版本信息
-v -verbose 输出附加信息
-l 输出行号和本地变量表
-public 仅显示公共类和成员
-protected 显示受保护的/公共类和成员
-package 显示程序包/受保护的/公共类
和成员 (默认)
-p -private 显示所有类和成员
-c 对代码进行反汇编
-s 输出内部类型签名
-sysinfo 显示正在处理的类的
系统信息 (路径, 大小, 日期, MD5 散列)
-constants 显示最终常量
-classpath <path> 指定查找用户类文件的位置
-cp <path> 指定查找用户类文件的位置
-bootclasspath <path> 覆盖引导类文件的位置
输入命令javap -verbose -p Main.class
查看输出内容:
collapse: closed
Classfile /E:/JavaCode/TestProj/out/production/TestProj/com/rhythm7/Main.class Last modified 2018-4-7; size 362 bytes MD5 checksum 4aed8540b098992663b7ba08c65312de Compiled from "Main.java" public class com.rhythm7.Main minor version: 0 major version: 52 flags: ACC_PUBLIC, ACC_SUPER Constant pool: #1 = Methodref #4.#18 #2 = Fieldref #3.#19 #3 = Class #20 #4 = Class #21 #5 = Utf8 m #6 = Utf8 I #7 = Utf8 <init> #8 = Utf8 ()V #9 = Utf8 Code #10 = Utf8 LineNumberTable #11 = Utf8 LocalVariableTable #12 = Utf8 this #13 = Utf8 Lcom/rhythm7/Main; #14 = Utf8 inc #15 = Utf8 ()I #16 = Utf8 SourceFile #17 = Utf8 Main.java #18 = NameAndType #7:#8 #19 = NameAndType #5:#6 #20 = Utf8 com/rhythm7/Main #21 = Utf8 java/lang/Object { private int m; descriptor: I flags: ACC_PRIVATE
public com.rhythm7.Main();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1
4: return
LineNumberTable:
line 3: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/rhythm7/Main;public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 7 0 this Lcom/rhythm7/Main;
}
SourceFile: "Main.java"
字节码文件信息
开头的 7 行信息包括: Class 文件当前所在位置,最后修改时间,文件大小,MD5 值,编译自哪个文件,类的全限定名,jdk 次版本号,主版本号。
然后紧接着的是该类的访问标志:ACC_PUBLIC, ACC_SUPER,访问标志的含义如下:
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为 Public 类型 |
ACC_FINAL | 0x0010 | 是否被声明为 final,只有类可以设置 |
ACC_SUPER | 0x0020 | 是否允许使用 invokespecial 字节码指令的新语义. |
ACC_INTERFACE | 0x0200 | 标志这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为 abstract 类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
ACC_SYNTHETIC | 0x1000 | 标志这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x2000 | 标志这是一个注解 |
ACC_ENUM | 0x4000 | 标志这是一个枚举 |
常量池
Constant pool
意为常量池。
常量池可以理解成 Class 文件中的资源仓库。主要存放的是两大类常量:字面量 (Literal) 和符号引用(Symbolic References)。字面量类似于 java 中的常量概念,如文本字符串,final 常量等,而符号引用则属于编译原理方面的概念,包括以下三种:
- 类和接口的全限定名 (Fully Qualified Name)
- 字段的名称和描述符号 (Descriptor)
- 方法的名称和描述符
不同于 C/C++, JVM 是在加载 Class 文件的时候才进行的动态链接,也就是说这些字段和方法符号引用只有在运行期转换后才能获得真正的内存入口地址。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建或运行时解析并翻译到具体的内存地址中。 直接通过反编译文件来查看字节码内容:
#1 = Methodref #4.#18
#4 = Class #21
#7 = Utf8 <init>
#8 = Utf8 ()V
#18 = NameAndType #7:#8
#21 = Utf8 java/lang/Object
第一个常量是一个方法定义,指向了第 4 和第 18 个常量。以此类推查看第 4 和第 18 个常量。最后可以拼接成第一个常量右侧的注释内容:
java/lang/Object."<init>":()V
这段可以理解为该类的实例构造器的声明,由于 Main 类没有重写构造方法,所以调用的是父类的构造方法。此处也说明了 Main 类的直接父类是 Object。 该方法默认返回值是 V, 也就是 void,无返回值。
标识字符 | 含义 |
---|---|
B | 基本类型 byte |
C | 基本类型 char |
D | 基本类型 double |
F | 基本类型 float |
I | 基本类型 int |
J | 基本类型 long |
S | 基本类型 short |
Z | 基本类型 boolean |
V | 特殊类型 void |
L | 对象类型,以分号结尾,如 Ljava/lang/Object; |
对于数组类型,每一位使用一个前置的[
字符来描述,如定义一个java.lang.String[][]
类型的维数组,将被记录为[[Ljava/lang/String;
方法表集合
在常量池之后的是对类内部的方法描述,在字节码中以表的集合形式表现,暂且不管字节码文件的 16 进制文件内容如何,我们直接看反编译后的内容。
code 内的主要属性为:
stack: 最大操作数栈,JVM 运行时会根据这个值来分配栈帧 (Frame) 中的操作栈深度, 此处为 1
locals: 局部变量所需的存储空间,单位为 Slot, Slot 是虚拟机为局部变量分配内存时所使用的最小单位,为 4 个字节大小。方法参数 (包括实例方法中的隐藏参数 this),显示异常处理器的参数 (try catch 中的 catch 块所定义的异常),方法体中定义的局部变量都需要使用局部变量表来存放。值得一提的是,locals 的大小并不一定等于所有局部变量所占的 Slot 之和,因为局部变量中的 Slot 是可以重用的。
args_size: 方法参数的个数,这里是 1,因为每个实例方法都会有一个隐藏参数 this
attribute_info: 方法体内容,0,1,4 为字节码 “行号”,该段代码的意思是将第一个引用类型本地变量推送至栈顶,然后执行该类型的实例方法,也就是常量池存放的第一个变量,也就是注释里的
java/lang/Object."":()V
, 然后执行返回语句,结束方法。LineNumberTable: 该属性的作用是描述源码行号与字节码行号 (字节码偏移量) 之间的对应关系。可以使用 -g:none 或 - g:lines 选项来取消或要求生成这项信息,如果选择不生成 LineNumberTable,当程序运行异常时将无法获取到发生异常的源码行号,也无法按照源码的行数来调试程序。
LocalVariableTable: 该属性的作用是描述帧栈中局部变量与源码中定义的变量之间的关系。可以使用 -g:none 或 -g:vars 来取消或生成这项信息,如果没有生成这项信息,那么当别人引用这个方法时,将无法获取到参数名称,取而代之的是 arg0, arg1 这样的占位符。 start 表示该局部变量在哪一行开始可见,length 表示可见行数,Slot 代表所在帧栈位置,Name 是变量名称,然后是类型签名。
类名
最后很显然是源码文件:
SourceFile: "Main.java"
字节码增强技术
从最直接操纵字节码的实现方式开始深入进行剖析
ASM
ASM 简介
ASM 是一个通用的 Java 字节码操作和分析框架。 它可以用于修改现有类或直接以二进制形式动态生成类。 ASM 提供了一些常见的字节码转换和分析算法,可以从中构建自定义复杂转换和代码分析工具。 ASM 提供与其他 Java 字节码框架类似的功能,但专注于性能。 因为它的设计和实现尽可能小而且快,所以它非常适合在动态系统中使用(但当然也可以以静态方式使用,例如在编译器中)。
对于需要手动操纵字节码的需求,可以使用 ASM,它可以直接生产 .class 字节码文件,也可以在类被加载入 JVM 之前动态修改类行为。
ASM 的应用场景有 AOP(Cglib 就是基于 ASM)、热部署、修改其他 jar 包中的类等。
ASM API
核心 API
ASM Core API 可以类比解析 XML 文件中的 SAX 方式,不需要把这个类的整个结构读取进来,就可以用流式的方法来处理字节码文件。好处是非常节约内存,但是编程难度较大。然而出于性能考虑,一般情况下编程都使用 Core API。在 Core API 中有以下几个关键类:
- ClassReader:用于读取已经编译好的. class 文件。
- ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
- 各种 Visitor 类:如上所述,CoreAPI 根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的 Visitor,比如用于访问方法的 MethodVisitor、用于访问类变量的 FieldVisitor、用于访问注解的 AnnotationVisitor 等。为了实现 AOP,重点要使用的是 MethodVisitor。
树形 API
ASM Tree API 可以类比解析 XML 文件中的 DOM 方式,把整个类的结构读取到内存中,缺点是消耗内存多,但是编程比较简单。TreeApi 不同于 CoreAPI,TreeAPI 通过各种 Node 类来映射字节码的各个区域,类比 DOM 节点,就可以很好地理解这种编程方式。
直接利用 ASM 实现 AOP
利用 ASM 的 CoreAPI 来增强类。这里不纠结于 AOP 的专业名词如切片、通知,只实现在方法调用前、后增加逻辑,通俗易懂且方便理解。首先定义需要被增强的 Base 类:其中只包含一个 process() 方法,方法内输出一行 “process”。增强后,我们期望的是,方法执行前输出 “start”,之后输出”end”。
为了利用 ASM 实现 AOP,需要定义两个类:一个是 MyClassVisitor 类,用于对字节码的 visit 以及修改;另一个是 Generator 类,在这个类中定义 ClassReader 和 ClassWriter,其中的逻辑是,classReader 读取字节码,然后交给 MyClassVisitor 类处理,处理完成后由 ClassWriter 写字节码并将旧的字节码替换掉。Generator 类较简单,我们先看一下它的实现,如下所示,然后重点解释 MyClassVisitor 类。
代码
collapse: closed
import org.objectweb.asm.ClassReader; import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.ClassWriter;
public class Generator {
public static void main(String[] args) throws Exception {ClassReader classReader = new ClassReader("meituan/bytecode/asm/Base"); ClassWriter classWriter = new ClassWriter(ClassWriter.COMPUTE_MAXS); ClassVisitor classVisitor = new MyClassVisitor(classWriter); classReader.accept(classVisitor, ClassReader.SKIP_DEBUG); byte[] data = classWriter.toByteArray(); File f = new File("operation-server/target/classes/meituan/bytecode/asm/Base.class"); FileOutputStream fout = new FileOutputStream(f); fout.write(data); fout.close(); System.out.println("now generator cc success!!!!!"); }
}
MyClassVisitor 继承自 ClassVisitor,用于对字节码的观察。它还包含一个内部类 MyMethodVisitor,继承自 MethodVisitor 用于对类内方法的观察,它的整体代码如下:
代码
collapse: closed
利用这个类就可以实现对字节码的修改。详细解读其中的代码,对字节码做修改的步骤是:
- 首先通过 MyClassVisitor 类中的 visitMethod 方法,判断当前字节码读到哪一个方法了。跳过构造方法
<init>
后,将需要被增强的方法交给内部类 MyMethodVisitor 来进行处理。 - 接下来,进入内部类 MyMethodVisitor 中的 visitCode 方法,它会在 ASM 开始访问某一个方法的 Code 区时被调用,重写 visitCode 方法,将 AOP 中的前置逻辑就放在这里。 MyMethodVisitor 继续读取字节码指令,每当 ASM 访问到无参数指令时,都会调用 MyMethodVisitor 中的 visitInsn 方法。我们判断了当前指令是否为无参数的 “return” 指令,如果是就在它的前面添加一些指令,也就是将 AOP 的后置逻辑放在该方法中。
- 综上,重写 MyMethodVisitor 中的两个方法,就可以实现 AOP 了,而重写方法时就需要用 ASM 的写法,手动写入或者修改字节码。通过调用 methodVisitor 的 visitXXXXInsn()方法就可以实现字节码的插入,XXXX 对应相应的操作码助记符类型,比如 mv.visitLdcInsn(“end”)对应的操作码就是 ldc “end”,即将字符串 “end” 压入栈。 完成这两个 visitor 类后,运行 Generator 中的 main 方法完成对 Base 类的字节码增强,增强后的结果可以在编译后的 target 文件夹中找到 Base.class 文件进行查看,可以看到反编译后的代码已经改变了。然后写一个测试类 MyTest,在其中 new Base(),并调用 base.process()方法,可以看到下图右侧所示的 AOP 实现效果:
import org.objectweb.asm.ClassVisitor; import org.objectweb.asm.MethodVisitor; import org.objectweb.asm.Opcodes;
public class MyClassVisitor extends ClassVisitor implements Opcodes {
public MyClassVisitor(ClassVisitor cv) {
super(ASM5, cv);
}
@Override
public void visit(int version, int access, String name, String signature,
String superName, String[] interfaces) {
cv.visit(version, access, name, signature, superName, interfaces);
}
@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 implements Opcodes { public MyMethodVisitor(MethodVisitor mv) { super(Opcodes.ASM5, mv); } @Override public void visitCode() { super.visitCode(); mv.visitFieldInsn(GETSTATIC, "java/lang/System", "out", "Ljava/io/PrintStream;"); mv.visitLdcInsn("start"); mv.visitMethodInsn(INVOKEVIRTUAL, "java/io/PrintStream", "println", "(Ljava/lang/String;)V", false); } @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/PrintStream", "println", "(Ljava/lang/String;)V", false); } mv.visitInsn(opcode); } }
}
ASM 工具
利用 ASM 手写字节码时,需要利用一系列 visitXXXXInsn() 方法来写对应的助记符,所以需要先将每一行源代码转化为一个个的助记符,然后通过 ASM 的语法转换为 visitXXXXInsn() 这种写法。第一步将源码转化为助记符就已经够麻烦了,不熟悉字节码操作集合的话,需要我们将代码编译后再反编译,才能得到源代码对应的助记符。第二步利用 ASM 写字节码时,如何传参也很令人头疼。ASM 社区也知道这两个问题,所以提供了工具 ASM ByteCode Outline (opens new window)。
安装后,右键选择 “Show Bytecode Outline”,在新标签页中选择“ASMified” 这个 tab,如图 19 所示,就可以看到这个类中的代码对应的 ASM 写法了。图中上下两个红框分别对应 AOP 中的前置逻辑于后置逻辑,将这两块直接复制到 visitor 中的 visitMethod()以及 visitInsn()方法中,就可以了。
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”,实现代码如下。我们需要做的就是从 pool 中获取到相应的 CtClass 对象和其中的方法,然后执行 method.insertBefore 和 insertAfter 方法,参数为要插入的 Java 代码,再以字符串的形式传入即可,实现起来也极为简单。
代码
collapse: closed
import com.meituan.mtrace.agent.javassist.*;
public class JavassistTest {
public static void main(String[] args) throws NotFoundException, CannotCompileException, IllegalAccessException, InstantiationException, IOException {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("meituan.bytecode.javassist.Base");
CtMethod m = cc.getDeclaredMethod("process");
m.insertBefore("{ System.out.println("start"); }");
m.insertAfter("{ System.out.println("end"); }");
Class c = cc.toClass();
cc.writeFile("/Users/zen/projects");
Base h = (Base)c.newInstance();
h.process();
}
}
运行时类的重载
Instrument
instrument 是 JVM 提供的一个可以修改已加载类的类库,专门为 Java 语言编写的插桩服务提供支持。它需要依赖 JVMTI 的 Attach API 机制实现,JVMTI 这一部分,我们将在下一小节进行介绍。在 JDK 1.6 以前,instrument 只能在 JVM 刚启动开始加载类时生效,而在 JDK 1.6 之后,instrument 支持了在运行时对类定义的修改。要使用 instrument 的类修改功能,我们需要实现它提供的 ClassFileTransformer 接口,定义一个类文件转换器。接口中的 transform() 方法会在类文件被加载时调用,而在 transform 方法里,我们可以利用上文中的 ASM 或 Javassist 对传入的字节码进行改写或替换,生成新的字节码数组后返回。
我们定义一个实现了 ClassFileTransformer 接口的类 TestTransformer,依然在其中利用 Javassist 对 Base 类中的 process()方法进行增强,在前后分别打印 “start” 和“end”,代码如下:
代码
collapse: closed
import java.lang.instrument.ClassFileTransformer;
public class TestTransformer implements ClassFileTransformer {
@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) {
System.out.println("Transforming " + className);
try {
ClassPool cp = ClassPool.getDefault();
CtClass cc = cp.get("meituan.bytecode.jvmti.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 null;
}
}
现在有了 Transformer,那么它要如何注入到正在运行的 JVM 呢?还需要定义一个 Agent,借助 Agent 的能力将 Instrument 注入到 JVM 中。我们将在下一小节介绍 Agent,现在要介绍的是 Agent 中用到的另一个类 Instrumentation。在 JDK 1.6 之后,Instrumentation 可以做启动后的 Instrument、本地代码(Native Code)的 Instrument,以及动态改变 Classpath 等等。我们可以向 Instrumentation 中添加上文中定义的 Transformer,并指定要被重加载的类,代码如下所示。这样,当 Agent 被 Attach 到一个 JVM 中时,就会执行类字节码替换并重载入 JVM 的操作。
代码
collapse: closed
import java.lang.instrument.Instrumentation;
public class TestAgent {
public static void agentmain(String args, Instrumentation inst) {inst.addTransformer(new TestTransformer(), true); try { inst.retransformClasses(Base.class); System.out.println("Agent Load Done."); } catch (Exception e) { System.out.println("agent load failed!"); } }
}
JVMTI & Agent & Attach API
追根溯源需要先介绍 JPDA(Java Platform Debugger Architecture)。如果 JVM 启动时开启了 JPDA,那么类是允许被重新加载的。在这种情况下,已被加载的旧版本类信息可以被卸载,然后重新加载新版本的类。正如 JDPA 名称中的 Debugger,JDPA 其实是一套用于调试 Java 程序的标准,任何 JDK 都必须实现该标准。
JPDA 定义了一整套完整的体系,它将调试体系分为三部分,并规定了三者之间的通信接口。三部分由低到高分别是 Java 虚拟机工具接口(JVMTI),Java 调试协议(JDWP)以及 Java 调试接口(JDI),三者之间的关系如下图所示:
现在回到正题,我们可以借助 JVMTI 的一部分能力,帮助动态重载类信息。JVM TI(JVM TOOL INTERFACE,JVM 工具接口)是 JVM 提供的一套对 JVM 进行操作的工具接口。通过 JVMTI,可以实现对 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 的 loadAgent() 方法,将打包好的 Agent jar 包动态 Attach 到目标 JVM 上。具体实现起来的步骤如下:
- 定义 Agent,并在其中实现 AgentMain 方法,如上一小节中定义的代码块 7 中的 TestAgent 类;
- 然后将 TestAgent 类打成一个包含 MANIFEST.MF 的 jar 包,其中 MANIFEST.MF 文件中将 Agent-Class 属性指定为 TestAgent 的全限定名
- 最后利用 Attach API,将我们打包好的 jar 包 Attach 到指定的 JVM pid 上
- 由于在 MANIFEST.MF 中指定了 Agent-Class,所以在 Attach 后,目标 JVM 在运行时会走到 TestAgent 类中定义的 agentmain()方法,而在这个方法中,我们利用 Instrumentation,将指定类的字节码通过定义的类转化器 TestTransformer 做了 Base 类的字节码替换(通过 javassist),并完成了类的重新加载。由此,我们达成了 “在 JVM 运行时,改变类的字节码并重新载入类信息” 的目的。
使用场景
- 热部署:不部署服务而对线上服务做修改,可以做打点、增加日志等操作。
- Mock:测试时候对某些服务做 Mock。
- 性能诊断工具:比如 bTrace 就是利用 Instrument,实现无侵入地跟踪一个正在运行的 JVM,监控到类和方法级别的状态信息。
总结
字节码增强技术相当于是一把打开运行时 JVM 的钥匙,利用它可以动态地对运行中的程序做修改,也可以跟踪 JVM 运行中程序的状态。此外,我们平时使用的动态代理、AOP 也与字节码增强密切相关,它们实质上还是利用各种手段生成符合规范的字节码文件。综上所述,掌握字节码增强后可以高效地定位并快速修复一些棘手的问题(如线上性能问题、方法出现不可控的出入参需要紧急加日志等问题),也可以在开发中减少冗余代码,大大提高开发效率。