Java 字节码增强探秘

1. 字节码

1.1 什么是字节码?

Java 之所以可以“一次编译,到处运行”,一是因为 JVM 针对各种操作系统、平台都进行了定制,二是因为无论在什么平台,都可以编译生成固定格式的字节码(.class 文件)供 JVM 使用。因此,也可以看出字节码对于 Java 生态的重要性。之所以被称之为字节码,是因为字节码文件由十六进制值组成,而 JVM 以两个十六进制值为一组,即以字节为单位进行读取。在 Java 中一般是用 javac 命令编译源代码为字节码文件,一个.java 文件从编译到运行的示例如图 1 所示。

图 1 Java 运行示意图

对于开发人员,了解字节码可以更准确、直观地理解 Java 语言中更深层次的东西,比如通过字节码,可以很直观地看到 Volatile 关键字如何在字节码上生效。另外,字节码增强技术在 Spring AOP、各种 ORM 框架、热部署中的应用屡见不鲜,深入理解其原理对于我们来说大有裨益。除此之外,由于 JVM 规范的存在,只要最终可以生成符合规范的字节码就可以在 JVM 上运行,因此这就给了各种运行在 JVM 上的语言(如 Scala、Groovy、Kotlin)一种契机,可以扩展 Java 所没有的特性或者实现各种语法糖。理解字节码后再学习这些语言,可以“逆流而上”,从字节码视角看它的设计思路,学习起来也“易如反掌”。

本文重点着眼于字节码增强技术,从字节码开始逐层向上,由 JVM 字节码操作集合到 Java 中操作字节码的框架,再到我们熟悉的各类框架原理及应用,也都会一一进行介绍。

1.2 字节码结构

.java 文件通过 javac 编译后将得到一个.class 文件,比如编写一个简单的 ByteCodeDemo 类,如下图 2 的左侧部分:

图 2 示例代码(左侧)及对应的字节码(右侧)

编译后生成 ByteCodeDemo.class 文件,打开后是一堆十六进制数,按字节为单位进行分割后展示如图 2 右侧部分所示。上文提及过,JVM 对于字节码是有规范要求的,那么看似杂乱的十六进制符合什么结构呢?JVM 规范要求每一个字节码文件都要由十部分按照固定的顺序组成,整体结构如图 3 所示。接下来我们将一一介绍这十个部分:

图 3 JVM 规定的字节码结构

(1) 魔数(Magic Number)

所有的.class 文件的前四个字节都是魔数,魔数的固定值为:0xCAFEBABE。魔数放在文件开头,JVM 可以根据文件的开头来判断这个文件是否可能是一个.class 文件,如果是,才会继续进行之后的操作。

有趣的是,魔数的固定值是 Java 之父 James Gosling 制定的,为 CafeBabe(咖啡宝贝),而 Java 的图标为一杯咖啡。

(2) 版本号

版本号为魔数之后的 4 个字节,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。上图 2 中版本号为“00 00 00 34”,次版本号转化为十进制为 0,主版本号转化为十进制为 52,在 Oracle 官网中查询序号 52 对应的主版本号为 1.8,所以编译该文件的 Java 版本号为 1.8.0。

(3) 常量池(Constant Pool)

紧接着主版本号之后的字节为常量池入口。常量池中存储两类常量:字面量与符号引用。字面量为代码中声明为 Final 的常量值,符号引用如类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符。常量池整体上分为两部分:常量池计数器以及常量池数据区,如下图 4 所示。

图 4 常量池的结构
  • 常量池计数器(constant_pool_count):由于常量的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。图 2 中示例代码的字节码前 10 个字节如下图 5 所示,将十六进制的 24 转化为十进制值为 36,排除掉下标“0”,也就是说,这个类文件中共有 35 个常量。

图 5 前十个字节及含义
  • 常量池数据区:数据区是由(constant_pool_count-1)个 cp_info 结构组成,一个 cp_info 结构对应一个常量。在字节码中共有 14 种类型的 cp_info(如下图 6 所示),每种类型的结构都是固定的。

图 6 各类型的 cp\_info

