Java字节码增强探秘
本文转载自公众号 美团技术团队
大家好,美美今天给大家推荐一篇Java字节码增强技术的文章,在实际工作中有很多应用场景。
1. 字节码
1.1 什么是字节码?
1.2 字节码结构
图3 JVM规定的字节码结构
(1) 魔数(Magic Number)
图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) 访问标志
图9 访问标志
(5) 当前类名
图11 字段表示例
(9)方法表
字段表结束后为方法表,方法表也是由两部分组成,第一部分为两个字节描述方法的个数;第二部分为每个方法的详细信息。方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性,如下图所示:
图12 方法表结构
方法的权限修饰符依然可以通过图9的值查询得到,方法名和方法的描述符都是常量池中的索引值,可以通过索引值在常量池中找到。而“方法的属性”这一部分较为复杂,直接借助javap -verbose将其反编译为人可以读懂的信息进行解读,如图13所示。可以看到属性中包括以下三个部分:
“Code区”:源代码对应的JVM指令操作码,在进行字节码增强时重点操作的就是“Code区”这一部分。
“LineNumberTable”:行号表,将Code区的操作码和源代码中的行号对应,Debug时会起到作用(源代码走一行,需要走多少个JVM指令操作码)。
“LocalVariableTable”:本地变量表,包含This和局部变量,之所以可以在每一个方法内部都可以调用This,是因为JVM将This作为每一个方法的第一个参数隐式进行传入。当然,这是针对非Static方法而言。
1.3 字节码操作集合
1.4 操作数栈和字节码
我们在上文所说的操作码或者操作集合,其实控制的就是这个JVM的操作数栈。为了更直观地感受操作码是如何控制操作数栈的,以及理解常量池、变量表的作用,将add()方法的对操作数栈的操作制作为GIF,如下图14所示,图中仅截取了常量池中被引用的部分,以指令iconst_2开始到ireturn结束,与图13中Code区0~17的指令一一对应:
1.5 查看字节码工具
图15 jclasslib查看字节码
2. 字节码增强
在上文中,着重介绍了字节码的结构,这为我们了解字节码增强技术的实现打下了基础。字节码增强技术就是一类对现有字节码进行修改或者动态生成全新字节码文件的技术。接下来,我们将从最直接操纵字节码的实现方式开始深入进行剖析。
图16 字节码增强技术
2.1 ASM
2.1.1 ASM API
2.1.1.1 核心API
ClassReader:用于读取已经编译好的.class文件。
ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
各种Visitor类:如上所述,CoreAPI根据字节码从上到下依次处理,对于字节码文件中不同的区域有不同的Visitor,比如用于访问方法的MethodVisitor、用于访问类变量的FieldVisitor、用于访问注解的AnnotationVisitor等。为了实现AOP,重点要使用的是MethodVisitor。
2.1.1.2 树形API
2.1.2 直接利用ASM实现AOP
public class Base {
public void process(){
System.out.println("process");
}
}
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!!!!!");
}
}
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);
//Base类中有两个方法:无参构造以及process方法,这里不增强构造方法
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) {
//方法在返回之前,打印"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工具
2.2 Javassist
CtClass(compile-time class):编译时类信息,它是一个Class文件在代码中的抽象表现形式,可以通过一个类的全限定名来获取一个CtClass对象,用来表示这个类文件。
ClassPool:从开发视角来看,ClassPool是一张保存CtClass信息的HashTable,Key为类名,Value为类名对应的CtClass对象。当我们需要对某个类进行修改时,就是通过pool.getCtClass("className")方法从pool中获取到相应的CtClass。
CtMethod、CtField:这两个比较好理解,对应的是类中的方法和属性。
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();
}
}
3.1 问题引出
import java.lang.management.ManagementFactory;
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 (Exception e) {
break;
}
process();
}
}
public static void process() {
System.out.println("process");
}
}
3.2 Instrument
我们定义一个实现了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) {
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;
}
}
import java.lang.instrument.Instrumentation;
public class TestAgent {
public static void agentmain(String args, Instrumentation inst) {
//指定我们自己定义的Transformer,在其中利用Javassist做字节码替换
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!");
}
}
}
3.3 JVMTI & Agent & Attach API
定义Agent,并在其中实现AgentMain方法,如上一小节中定义的代码块7中的TestAgent类;
然后将TestAgent类打成一个包含MANIFEST.MF的jar包,其中MANIFEST.MF文件中将Agent-Class属性指定为TestAgent的全限定名,如下图所示;
图22 Manifest.mf
最后利用Attach API,将我们打包好的jar包Attach到指定的JVM pid上,代码如下:
import com.sun.tools.attach.VirtualMachine;
public class Attacher {
public static void main(String[] args) throws AttachNotSupportedException, 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",也就是说完成了运行时的字节码增强,并重新载入了这个类。
3.4 使用场景
热部署:不部署服务而对线上服务做修改,可以做打点、增加日志等操作。
Mock:测试时候对某些服务做Mock。
性能诊断工具:比如bTrace就是利用Instrument,实现无侵入地跟踪一个正在运行的JVM,监控到类和方法级别的状态信息。
4. 总结
5. 参考文献
《ASM4-Guide》
Oracle:The class File Format
Oracle:The Java Virtual Machine Instruction Set
Javassist tutorial
JVM Tool Interface - Version 1.2
作者简介
泽恩,美团到店住宿业务研发团队工程师。
本文转载自公众号【程序员小灰】
- 点赞
- 收藏
- 关注作者
评论(0)