带你领略 JVM 类文件结构

 2023-01-12
原文作者:千山渡 原文地址:https://juejin.cn/post/6936853259197874206

类文件是以8字节为基础单位的二进制流,各个数据项严格按照顺序紧凑地排序,中间没有多余的分隔符。数据项是指类集合、字段表、方法表和属性表等等

类文件格式是采用一种类似C语言结构体的伪结构来存储数据,主要有两种:无符号数和表。无符号数是以u1、u2、u4和u8来分别表示1个字节、2个字节、4个字节和8个字节,其值用来表示,后面紧跟着会有多少个数据项。表是有多个无符号数或者其他表作为数据项构成的复合数据类型,习惯以“_info”结尾。

数据存储的字节顺序采用大端法表示:最高有效字节在前面,对应的还有小端法,但是这些不会影响我们队类文件结构的理解,这里只是提一下。

以下示例使用到的软件工具包括:jdk8、notepad++(含Hex_Editor插件)、javap和bytecode-viewer。在整篇文章中,我们都是使用以下这个demo。

202301011634232301.png

无论是无符号数还是表,当需要描述同一个类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这是称这一些列连续的某一类型的数据为某一类型的集合。这个其实很好理解,比如,类中有多个方法,我们用一个数据结构描述这个类的方法,当然是想有个字段来统计有多少个方法,然后接下来就是每个方法的描述。这个字段就是上面的无符号数。

202301011634236832.png

上图列出的就是所有的数据项,一个类的所有元信息就是由这些数据项组成描述的。

魔数与版本

每个类文件的头4个字节称为魔数,确定这个文件是否为一个能否被虚拟机接受,仅跟着魔数的4个字节是类文件的版本号,版本号如下图,第5、第6个字节是次版本号

202301011634242783.png

202301011634248154.png

十六进制的34转换为十进制,是52,就是jdk8的主版本号。

常量池

常量池主要存放两大类常量:字面量和符号引用,字面量可理解为Java语言层面的常量概念,如字符串常量“abnddd”,符号引用包括类常量,有类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。

202301011634258065.png

常量会用一个无符号数作为标志,如标志位1,代表UTF-8编码的字符串,编码为CONSTANT_utf8_info。

仅接着主次版本号之后的是常量池的入口,常量池可以理解为类文件的资源仓库,比如里面放着类名的字符串,方法名的字符串等等。常量池中常量的数量不固定,需在入口处放置一个u2类型的数据,以表示有多少个常量。这个值是从1开始,0用作特殊情况,表示不需要引用任何一个常量池数据项。

202301011634262816.png

如图,有13(转为十进制为19)个常量。

这里先直观看一下反编译后的类文件信息,如下图

202301011634270007.png

202301011634275258.png

这里的常量池,前面都有一个数字,代表常量在常量池中的索引,索引可以理解为数组的下标。后面的解析中,会频繁引用到这个索引,所以先提及一下。

目前常用的常量池的结构类型如下,后面具体到每一个常量结构,再仔细分析。

202301011634280459.png

1. 现在,开始解析常量池的第一个常量:

2023010116342893810.png

第一个标志位0x0a(十进制位10),看一下标志位10的常量结构:

2023010116342946611.png

第1项为标志,u1类型,第2项为u2类型,是指向常量池的索引,值为0x0004(十进制为4),这时看一下索引为4的是什么常量:

2023010116343011012.png

指向CONSTANT_Class_info类全限定名,值为org/javasoft/clazz/TestClass。

第3项为u2类型,指为0x000f(十进制为15),是指向CONSTANT_NameAndType常量的索引,。根据常量结构,CONSTANT_NameAndType常量的第2项是指向该字段或方法名称常量项的索引,第3项是指向该字段或方法描述符常量项的索引。从图可以看到,这个CONSTANT_NameAndType常量又引用了常量池的第7、第8个常量,等把第7和第8个常量解析出来,就知道是什么了。

2.接下来,解析常量池的第二个常量:

2023010116343083813.png

2023010116343139214.png

2023010116343196415.png

实质上,解释的方法是一样的,这里就不再重复了,按上图可以知道第二个常量是CONSTANT_Feild_info。

3.接下来,解析常量池的第三个常量:

2023010116343251316.png

2023010116343310217.png

2023010116343359718.png

第三个常量是CONSTANT_Class_info。

4.解析常量池的第四个常量:

2023010116343409019.png

2023010116343465620.png

这个字符串的length值是0x0001,也就是长1个字节,即“0x6d”,内容为“m”。Class文件中的方法、字段等都需要引用CONSTANTS_Utf8_info型常量来描述名称

这里就不一一解析,我把18个常量按下图画出来,区分一下常量池的位置和剩余部分,常量池中的常量到0xb0处为止。

2023010116343526921.png

类、父类和接口索引集合

一个类是有访问标志,如public、final等,类文件结构用两个字节代表访问标志,用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract类型;如果是类的话,是否被声明为final等。

2023010116343574422.png

接下来,继续解析:

2023010116343639523.png

0x21,这个值代表除了ACC_PUBLIC和ACC_SUPER为真,其余为假,回想一下,我们的类是定义为“public class TestClazz”。