具体以 CONSTANT_utf8_info 为例,它的结构如下图 7 左侧所示。首先一个字节“tag”,它的值取自上图 6 中对应项的 Tag,由于它的类型是 utf8_info,所以值为“01”。接下来两个字节标识该字符串的长度 Length,然后 Length 个字节为这个字符串具体的值。从图 2 中的字节码摘取一个 cp_info 结构,如下图 7 右侧所示。将它翻译过来后,其含义为:该常量类型为 utf8 字符串,长度为一字节,数据为“a”。

图 7 CONSTANT\_utf8\_info 的结构(左)及示例(右)

其他类型的 cp_info 结构在本文不再赘述,整体结构大同小异,都是先通过 Tag 来标识类型,然后后续 n 个字节来描述长度和(或)数据。先知其所以然,以后可以通过 javap -verbose ByteCodeDemo 命令,查看 JVM 反编译后的完整常量池,如下图 8 所示。可以看到反编译结果将每一个 cp_info 结构的类型和值都很明确地呈现了出来。

图 8 常量池反编译结果

(4) 访问标志

常量池结束之后的两个字节,描述该 Class 是类还是接口,以及是否被 Public、Abstract、Final 等修饰符修饰。JVM 规范规定了如下图 9 的访问标志(Access_Flag)。需要注意的是,JVM 并没有穷举所有的访问标志,而是使用按位或操作来进行描述的,比如某个类的修饰符为 Public Final,则对应的访问修饰符的值为 ACC_PUBLIC | ACC_FINAL,即 0x0001 | 0x0010=0x0011。

图 9 访问标志

(5) 当前类名

访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。

(6) 父类名称

当前类名后的两个字节,描述父类的全限定名,同上,保存的也是常量池中的索引值。

(7) 接口信息

父类名称后为两字节的接口计数器,描述了该类或父类实现的接口数量。紧接着的 n 个字节是所有接口名称的字符串常量的索引值。

(8) 字段表

字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。字段表也分为两部分,第一部分为两个字节,描述字段个数;第二部分是每个字段的详细信息 fields_info。字段表结构如下图所示:

图 10 字段表结构

以图 2 中字节码的字段表为例,如下图 11 所示。其中字段的访问标志查图 9,0002 对应为 Private。通过索引下标在图 8 中常量池分别得到字段名为“a”,描述符为“I”(代表 int)。综上,就可以唯一确定出一个类中声明的变量 private int a。

图 11 字段表示例

(9)方法表

字段表结束后为方法表,方法表也是由两部分组成,第一部分为两个字节描述方法的个数;第二部分为每个方法的详细信息。方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性,如下图所示:

图 12 方法表结构

方法的权限修饰符依然可以通过图 9 的值查询得到,方法名和方法的描述符都是常量池中的索引值,可以通过索引值在常量池中找到。而“方法的属性”这一部分较为复杂,直接借助 javap -verbose 将其反编译为人可以读懂的信息进行解读,如图 13 所示。可以看到属性中包括以下三个部分:

  • “Code 区”:源代码对应的 JVM 指令操作码,在进行字节码增强时重点操作的就是“Code 区”这一部分。
  • “LineNumberTable”:行号表,将 Code 区的操作码和源代码中的行号对应,Debug 时会起到作用(源代码走一行,需要走多少个 JVM 指令操作码)。
  • “LocalVariableTable”:本地变量表,包含 This 和局部变量,之所以可以在每一个方法内部都可以调用 This,是因为 JVM 将 This 作为每一个方法的第一个参数隐式进行传入。当然,这是针对非 Static 方法而言。

图 13 反编译后的方法表

(10)附加属性表

字节码的最后一部分,该项存放了在该文件中类或接口所定义属性的基本信息。

1.3 字节码操作集合

在上图 13 中,Code 区的红色编号 0~17,就是.java 中的方法源代码编译后让 JVM 真正执行的操作码。为了帮助人们理解,反编译后看到的是十六进制操作码所对应的助记符,十六进制值操作码与助记符的对应关系,以及每一个操作码的用处可以查看 Oracle 官方文档 进行了解,在需要用到时进行查阅即可。比如上图中第一个助记符为 iconst_2,对应到图 2 中的字节码为 0x05,用处是将 int 值 2 压入操作数栈中。以此类推,对 0~17 的助记符理解后,就是完整的 add() 方法的实现。

