字节码分析与操作
Posted cherrytab
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了字节码分析与操作相关的知识,希望对你有一定的参考价值。
1.1什么是字节码
https://zh.wikipedia.org/wiki/Java%E5%AD%97%E8%8A%82%E7%A0%81
Java所宣称的一次编译处处运行就是靠的字节码技术,java文件编译后会生成字节码文件.class,供jvm使用。字节码文件是由十六进制值组成,两个十六进制为一组,以一个字节为单位进行读取。
编译 javac *.java
反编译javap -c -verbose *.class
1.2.字节码结构
public class ByteCodeDemo { private int a = 1; public int add() { int b = 2; int c = a + b; System.out.println(c); return c; } public static void main(String[] args) { System.out.println("sss"); } }
编译后生成的.class文件,这里我们用notepad++ 和 HEX-Editor插件查看这个十六进制文件
分析文件
(1) 魔数(Magic Number)
所有的.class文件的前四个字节都是魔数,魔数的固定值为:0xCAFEBABE。魔数放在文件开头,JVM可以根据文件的开头来判断这个文件是否可能是一个.class文件,避免不必要的操作。
cafeebabe是java之父James Gosling制定的,Java的图标为一杯咖啡,应该是有关系的。
(2) 版本号
版本号为魔数之后的4个字节,前两个字节表示次版本号(Minor Version),后两个字节表示主版本号(Major Version)。上图中版本号为“00 00 00 34”,次版本号转化为十进制为0,主版本号转化为十进制为52,在Oracle官网中查询序号52对应的主版本号为1.8,所以编译该文件的Java版本号为1.8.0。
(3) 常量池(Constant Pool)
常量池中存储两类常量:字面量与符号引用。字面量为代码中声明为final的常量值,符号引用如类和接口的全局限定名、字段的名称和描述符、方法的名称和描述符。常量池整体上分为两部分:常量池计数器以及常量池数据区,如下图所示。
常量池计数器(constant_pool_count):由于常量的数量不固定,所以需要先放置两个字节来表示常量池容量计数值。示例代码的字节码前10个字节如下图所示,将十六进制的2d转化为十进制值为46,排除掉下标“0”,也就是说,这个类文件中共有46个常量。
(4) 访问标志
常量池结束之后的两个字节,描述该class为类还是接口,以及是否被public,abstract,final等修饰过。JVM并没有穷举所有的访问标志,而是使用按位或操作来进行描述的,比如某个类的修饰符为Public Final,则对应的访问修饰符的值为ACC_PUBLIC | ACC_FINAL,即0x0001 | 0x0010=0x0011。
(5) 当前类名
访问标志后的两个字节,描述的是当前类的全限定名。这两个字节保存的值为常量池中的索引值,根据索引值就能在常量池中找到这个类的全限定名。
(6) 父类名称
当前类名后的两个字节,描述父类的全限定名,同上,保存的也是常量池中的索引值。
(7) 接口信息
父类名称后为两字节的接口计数器,描述了该类或父类实现的接口数量。紧接着的n个字节是所有接口名称的字符串常量的索引值。
(8) 字段表
字段表用于描述类和接口中声明的变量,包含类级别的变量以及实例变量,但是不包含方法内部声明的局部变量。字段表也分为两部分,第一部分为两个字节,描述字段个数;第二部分是每个字段的详细信息fields_info。字段表结构如下图所示:
(9)方法表
字段表结束后为方法表,方法表分为两部分,第一部分为用两字节描述方法的个数,第二部分为每个方法的详细信息。方法的详细信息较为复杂,包括方法的访问标志、方法名、方法的描述符以及方法的属性,如下图所示:
(10)附加属性表
字节码的最后一部分,该项存放了在该文件中类或接口所定义属性的基本信息。
1.3查看字节码的工具
classlib,可以在idea内install这个插件
代码编译后在菜单栏”View”中选择”Show Bytecode With jclasslib”,可以很直观地看到当前字节码文件的类信息、常量池、方法区等信息
2字节码操作增强
2.1 ASM
https://www.ibm.com/developerworks/cn/java/j-lo-asm30/index.html
使用ASM可以直接生产.class文件,在类被加载进jvm之前动态修改。ASM的应用场景有AOP(Cglib就是基于ASM)、热部署、修改其他jar包中的类等。主要是利用了访问者设计模式。
2.1.1.1 ASM 核心API
ASM Core API可以类比解析XML文件中的SAX方式,不需要把这个类的整个结构读取进来,就可以用流式的方法来处理字节码文件。好处是非常节约内存,但是编程难度较大。然而出于性能考虑,一般情况下编程都使用Core API。在Core API中有以下几个关键类:
ClassReader:用于读取已经编译好的.class文件。
ClassWriter:用于重新构建编译后的类,如修改类名、属性以及方法,也可以生成新的类的字节码文件。
2.1.1.2树形API
ASM Tree API可以类比解析XML文件中的DOM方式,把整个类的结构读取到内存中,缺点是消耗内存多,但是编程比较简单。TreeApi不同于CoreAPI,TreeAPI通过各种Node类来映射字节码的各个区域,类比DOM节点,就可以很好地理解这种编程方式。
2.1.2 直接利用ASM实现AOP
package asm; public class Base { public void process() { System.out.println("process"); } }
我们的目的是在process之前和之后都进行操作。
为了利用ASM实现AOP,需要定义两个类:一个是MyClassVisitor类,用于对字节码的visit以及修改;另一个是Generator类,在这个类中定义ClassReader和ClassWriter,其中的逻辑是,classReader读取字节码,然后交给MyClassVisitor类处理,处理完成后由ClassWriter写字节码并将旧的字节码替换掉。Generator类较简单,我们先看一下它的实现,如下所示,然后重点解释MyClassVisitor类。
package asm; import jdk.internal.org.objectweb.asm.ClassReader; import jdk.internal.org.objectweb.asm.ClassVisitor; import jdk.internal.org.objectweb.asm.ClassWriter; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; public class Generator { public static void main(String[] args) throws IOException { //读取 ClassReader classReader = new ClassReader("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("D:\\program\\java project\\guava\\target\\classes\\asm\\Base.class"); FileOutputStream fout = new FileOutputStream(f); fout.write(data); fout.close(); System.out.println("now generator cc success!!!!!"); new Base().process(); } }
MyClassVisitor继承自ClassVisitor,用于对字节码的观察。它还包含一个内部类MyMethodVisitor,继承自MethodVisitor用于对类内方法的观察,它的整体代码如下:
package asm; 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 visitor) { super(ASM5, visitor); } @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); } }
运行generator之前的Base.class
package asm; public class Base { public Base() { } public void process() { System.out.println("process"); } }
运行之后
package asm; public class Base { public Base() { } public void process() { System.out.println("start"); System.out.println("process"); System.out.println("end"); } }
分析:
首先通过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”压入栈。
2.1.3 ASM工具
idea install 插件ASM Bytecide Outline
使用方法是对需要操作的java文件右键show bytecode outline,然后在弹出的标签页中选ASMified
直接复制ok
2.2Javassist
强调源代码层次操作字节码的框架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:这两个比较好理解,对应的是类中的方法和属性。
示例
package asm; import javassist.*; import java.io.IOException; public class JavassistTest { public static void main(String[] args) throws NotFoundException, CannotCompileException, IOException, IllegalAccessException, InstantiationException { ClassPool cp = ClassPool.getDefault(); CtClass cc = cp.get("asm.Base"); CtMethod m = cc.getDeclaredMethod("process"); m.insertBefore("{ System.out.println("start"); }"); m.insertAfter("{ System.out.println("end"); }"); Class c = cc.toClass(); cc.writeFile("D:\\program\\java project\\guava\\target\\classes"); Base base = (Base) c.newInstance(); base.process(); } }
改造后的class文件
// // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // package asm; public class Base { public Base() { } public void process() { System.out.println("start"); System.out.println("start"); System.out.println("process"); Object var2 = null; System.out.println("end"); Object var4 = null; System.out.println("end"); } public void test() { System.out.println("test"); } }
3.4使用场景
热部署:不部署服务而对线上服务做修改,可以做打点、增加日志等操作。
Mock:测试时候对某些服务做Mock。
性能诊断工具:比如bTrace就是利用Instrument,实现无侵入地跟踪一个正在运行的JVM,监控到类和方法级别的状态信息。
参考引用
https://tech.meituan.com/2019/09/05/java-bytecode-enhancement.html
https://blog.csdn.net/u011810352/article/details/80316870
end
以上是关于字节码分析与操作的主要内容,如果未能解决你的问题,请参考以下文章
GroovyMOP 元对象协议与元编程 ( Groovy 类内部和外部分别获取 metaClass | 分析获取 metaClass 操作的字节码 | HandleMetaClass 注入方法 )
Java 虚拟机原理Class 字节码二进制文件分析 一 ( 字节码文件附加信息 | 魔数 | 次版本号 | 主版本号 | 常量池个数 )