1 深入剖析class文件结构
1.1 初探class文件
在IDEA中编写代码并运行后,就会自动在out目录下生成对应的class文件
1 | public class Hello { |
用010Editor查看class文件
1.2 class文件结构
1 | classFile { |
1.2.1 魔数
可以从上文看到Hello.class的魔数是四字节的0xCAFEBABE(咖啡宝贝?)
魔数是JVM识别class文件的标志,虚拟机在加载类文件之前会检查魔数是否正确,否则抛出java.lang.ClassFormatError
异常,比如将魔数改为0xCAFEBABB
然后在终端运行修改过的Hello.class,(注意如果直接IDEA运行会重新生成class文件并覆盖旧的)
1 | java Hello |
1.2.2 版本号
0x0034表示主版本号为52(0x34),虚拟机解析时可以知道这是一个Java 8编译出的类。如果类文件版本高于JVM版本,加载类时会抛出异常java.lang.UnsupportedClassVersionError
关于版本号
每次Java发布大版本,主版本号就会加1,比如Java 8是52,Java 9是53
我们把主版本号改成53(0x35),然后终端运行一下修改后的类文件
1.2.3 常量池
一、常量池是做什么用的?
像0这样的简单常用的操作数,会被内嵌到字节码中。如果操作数比较大或者是字符串常量,class文件就会将其存储在常量池,使用时,根据索引位置来查找。
二、常量池大小
比如大小为n时,真正有效的索引是1~n-1,因为0属于保留索引。
三、常量池项(cp_info)集合
最多包含n-1(因为long和double类型的常量会占用两个索引位置)
四、每个常量项——cp_info的结构
1 | cp_info { |
五、常量池类型
类型 | tag值 |
---|---|
CONSTANT_Utf8_info | 1 |
CONSTANT_Integer_info | 3 |
CONSTANT_Flaot_info | 4 |
CONSTANT_Long_info | 5 |
CONSTANT_Double_info | 6 |
CONSTANT_Class_info | 7 |
CONSTANT_String_info | 8 |
CONSTANT_Fieldref_info | 9 |
CONSTANT_Methodref_info | 10 |
CONSTANT_InterfaceMethodref_info | 11 |
CONSTANT_NameAndType_info | 12 |
CONSTANT_MethodHandle_info | 15 |
CONSTANT_MethodType_info | 16 |
CONSTANT_InvokeDynamic_info | 18 |
(1)查看类文件Hello.class常量池的方法
1 | javap -v Hello |
六、CONSTANT_Integer_info和CONSTANT_Flaot_info
这两种常量都是4字节的。其结构分别如下
1 | CONSTANT_Integer_info { |
1 | CONSTANT_Float_info { |
其他如boolean、byte、short、char类型的变量,在常量池中都被当做int来处理。
有示例类MyConstantTest
如下
1 | public class MyConstantTest { |
在终端中javac MyConstantTest.java
生成class文件,然后拖入010Editor查看。
七、CONSTANT_Long_info和CONSTANT_Double_info
这两个都是8字节。结构如下
1 | CONSTANT_Long_info { u1 tag; u4 high_bytes; u4 low_bytes;} |
1 | CONSTANT_Double_info { u1 tag; u4 high_bytes; u4 low_bytes;} |
示例程序如下
1 | public class HelloWorldMain { public final long a = Long.MAX_VALUE;} |
八、CONSTANT_Utf8_info
存储字符串内容,其结构如下
1 | CONSTANT_Utf8_info { u1 tag; u2 length; // 注意,这个length不是表示字符串有多少字符,而是表示byte数组的长度 u1 bytes[length]; // 采用MUTF-8编码的字节数组} |
什么是MUTF-8编码
这要先从UTF-8编码说起。UTF-8是一种变长编码,使用1~4字节表示一个字符。规则如下:
参考 - 知乎 - Unicode 和 UTF-8 有什么区别?
Unicode范围(十六进制) | UTF-8编码方式(二进制) |
---|---|
0000 0001 - 0000 007F | 0xxxxxxx |
0000 0080 - 0000 07FF | 110xxxxx 10xxxxxx |
0000 0800 - 0000 FFFF | 1110xxxx 10xxxxxx 10xxxxxx |
0001 0000 - 0010 FFFF | 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx |
比如A
的Unicode值是0x41在0000 0001 - 0000 007F范围内,将0x41填到7个x中,所以其UTF-8值为01000001,即0x41
再如严
的Unicode是0x4E25,其范围在0000 0800 - 0000 FFFF,将0x4E25从低位开始填入1110xxxx 10xxxxxx 10xxxxxx的16个x中,得到其UTF-8为11100100 10111000 10100101,也即0xE4B8A5
MUTF-8的不同指出在于:
(1)MUTF-8中用两个字节表示空字符("\0"
):把110xxxxx 10xxxxxx中的x全部填入0,也就是0xC080。而UTF-8只用一个字节,也就是0x00表示。MUTF-8这种方式的好处在于可以保证字符串中不会出现空字符,在C语言处理时不会出现意外截断。
(2)MUTF-8只用了标准UTF-8中的三种编码方式:单字节、两字节、三字节,没有用到四字节。需要编码超过0000 FFFF的字符时,Java使用“代理对”通过两个字符来表示。
1 | public class HelloWorldMain { |
y
是笑哭的emoji
将其编译为字节码后,先用javap -v HelloWorldMain.class
查看常量池
拖到010Editor查看
可以看到x
对应的常量池字节信息第一个字节为tag,值为1也就是对应着CONSTANT_Utf8_info,然后紧跟的两个字节表示字节数组长度为2,后面的0xC080就是x
的值了。
y的解码过程
y
的tag值也是一样,其字节数组长度为6,也就是用6个字节来表示,前三个字节ED A0 BD对应二进制为11101101 10100000 10111101,根据UTF-8三字节的编码方法,解码时去掉第一个字节的110和第二三字节的10,剩下1101 1000 0011 1101也即0xD83D,同理后三字节ED B8 82可以解码为0xDE02,因此得到这个emoji的编码为4字节的0xD83DDE02
九、CONSTANT_String_info
与CONSTANT_Utf8_info的区别在于,CONSTANT_Utf8_info存储了字符串真正的内容,而CONSTANT_String_info存的是指向CONSTANT_Utf8_info常量类型的索引。其结构为
1 | CONSTANT_String_info { |
示例
1 | public class Hello { |
在常量池第15个常量,存放了字符串的内容。
十、CONSTANT_Class_info
用来表示类或接口。结构与CONSTANT_String_info类似
1 | CONSTANT_Class_info { |
十一、CONSTANT_NameAndType_info
表示字段或者方法
1 | CONSTANT_NameAndType_info { |
name_index和descriptor_index都指向CONSTANT_Utf8_info,前者表示字段或方法名,后者是字段或方法描述符,用来表示字段或方法的类型。
1 | public class Hello { public void testMethod(int id, String name) { }} |
这里有个疑问?
按《深入理解JVM字节码》P15所述,name_index和descriptor_index分别指向第4和第5个位置,也就应该对应到constant_pool[3]
和constant_pool[4]
才对,但实际上对应的是constant_pool[7]
和constant_pool[8]
十二、CONSTANT_Fieldref_info、CONSTANT_Methodref_info和CONSTANT_InterfaceMethodref_info
结构如下
1 | CONSTANT_Fieldref_info { |
示例代码
1 | public class HelloWorldMain { |
编译得到类文件,查看其常量池javap -v HelloWorldMain.class
(1)所以,类名的索引在constant_pool[1]
全限定名在constant_pool[26]
(2)方法相关信息在constant_pool[4]
所以,类索引是2,也就是上文对应的constant_pool[1]
,方法名和类型的常量池索引为29,也即在constant_pool[28]
所以方法名在constant_pool[17]
,方法类型constant_pool[18]
,如下
十三、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info
从JDK1.7后为更好地支持动态语言调用,增加的3中常量池。CONSTANT_InvokeDynamic_info的作用是为invokedynamic指令提供启动引导方法。
1 | CONSTANT_InvokeDynamic_info { |
1.2.4 Access flags
紧跟在常量池后的两个字节,共16个标记位,目前只使用了8个,完整的访问标记含义如图
如下0x21就意味着有ACC_SUPER和ACC_PUBLIC标记
1.2.5 字段表
一、field_info结构
1 | field_info { |
二、字段访问标记
三、字段描述符
表示某个field的类型
说明:
(1)引用类型后面的分号是为了防止多个连续引用类型描述符出现混淆。比如字符串类型String的描述符为Ljava/lang/String;
(2)JVM用前置[
表示数组,比如int[]
的类型描述符为[I
,字符串数组String[]
的描述符为[Ljava/lang/String;
。多维数组就是多加几个[
,比如Object[][][]
描述符就是[[[Ljava/lang/Object;
1.2.6 方法表
一、method_info结构
1 | method_info { |
二、方法访问标记
三、方法描述符
格式如下
1 | (参数1类型 参数2类型 参数3类型 ...) 返回值类型 |
比如Object foo(int i, double d, Thread t)
的描述符为(IDLjava/lang/Thread;)Ljava/lang/Object;
1.2.7 方法属性表
一、属性表结构
1 | { |
其中每个属性项的结构如下
1 | attribute_info { |
二、ConstantValue属性
用来表示静态变量初始值,结构如下
1 | ConstantValue_attribute { |
比如变量为long类型,constantvalue_index就指向CONSTANT_Long_info类型的常量项
三、Code属性
除native和abstract方法以外,每个方法都有且仅有一个Code属性。
1 | Code_attribute { |
Java虚拟机规定Code属性只能包含四种可选属性:LineNumberTable、LocalVariableTable、LocalVariableTypeTable、StackMapTable。
示例代码:
1 | import java.io.IOException; |
1.3 javap
javap [options] <classes>
(1)javap -p
显示private方法和字段
(2)javap -s
可以输出类型描述符签名信息
(3)javap -c
可以对类文件进行反编译,可以显示出方法内的字节码
(4)javap -v
可以显示更加详细的内容,比如版本号、类访问权限、常量池等信息
(5)javap -l
可以显示行号表和局部变量表。不过一般只显示行号表。因为想要显示局部变量表,需要在javac编译时加上-g
选项。
2 字节码基础
6 ASM和Javassit字节码操作工具
6.1 ASM Core API核心类
(1)ClassVisitor:是一个抽象类
(2)ClassWriter:ClassVisitor的实现类,在visit方法中可以修改原始字节码;toByteArray方法可以返回修改后的字节数组
(3)ClassReader:负责解析class文件。accept调用后,ClassReader会把解析Class文件过程中的事件源源不断地通知给ClassVisitor对象调用不同的visit方法,ClassVisitor可以在这些visit方法中对字节码进行修改,ClassWriter可以生成最终修改过的字节码。解析过程中遇到不同的节点会调用不同的visit方法。ASM的visit方法调用时序如下:
6.2 如何使用ASM API操作字节码
一、访问类的方法和字段
(1)新建类
1 | public class MyMain { |
(2)编译,生成MyMain.class文件
(3)使用ASM输出类的方法和字段列表
1 | import jdk.internal.org.objectweb.asm.*; |
输出结果如下:
accept方法的第二个参数:位掩码
位掩码 | 含义 |
---|---|
SKIP_DEBUG | 跳过类文件中的调试信息 |
SKIP_CODE | 跳过方法体中的Code属性 |
EXPAND_FRAMES | 展开StackMapTable属性 |
SKIP_FRAMES | 跳过StackMapTable属性 |
二、新增字段
利用visitEnd
方法添加字段
这里给MyMain新增了一个String类型的xyz字段。
1 | import jdk.internal.org.objectweb.asm.*; |
用javap
查看MyMain2的字节码,可以看到多出的String类型的xyz变量,部分字节码如下:
三、新增方法
这里新增一个xyz方法,其签名为(ILjava/lang/String;)V
(前文笔记中已有“方法描述符”的格式参考)。
新增方法与新增字段的区别在于visitEnd
里是MethodVisitor还是FieldVisitor
1 | import jdk.internal.org.objectweb.asm.*; |
用javap
查看MyMain2的字节码
四、移除方法和字段
a. 移除的本质在于
将visit方法(visitField和visitMethod)返回null
b. 示例
以下MyMain类为例,删除abc字段和xyz方法。
1 | public class MyMain { |
1 | import jdk.internal.org.objectweb.asm.*; |
使用javap
查看MyMain2的字节码发现只剩下def字段和foo方法了
五、修改方法内容
修改的本质
移除+新增
示例
1 | public class MyMain { |
1 | import jdk.internal.org.objectweb.asm.*; |
ClassFormatError异常
执行java -cp . MyMain
报错,提示入参无法放到局部变量表。
解决方法:让ASM自动计算stack和locals。这与ClassWriter构造器方法参数有关。
构造器参数 | 含义 |
---|---|
new ClassWriter(0) | 不自动计算操作数栈和局部变量大小,需要手动指定 |
new ClassWriter(ClassWriter.COMPUTE_MAXS) | 自动计算操作数栈和局部变量大小,前提是需要调用visitMaxs方法触发计算上述两个值,参数可以随意指定 |
new ClassWriter(ClassWriter.COMPUTE_FRAMES) | 除了操作数栈和局部变量大小,还会自动计算StackMapFrames |
修改代码如下
1 | import jdk.internal.org.objectweb.asm.*; |
再次执行MyMain,正常运行。
六、AdviceAdapter使用
作用
在方法的开始和结束插入代码
示例
1 | import jdk.internal.org.objectweb.asm.*; |
七、给方法加上try catch
7 Java Instrumentation原理
7.1 Java Instrumentation简介
JDK 1.5版本后引入java.lang.instrument
包,可以实现字节码增强,其核心功能由java.lang.instrument.Instrumentation
提供,该接口的方法提供了注册类文件转换器、获取所有已加载的类等功能,允许我们修改已加载或未加载的类,实现AOP、性能监控等功能。
一、修改字节码的基本过程
Instrumentation的常用组成
Instrumentation
接口的addTransformer
方法给Instrumentation
注册一个类型为ClassFileTransformer
的类文件转换器,ClassFileTransformer
接口只有一个transform
方法。
修改字节码的过程
调用addTransformer
注册transform
以后,后续所有JVM加载类都会被transform
方法拦截,这个方法接受了原类文件的字节数组,在这个方法中可以做任意修改,最后返回转换过的字节数组,由JVM加载这个修改后的类文件。
如果transform
方法返回null,则表示不做处理;如果返回值不为null,则说明,JVM会用返回的字节数组替换原来类的字节数组。
接口的其他方法
1 | retransformClasses // 对JVM已加载的类重新触发类加载 |
Instrumentation的两种使用方法
(1)在JVM启动时添加一个Agent的jar包
(2)JVM运行以后在任意时刻通过Attach API远程加载Agent的jar包