1.4 操作数栈和字节码

JVM 的指令集是基于栈而不是寄存器,基于栈可以具备很好的跨平台性(因为寄存器指令集往往和硬件挂钩),但缺点在于,要完成同样的操作,基于栈的实现需要更多指令才能完成(因为栈只是一个 FILO 结构,需要频繁压栈出栈)。另外,由于栈是在内存实现的,而寄存器是在 CPU 的高速缓存区,相较而言,基于栈的速度要慢很多,这也是为了跨平台性而做出的牺牲。

我们在上文所说的操作码或者操作集合,其实控制的就是这个 JVM 的操作数栈。为了更直观地感受操作码是如何控制操作数栈的,以及理解常量池、变量表的作用,将 add() 方法的对操作数栈的操作制作为 GIF,如下图 14 所示,图中仅截取了常量池中被引用的部分,以指令 iconst_2 开始到 ireturn 结束,与图 13 中 Code 区 0~17 的指令一一对应:

图 14 控制操作数栈示意图

1.5 查看字节码工具

如果每次查看反编译后的字节码都使用 javap 命令的话,好非常繁琐。这里推荐一个 Idea 插件: jclasslib 。使用效果如图 15 所示,代码编译后在菜单栏 "View" 中选择 "Show Bytecode With jclasslib",可以很直观地看到当前字节码文件的类信息、常量池、方法区等信息。

图 15 jclasslib 查看字节码

2. 字节码增强

在上文中,着重介绍了字节码的结构,这为我们了解字节码增强技术的实现打下了基础。字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术。接下来,我们将从最直接操纵字节码的实现方式开始深入进行剖析。

图 16 字节码增强技术

2.1 ASM

对于需要手动操纵字节码的需求,可以使用 ASM,它可以直接生成.class 字节码文件,也可以在类被加载入 JVM 之前动态修改类行为(如下图 17 所示)。ASM 的应用场景有 AOP(Cglib 就是基于 ASM)、热部署、修改其他 jar 包中的类等。当然,涉及到如此底层的步骤,实现起来也比较麻烦。接下来,本文将介绍 ASM 的两种 API,并用 ASM 来实现一个比较粗糙的 AOP。但在此之前,为了让大家更快地理解 ASM 的处理流程,强烈建议读者先对 访问者模式 进行了解。简单来说,访问者模式主要用于修改或操作一些数据结构比较稳定的数据,而通过第一章,我们知道字节码文件的结构是由 JVM 固定的,所以很适合利用访问者模式对字节码文件进行修改。

图 17 ASM 修改字节码

2.1.1 ASM API

2.1.1.1 核心 API

ASM Core API 可以类比解析 XML 文件中的 SAX 方式,不需要把这个类的整个结构读取进来,就可以用流式的方法来处理字节码文件。好处是非常节约内存,但是编程难度较大。然而出于性能考虑,一般情况下编程都使用 Core API。在 Core API 中有以下几个关键类:

  • ClassReader:用于读取已经编译好的.class 文件。
  • ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
  • 各种 Visitor 类:如上所述,CoreAPI 根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的 Visitor,比如用于访问方法的 MethodVisitor、用于访问类变量的 FieldVisitor、用于访问注解的 AnnotationVisitor 等。为了实现 AOP,重点要使用的是 MethodVisitor。

2.1.1.2 树形 API

ASM Tree API 可以类比解析 XML 文件中的 DOM 方式,把整个类的结构读取到内存中,缺点是消耗内存多,但是编程比较简单。TreeApi 不同于 CoreAPI,TreeAPI 通过各种 Node 类来映射字节码的各个区域,类比 DOM 节点,就可以很好地理解这种编程方式。

2.1.2 直接利用 ASM 实现 AOP

利用 ASM 的 CoreAPI 来增强类。这里不纠结于 AOP 的专业名词如切片、通知,只实现在方法调用前、后增加逻辑,通俗易懂且方便理解。首先定义需要被增强的 Base 类:其中只包含一个 process() 方法,方法内输出一行“process”。增强后,我们期望的是,方法执行前输出“start”,之后输出 "end"。