若描述一个类定义,需要描述类的全限定名,父类的全限定名,还有实现的接口,父类只有一个,以为Java只有单继承,而接口有多个,所以描述接口需要用到一个集合

  • 类索引:u2类型,用于确定这个类的全限定名
  • 父类索引:u2类型,用于确定这个类的父类的全限定名(除java.lang.Object外,所有java类都有父类索引都不为0)
  • 接口索引:一组u2类型的数据的集合,用于描述这个类实现了哪些接口,入口第一项(u2类型)表示索引表的容量

2023010116343745524.png

2023010116343817325.png

解释方法,还是按上面所说的。

字段表集合

字段表用于描述接口或者类中声明的变量(包括类变量和实例变量,但不包括方法内部声明的局部变量)。字段能描述的信息:字段的作用域(public、protected和private修饰符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数据)、字段名称。各个修饰符都是布尔值;字段数据类型和名称无法固定,只能引用常量池的常量来描述

2023010116343898926.png

字段表引用了属性表,而属性表是用来描述字段的内容的,详细下面再讲。

2023010116344016727.png

上图为字段表访问标志,跟类的访问标志类似。

描述符

用于描述字段的数据类型、方法的参数列表(包括数量、类型和顺序)和返回值基本类型和void类型都用一个大写字母来表示,对象用L加全限定名来表示。

2023010116344130028.png

  • 数组表示:每一维度使用一个前置的"["表示,如 "java.lang.String[][]"记录为"[[Ljava/lang/String"; 一个整型数组 "int[]" 记录为 "[I"
  • 方法表示:先参数列表后返回值,如void inc()记录为 “()V”,int indexOf(char[] source, int sourceOffset, int sourceCount,char[] target, int targetOffset, int targetCount,int fromIndex) 记录为“([CII[CIII)I”

说白了,描述符就是为了节省空间的。

接下来继续解析:

2023010116344214629.png

2023010116344285230.png

由此可以推断代码为:private int m;

在Java语言层面,两个字段重名是非法的,但是在字节码文件里,两个字段只要描述符不一致, 重名是合法的。

方法表集合

与字段表的描述完全一致,结构也是一致

2023010116344378531.png

2023010116344468932.png

继续解析:

2023010116344558433.png

2023010116344631034.png

和字段表一样,子类没有覆写父类方法的话,父类的方法信息不会重新在子类的方法表集合中。

到此,我们不禁会有些疑惑:

方法的定义可以通过访问标志、名称索引、描述符索引表达清楚,但方法里面的代码去哪里了?方法里的java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里。

属性表集合

在类文件、字段表、方法表都可以携带自己的属性表集合,用以描述某些场景专有的信息。对于每个属性,名称需要从常量池中引用一个CONSTANT_utf8_info类型的常量来表示而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。

属性表结构如下:

2023010116344722535.png

Java虚拟机规范预定义了23种属性:

2023010116344790736.png

2023010116344882637.png

其中最重要的,也是我们要讲的一个是Code属性表

Code属性

2023010116344955638.png

java方法体中的代码经过javac编译器处理后,最终变成字节码指令存储在Code属性内,接口和抽象类方法不存在Code属性。如果一个程序中的信息分为代码(Code,方法体内的java代码)和元数据(metadata,包括类、字段、方法定义及其他信息)两部分,那么在Class文件中,Code属性用于描述代码,其他数据项目都用于描述元数据。

2023010116345020139.png

  • attribute_name_index: 指向CONSTANTS_utf8_info型常量型的索引,常量值固定为“Code”,代表该属性的属性名称
  • attribute_length:表示属性值得长度,由于属性名称索引和属性长度一共占6个字节,那属性值的长度固定为整个属性表长度减去6字节
  • max_stack: 操作数栈深度的最大值,方法执行的任意时刻,操作数栈都不会超过这个深度,虚拟机运行时需根据这个值来分配栈帧中的操作栈深度
  • max_locals:局部变量表所需的存储空间,单位是Slot(虚拟机为局部变量分配内存所使用的最小单位),局部变量表中的Slot可以重用,当代码执行超出一个局部变量的作用域时,即被重用,javac编译器根据作用域来分配Slot给各个变量使用,然后计算出max_locals的大小
  • code_length:字节码长度
  • code:字节码指令的一系列字节流,一个指令是u1类型,范围为0x000xFF(0255)
  • exception_table_length:异常表长度
  • exceptin_table:异常表

继续解析:

2023010116345130440.png

  • attribute_name_index: 0x0009,指向Code
  • attribute_length:0x0000001d,十进制为29
  • max_stack: 0x0001
  • max_locals:0x0001
  • code_length:0x00000005
  • code:0x2ab70001b1,字节码指令
  • exception_table_length:0x0000,没有异常信息

2023010116345213841.png

2023010116345288142.png

其余的属性表也是按照这种方法一个一个解析出来,只是这里展示了最重要的Code属性表。

总结

其实,Class文件结构理解起来并不难,前面我们知道了常量池中的常量,然后我们可以根据Class文件结构对于类、方法、字段的安排顺序,再根据计数器和索引,对照着常量的结构类型图,就可以一个一个的解析出来。到此,想必对类文件结构有了一个整体的认识。那么认识了类文件结构,有什么好处。

理解类文件结构,是理解类加载过程的重要前提。