复制代码

publicclassBase{
publicvoidprocess(){
System.out.println("process");
}
}

为了利用 ASM 实现 AOP,需要定义两个类:一个是 MyClassVisitor 类,用于对字节码的 Visit 以及修改;另一个是 Generator 类,在这个类中定义 ClassReader 和 ClassWriter,其中的逻辑是,classReader 读取字节码,然后交给 MyClassVisitor 类处理,处理完成后由 ClassWriter 写字节码并将旧的字节码替换掉。Generator 类较简单,我们先看一下它的实现,如下所示,然后重点解释 MyClassVisitor 类。

复制代码

importorg.objectweb.asm.ClassReader;
importorg.objectweb.asm.ClassVisitor;
importorg.objectweb.asm.ClassWriter;

publicclass Generator {
publicstaticvoidmain(String[] args) throws Exception {
// 读取
ClassReader classReader =newClassReader("meituan/bytecode/asm/Base");
ClassWriter classWriter =newClassWriter(ClassWriter.COMPUTE_MAXS);
// 处理
ClassVisitor classVisitor =newMyClassVisitor(classWriter);
classReader.accept(classVisitor, ClassReader.SKIP_DEBUG);
byte[] data = classWriter.toByteArray();
// 输出
Filef =newFile("operation-server/target/classes/meituan/bytecode/asm/Base.class");
FileOutputStream fout =newFileOutputStream(f);
fout.write(data);
fout.close();
System.out.println("now generator cc success!!!!!");
}
}

MyClassVisitor 继承自 ClassVisitor,用于对字节码的观察。它还包含一个内部类 MyMethodVisitor,继承自 MethodVisitor 用于对类内方法的观察,整体代码如下:

复制代码

importorg.objectweb.asm.ClassVisitor;
importorg.objectweb.asm.MethodVisitor;
importorg.objectweb.asm.Opcodes;

publicclassMyClassVisitorextendsClassVisitorimplementsOpcodes{
publicMyClassVisitor(ClassVisitor cv){
super(ASM5, cv);
}
@Override
publicvoidvisit(intversion,intaccess, String name, String signature,
String superName, String[] interfaces){
cv.visit(version, access, name, signature, superName, interfaces);
}
@Override
publicMethodVisitorvisitMethod(intaccess, String name, String desc, String signature, String[] exceptions){
MethodVisitor mv = cv.visitMethod(access, name, desc, signature,
exceptions);
//Base 类中有两个方法:无参构造以及 process 方法,这里不增强构造方法
if(!name.equals("<init>") && mv !=null) {
mv =newMyMethodVisitor(mv);
}
returnmv;
}
classMyMethodVisitorextendsMethodVisitorimplementsOpcodes{
publicMyMethodVisitor(MethodVisitor mv){
super(Opcodes.ASM5, mv);
}

@Override
publicvoidvisitCode(){
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
publicvoidvisitInsn(intopcode){
if((opcode >= Opcodes.IRETURN && opcode <= Opcodes.RETURN)
|| opcode == Opcodes.ATHROW) {
// 方法在返回之前,打印 "end"
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);
}
}
}

利用这个类就可以实现对字节码的修改。详细解读其中的代码,对字节码做修改的步骤是:

  • 首先通过 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 文件进行查看,可以看到反编译后的代码已经改变了(如图 18 左侧所示)。然后写一个测试类 MyTest,在其中 new Base(),并调用 base.process() 方法,可以看到下图右侧所示的 AOP 实现效果:

图 18 ASM 实现 AOP 的效果

2.1.3 ASM 工具

利用 ASM 手写字节码时,需要利用一系列 visitXXXXInsn() 方法来写对应的助记符,所以需要先将每一行源代码转化为一个个的助记符,然后通过 ASM 的语法转换为 visitXXXXInsn() 这种写法。第一步将源码转化为助记符就已经够麻烦了,不熟悉字节码操作集合的话,需要我们将代码编译后再反编译,才能得到源代码对应的助记符。第二步利用 ASM 写字节码时,如何传参也很令人头疼。ASM 社区也知道这两个问题,所以提供了工具 ASM ByteCode Outline

安装后,右键选择“Show Bytecode Outline”,在新标签页中选择“ASMified”这个 tab,如图 19 所示,就可以看到这个类中的代码对应的 ASM 写法了。图中上下两个红框分别对应 AOP 中的前置逻辑于后置逻辑,将这两块直接复制到 Visitor 中的 visitMethod() 以及 visitInsn() 方法中,就可以了。

图 19 ASM Bytecode Outline

2.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",实现代码如下。我们需要做的就是从 Pool 中获取到相应的 CtClass 对象和其中的方法,然后执行 method.insertBefore 和 insertAfter 方法,参数为要插入的 Java 代码,再以字符串的形式传入即可,实现起来也极为简单。

复制代码

import com.meituan.mtrace.agent.javassist.*;

public classJavassistTest{
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");
Baseh = (Base)c.newInstance();
h.process();
}
}

3. 运行时类的重载

3.1 问题引出

上一章重点介绍了两种不同类型的字节码操作框架,且都利用它们实现了较为粗糙的 AOP。其实,为了方便大家理解字节码增强技术,在上文中我们避重就轻将 ASM 实现 AOP 的过程分为了两个 Main 方法:第一个是利用 MyClassVisitor 对已编译好的 Class 文件进行修改,第二个是 New 对象并调用。这期间并不涉及到 JVM 运行时对类的重加载,而是在第一个 Main 方法中,通过 ASM 对已编译类的字节码进行替换,在第二个 Main 方法中,直接使用已替换好的新类信息。另外在 Javassist 的实现中,我们也只加载了一次 Base 类,也不涉及到运行时重加载类。

如果我们在一个 JVM 中,先加载了一个类,然后又对其进行字节码增强并重新加载会发生什么呢?模拟这种情况,只需要我们在上文中 Javassist 的 Demo 中 main() 方法的第一行添加 Base b=new Base(),即在增强前就先让 JVM 加载 Base 类,然后在执行到 c.toClass() 方法时会抛出错误,如下图 20 所示。跟进 c.toClass() 方法中,我们会发现它是在最后调用了 ClassLoader 的 Native 方法 defineClass() 时报错。也就是说,JVM 是不允许在运行时动态重载一个类的。

图 20 运行时重复 load 类的错误信息

显然,如果只能在类加载前对类进行强化,那字节码增强技术的使用场景就变得很窄了。我们期望的效果是:在一个持续运行并已经加载了所有类的 JVM 中,还能利用字节码增强技术对其中的类行为做替换并重新加载。为了模拟这种情况,我们将 Base 类做改写,在其中编写 main 方法,每五秒调用一次 process() 方法,在 process() 方法中输出一行“process”。

我们的目的就是,在 JVM 运行中的时候,将 process() 方法做替换,在其前后分别打印“start”和“end”。也就是在运行中时,每五秒打印的内容由 "process" 变为打印 "start process end"。那如何解决 JVM 不允许运行时重加载类信息的问题呢?为了达到这个目的,我们接下来一一介绍需要借助的 Java 类库。

复制代码

importjava.lang.management.ManagementFactory;

publicclass Base {
publicstaticvoidmain(String[] args) {
Stringname = ManagementFactory.getRuntimeMXBean().getName();
Strings = name.split("@")[0];
// 打印当前 Pid
System.out.println("pid:"+s);
while(true) {
try{
Thread.sleep(5000L);
}catch(Exception e) {
break;
}
process();
}
}

publicstaticvoidprocess() {
System.out.println("process");
}
}

3.2 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”,代码如下:

复制代码

importjava.lang.instrument.ClassFileTransformer;

publicclassTestTransformer implements ClassFileTransformer {
@Override
publicbyte[]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\"); }");
returncc.toBytecode();
} catch (Exceptione) {
e.printStackTrace();
}
returnnull;
}
}

现在有了 Transformer,那么它要如何注入到正在运行的 JVM 呢?还需要定义一个 Agent,借助 Agent 的能力将 Instrument 注入到 JVM 中。我们将在下一小节介绍 Agent,现在要介绍的是 Agent 中用到的另一个类 Instrumentation。在 JDK 1.6 之后,Instrumentation 可以做启动后的 Instrument、本地代码(Native Code)的 Instrument,以及动态改变 Classpath 等等。我们可以向 Instrumentation 中添加上文中定义的 Transformer,并指定要被重加载的类,代码如下所示。这样,当 Agent 被 Attach 到一个 JVM 中时,就会执行类字节码替换并重载入 JVM 的操作。

复制代码

import java.lang.instrument.Instrumentation;

publicclassTestAgent{
publicstaticvoidagentmain(String args, Instrumentation inst){
// 指定我们自己定义的 Transformer,在其中利用 Javassist 做字节码替换
inst.addTransformer(newTestTransformer(),true);
try{
// 重定义类并载入新的字节码
inst.retransformClasses(Base.class);
System.out.println("Agent Load Done.");
}catch(Exception e) {
System.out.println("agent load failed!");
}
}
}

3.3 JVMTI & Agent & Attach API

上一小节中,我们给出了 Agent 类的代码,追根溯源需要先介绍 JPDA(Java Platform Debugger Architecture)。如果 JVM 启动时开启了 JPDA,那么类是允许被重新加载的。在这种情况下,已被加载的旧版本类信息可以被卸载,然后重新加载新版本的类。正如 JDPA 名称中的 Debugger,JDPA 其实是一套用于调试 Java 程序的标准,任何 JDK 都必须实现该标准。

JPDA 定义了一整套完整的体系,它将调试体系分为三部分,并规定了三者之间的通信接口。三部分由低到高分别是 Java 虚拟机工具接口(JVMTI),Java 调试协议(JDWP)以及 Java 调试接口(JDI),三者之间的关系如下图所示:

图 21 JPDA

现在回到正题,我们可以借助 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 的全限定名,如下图所示;

图 22 Manifest.mf
  • 最后利用 Attach API,将我们打包好的 jar 包 Attach 到指定的 JVM pid 上,代码如下:

复制代码

importcom.sun.tools.attach.VirtualMachine;

publicclassAttacher{
publicstaticvoidmain(String[] args)throwsAttachNotSupportedException, IOException, AgentLoadException, AgentInitializationException{
// 传入目标 JVM pid
VirtualMachine vm = VirtualMachine.attach("39333");
vm.loadAgent("/Users/zen/operation_server_jar/operation-server.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",也就是说完成了运行时的字节码增强,并重新载入了这个类。

图 23 运行时重载入类的效果

3.4 使用场景

至此,字节码增强技术的可使用范围就不再局限于 JVM 加载类前了。通过上述几个类库,我们可以在运行时对 JVM 中的类进行修改并重载了。通过这种手段,可以做的事情就变得很多了:

  • 热部署:不部署服务而对线上服务做修改,可以做打点、增加日志等操作。
  • Mock:测试时候对某些服务做 Mock。
  • 性能诊断工具:比如 bTrace 就是利用 Instrument,实现无侵入地跟踪一个正在运行的 JVM,监控到类和方法级别的状态信息。

4. 总结

字节码增强技术相当于是一把打开运行时 JVM 的钥匙,利用它可以动态地对运行中的程序做修改,也可以跟踪 JVM 运行中程序的状态。此外,我们平时使用的动态代理、AOP 也与字节码增强密切相关,它们实质上还是利用各种手段生成符合规范的字节码文件。综上所述,掌握字节码增强后可以高效地定位并快速修复一些棘手的问题(如线上性能问题、方法出现不可控的出入参需要紧急加日志等问题),也可以在开发中减少冗余代码,大大提高开发效率。

5. 参考文献

作者介绍:

泽恩,美团到店住宿业务研发团队工程师。

本文转载自公众号美团技术团队(ID:meituantech)。

原文链接:

https://mp.weixin.qq.com/s?__biz=MjM5NjQ5MTI5OA==&mid=2651750626&idx=1&sn=3e1ac6c41d6e1803abb32285daf0244a&chksm=bd1259af8a65d0b97809a6a8ff5afaff1be4a4232bd8527ef9d95bb7a2e768bd7d9fdc768211&scene=27#wechat_redirect

我来评几句
登录后评论

已发表评论数()

相关站点

+订阅
热门文章