简介
在java的学习过程中,关于Class的文件结构一直困扰着我。比如像常量池,它是类文件中最为重要的一部分之一,但是网络上关于常量池的说话五花八门,因此我决定参考《深入理解Java虚拟机》一书去对Class文件以及常量池等重要知识进行学习和梳理。
Class类文件的结构
Class文件是一组以8位字节为基础单位的二进制流,文件中各个部分严格按照规则顺序紧凑地排列在Class文件之中,中间不存在任何间隔符号。如果遇上8位以上的数据时,会分割成若干8位字节进行存储。
下面是一个简单个class文件的十六进制表示
Class文件中只存在两种数据结构(所有的解析都会以这两种类型为基础):
- 无符号数:属于基本的数据类型,以u1、u2、u4、u8来代表1、2、4、8个字节的无符号数,用无符号数可以描述数字、索引引用、数量值或者utf-8编码构成的字符串值。
- 表:表是由多个无符号或其他表作为数据项构成的符合数据类型,所有的表一般以“_info”结尾。整个Class文件我们可以看作一张表
下面我们介绍一下Class的文件格式
类型 | 名称 | 数量 | 说明 |
---|---|---|---|
u4 | magic | 1 | 魔数 |
u2 | minor_version | 1 | 次版本号 |
u2 | major_version | 1 | 主版本号 |
u2 | constant_pool_count | 1 | 常量池中常量数量 |
cp_info | constant_pool | constant_pool_count - 1 | 常量池 |
u2 | access_flags | 1 | 当前类的访问修饰符 |
u2 | this_class | 1 | 当前类索引 |
u2 | super_class | 1 | 父类索引 |
u2 | interfaces_count | 1 | 接口数量 |
u2 | interfaces | interfaces_count | 接口索引集合 |
u2 | fields_count | 1 | 字段表数量 |
field_info | fields | fields_count | 字段表 |
u2 | methods_count | 1 | 方法表数量 |
method_info | methods | methods_count | 方法表 |
u2 | attribute_count | 1 | 属性表数 |
attribute_info | attributes | attributes_count | 属性表 |
注意:无论是无符号数或者表,当需要描述同一类型但是数量不定的多个数据时,经常会使用一个前置的容量计数器加若干连续的数据项的形式。
魔数与Class版本
前面Class文件表中的前三个类型分别是魔数、次版本号以及主版本号。
每个Class文件的前四位都是代表这个文件是一个Class文件,事实上,虚拟机加载类文件时会对类文件进行识别,而识别的标准就是目标文件的前四位是否为CAFE BABE,这两个单词是咖啡宝贝的意思,刚好与Java的咖啡图标相对应。
后面的00 00与00 34分别代表JDK的次版本号与主版本号,对应jdk1.8版本,通过这个版本号,虚拟机就可判断目标class文件能否运行在自身上。
常量池
我们都知道,class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table),用于存放编译器生成的各种字面量(Literal)和符号引用(Symbolic References)。我们可以将常量池看作Class文件的资源仓库,也就是说,Class文件中的其他大部分部分内容都是通过引用常量池中的对应位置上的常量来表示的。 字面量就是我们所说的常量概念,如文本字符串、被声明为final的常量值等。 符号引用是一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可(它与直接引用区分一下,直接引用一般是指向方法区的本地指针,相对偏移量或是一个能间接定位到目标的句柄)。一般包括下面三类常量:
-
类和接口的全限定名
-
字段的名称和描述符
-
方法的名称和描述符
下面用一张图来表示常量池里存储的内容:
常量池的每一项常量都是一个表,一共有如下表所示的11种各不相同的表结构数据,这每个表开始的第一位都是一个字节的标志位。而在JDK.17中为了更好地支持动态语言调用,有额外增加了三种。
我们以CONSTANT_Class_Info为例。
我们可以看到在这个表中存在两个数据,其中tag就是我们提到的标志位,用来表示这是一个Class,而后面紧跟着的index则是一个索引值,它指向常量池中一个CONSTANT_Utf8_info类型常量,此常量代表了这个类的全限定名。如果这个值为0x0002,那就表示指向了常量池中的第二个常量。
说到了CONSTANT_Utf8_info,我们就再对其解释一下。
tag我们已经知道是常量类型的标识,后面的length表示这个utf8常量的长度到底为多长,我们可以看到这个length的长度为2bit也就是说,CONSTANT_Utf8_info所能表示的字符串的最大值为65535。如意如果我们定义了超过64k英文字符的变量名或者方法名,将会无法编译。后面的bytes就代表我们字符串的实际内容了。
实例对比
关于前面提到的知识我们用实际的案例进行对比。
现在我们有这么一个类Test1
public class Test1{
private String name = "Tom";
private int age = 30;
public void f(){
System.out.print(name);
}
}
我们对其进行编译再用十六进制的编辑器打开。
我们说过,class文件前前四个字节是魔数也就是我们的CAFE BABE,上图中也证实了这点。
紧接着后的4到5位为次版本,6-7位为主版本,主版本是0034,转换成十进制就是我们的52,而52对应的JDK版本正是1.8,因为JDK版本1是对应我们的45,每一个大版本都会加1。
在次版本后就是我们的常量池了,第8-9位为0024,这表示常量池中有35个常量,因为常量池中的第0位位设计者预留出来作为一些在某些特殊情况下并不需要引用常量池常量的一些行为。我们再看看常量池中对应的常量内容。
可以看到我们0x0000000A,也就是第一个常量项的标识位为0A,对应我们的标识表这是一个方法常量。
方法常量后面的两位指向声明方法的类描述符的索引项也就是方法所在的类。
0008表示常量池中第八个常量项。由于中间隔着太多常量,我们这里直接使用javap -verbose来进行反编译查看我们的常量池内容
我们可以看到,常量池中第八位常量正是我们的java/lang/Object。因为我们并没有在Test1类中添加自定义构造器,所以默认使用的是父类Obejct的构造器。
我们再继续往后查看,会发现后面两位所代表的名称及类型描述符也就是我们的方法名、参数及返回值,可以看到他们指向的位置是16进制的14也就是我们十进制的20。
参考前面反编译出的常量池内容,可以看到20位表示的就是"<init>": ()V,表示这是一个构造方法,没有入参,后面的V代表返回值为Void。
通过这个案例我们应该可以对我们带目前常量池中结构的学习有了一定的认识。
访问标记
在我们了解了常量池之后,后面紧随的两个字节代表访问标志access_flag,这个标志用于识别一些类、接口层次的访问信息,包括这个Class是类还是接口;是否为public,是否为abstract类型等等
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x00 01 | 是否为Public类型 |
ACC_FINAL | 0x00 10 | 是否被声明为final,只有类可以设置 |
ACC_SUPER | 0x00 20 | 是否允许使用invokespecial字节码指令的新语义. |
ACC_INTERFACE | 0x02 00 | 标志这是一个接口 |
ACC_ABSTRACT | 0x04 00 | 是否为abstract类型,对于接口或者抽象类来说,次标志值为真,其他类型为假 |
ACC_SYNTHETIC | 0x10 00 | 标志这个类并非由用户代码产生 |
ACC_ANNOTATION | 0x20 00 | 标志这是一个注解 |
ACC_ENUM | 0x40 00 | 标志这是一个枚举 |
access_flags中共有两个字节(16位)可以使用,没有使用到的标志为要求一律为0。目前只定义了8种。以我们前面的Test1为例,Test1只是一个普通类,只被public修饰,并被JDK1.8进行编译,因此它的ACC_PUBLIC和ACC_SUPER标志位应该为真,因此它的access_flags的值应该为:0x0001|0x0020 = 0x0021。
从图中可以看出,access_flag标识确实为0x0021
类索引、父类索引与接口索引集合
在访问标识后是我们的类索引(this_class)和父类索引(super_class)以及当前类所实现的接口集合(insterfaces),前两者是一个u2类型的数据,后者则是一组u2类型的集合。Class文件中由这三项数据来确定这个类的继承关系。
类索引用来指向常量池中对应的全限定类名,父类索引用于指向常量池中对应的父类的全限定类名。在父类索引的后面则是一个u2类型的接口数量,用来表示后面有多少个接口。但这里的例子中我们并没有实现任何接口,所以为0.
根据字节码文件的指示,为我们的类索引指向00 07的位置,而父类索引指向00 08的位置,对照反编译的文件我们可以证实这一点
字段表集合
字段表用于描述接口或者类中声明的变量。字段包括类级变量及实例级变量,但不包括在方法内声明的局部变量。
下面是字段表的结构
类型 | 名称 数量 |
---|---|
u2 | access_flags 1 |
u2 | name_index 1 |
u2 | descriptor_index 1 |
u2 | attributes_count 1 |
attribute_info | attributes attributes_count |
字段表结构中的第一个类型就是标识我们字段的修饰符,字段可以被public、final等修饰,因此,需要使用这样一个标记来识别我们字段的修饰类型。
下面是字段访问标志表
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x00 01 | 字段是否为public |
ACC_PRIVATE | 0x00 02 | 字段是否为private |
ACC_PROTECTED | 0x00 04 | 字段是否为protected |
ACC_STATIC | 0x00 08 | 字段是否为static |
ACC_FINAL | 0x00 10 | 字段是否为final |
ACC_VOLATILE | 0x00 40 | 字段是否为volatile |
ACC_TRANSTENT | 0x00 80 | 字段是否为transient |
ACC_SYNCHETIC | 0x10 00 | 字段是否为由编译器自动产生 |
ACC_ENUM | 0x40 00 | 字段是否为enum |
该标识的用法可以参考前面类的访问标识的用法。
跟随access_flag标志的是两个索引值:name_index和descriptor_index,它们都是对常量池的引用,分别代表着字段的简单名称以及字段方法和方法的描述符。他们都是对常量池的引用,分别表示字段的简单名称以及字段的描述符。简单名称就是我们的字段名,描述符的作用是用来描述字段的数据类型、方法的参数列表和返回值。在类文件中他们都是以一些符号来表示。
下面是描述符以及其对应的含义
标志符 | 含义 |
---|---|
B | 基本数据类型byte |
C | 基本数据类型char |
D | 基本数据类型double |
F | 基本数据类型float |
I | 基本数据类型int |
J | 基本数据类型long |
S | 基本数据类型short |
Z | 基本数据类型boolean |
V | 基本数据类型void |
L | 对象类型 |
对于数组类型,每一维度将使用一个前置的"["字符来描述.如一个定义为"java.lang.Stirng[ ]"类型的二维数组,将被记录为:"[[Ljava/lang/Stirng",一个整型数组"int[]"将被记录为"[I"。
用描述符来描述方法时,按照先参数列表,后返回值的顺序来描述,参数列表按照参数的严格顺序放在一组小括号"()"之内。
假设我们存在一个void test(String[] agrs)方法,那么它对应的描述符为 “([L)V”
在上个部分我们提到了接口数量为0 ,紧跟在接口数量位置后的就是我们的字段数量。字节码显示为00 02,表示我们的类中存在两个字段。
在字段数量后面跟随的就是我们每个字段表。我们先看第一个字段表。第一个字段表的标识位为00 02,对照字段访问表示表,这个字段只被private所修饰。再往后就是我们的字段名称,对应字节码为00 09,对照反编译代码。是我们的name,那就表示我们该字段的字段名为name。
我们接着往下看,后面的00 0A是我们的修饰符标识位,表示该字段的对应类型。
缓存成十进制就是我们的第十个常量,刚好是Ljava/lang/String
关于字段表的分析就先到这里,后面的属性表将等到方法表集合分析完毕后一并进行分析。
注意:字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能列出原来Java代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段.另外,在Java语言中字段是无法重载的,两个字段的数据类型,修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果连个字段的描述符不一致,那字段重名就是合法的。
方法表集合
方法表集合与字段表集合其实很相似,一样依次包括了访问标识(access_flags)、名称索引(name_index)、描述符索引(descriptor_index)、属性表集合(attributes)。方法表中仅在访问标识和属性表集合上与字段表不同。
我们首先看方法访问标识列表
其用法与前面一致。
但是我们会注意到一点我们的方法体到哪里去了。事实上,我们的方法体就存在于我们的属性表集合中。我们通过前面的示例对其进行解读分析。
下图标识的是方法表的入口,表示我们的方法中存在两个方法。
后面跟着的00 01标识着方法的访问修饰符,刚好对应public修饰符。
再往后的00 0D是表示我们在常量池中的方法名,转换成10进制为13。对照反编译内容可以看到是一个"",这表示一个构造函数名
后面的修饰符标识为00 0E,对照着前面的反编译内容,就是我们常量池中第14个常量"()V"表示这个函数没有入参没有返回值。
接下来就是我们真正需要理解的地方了。那就是我们前面提到的方法体所在的属性集合。
对照着字节码我们可以看到方法的属性中只有一个属性表,那么我们看看这个属性表到底是什么属性。
可以看到属性表的第一项是我们的属性名称,00 0F对应我们的第15个常量,就是我们的Code,说明这个属性是一个Code属性,表示此属性是方法的字节码描述,也就是我们的方法体。
我们看一下属性表的结构
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | attribute_length |
我们发现在属性名后面紧随的是表示属性长度的attribute_length。比对我们的案例,我们发现我们的attribute_length是00 00 00 31,这表示我们的属性长度为49。
具体关于Code的内容我们放在属性表集合中进行讲解。
属性表集合
在class文件,字段表,方法表都可以携带自己的属性表集合(像前面方法表的时候就用到"code"这个属性表)以用于描述某些场景专有的信息
虚拟机中预定义的属性:
属性名称 | 使用位置 | 含义 |
---|---|---|
Code | 方法表 | Java代码编译成的字节码指令 |
ConstantValue | 字段表 | final关键字定义的常量池 |
Deprecated | 类,方法,字段表 | 被声明为deprecated的方法和字段 |
Exceptions | 方法表 | 方法抛出的异常 |
EnclosingMethod | 类文件 | 仅当一个类为局部类或者匿名类是才能拥有这个属性,这个属性用于标识这个类所在的外围方法 |
InnerClass | 类文件 | 内部类列表 |
LineNumberTable | Code属性 | Java源码的行号与字节码指令的对应关系 |
LocalVariableTable | Code属性 | 方法的局部便狼描述 |
StackMapTable | Code属性 | JDK1.6中新增的属性,供新的类型检查检验器检查和处理目标方法的局部变量和操作数有所需要的类是否匹配 |
Signature | 类,方法表,字段表 | 用于支持泛型情况下的方法签名 |
SourceFile | 类文件 | 记录源文件名称 |
SourceDebugExtension | 类文件 | 用于存储额外的调试信息 |
Synthetic | 类,方法表,字段表 | 标志方法或字段为编译器自动生成的 |
LocalVariableTypeTable | 类 | 使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加 |
RuntimeVisibleAnnotations | 类,方法表,字段表 | 为动态注解提供支持 |
RuntimeInvisibleAnnotations | 表,方法表,字段表 | 用于指明哪些注解是运行时不可见的 |
RuntimeVisibleParameterAnnotation | 方法表 | 作用与RuntimeVisibleAnnotations属性类似,只不过作用对象为方法 |
RuntimeInvisibleParameterAnnotation | 方法表 | 作用与RuntimeInvisibleAnnotations属性类似,作用对象哪个为方法参数 |
AnnotationDefault | 方法表 | 用于记录注解类元素的默认值 |
BootstrapMethods | 类文件 | 用于保存invokeddynamic指令引用的引导方式限定符 |
这里我们不对其进行一一说明,而是选择几个我们我们最想了解的用的最多的进行说明
Code
Java程序方法体中的代码经过javac编译器处理后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合中,但并非所有方法表都必须存在这个属性。比如接口和抽象类的抽象方法就不存在Code属性。我们看看Code属性的表结构。
attribute_name_index: 通常指向常量池中一个名为Code的常量
attribute_length: 指示了属性的长度,由于属性名称索引和属性长度一共6个字节,索引实际的属性长度还要减去6
max_stack: 该属性代表了操作数栈的深度。虚拟机运行时需要根据这个值来分配栈帧中的操作数栈深度。关于操作数栈的深度是如何定义的我们可以考这篇博客https://blog.csdn.net/weixin_42214548/article/details/109959740
max_locals: 该属性代表了局部变量表所需的存储空间。max_locals的单位是Slot,我们可以叫做槽,Slot是虚拟机为局部变量分配内存所用的最小单位。所有不超过32位的属性都只占据一个Slot,64位的属性则占据两个Slot。同时,除了一些显示定义的局部变量以外,还包括方法参数(包括隐藏参数this)、显式异常处理的参数。另外,并不是有多少局部变量就分配多少Slot,因为这个Slot是可以复用的,当某个局部变量超出其作用域时,它的Slot是可以被其他局部变量所使用的。编译器会根据变量的作用域来最终计算出max_locals的大小
code_length: 该属性表示后面的code的长度,也就是我们字节码指令的长度。注意,由于我们的code_length是4字节的,也就是说,它所表示的code指令长度不可以超过2^32-1条指令。因此我们的方法不能够太长,太长了一旦超出这个数字就无法编译。
code: code属性是用来存储字节码指令的一系列字节流。每个指令集就是一个u1类型的单字节,当虚拟机读到code中某个字节码时,就可以对应找出这个字节码代表的对应指令。因为u1对应0x00~0xFF,对应十进制0-255,也就是256条指令,目前虚拟机中已存在约200条指令。具体的指令后面结尾会专门有一张表进行表现。
exception_table_length: 在指令码之后的是这个方法的显式异常处理表集合,异常处理表对于Code来说并不是必须存在的。
exception_table: 异常表的格式如下:
这些字段的含义为:当字节码在第start_pc到end_pc之间出现了类型为catch_type的异常,将转到行号为handler_pc的代码继续处理。当catch_type为0时,任何异常都需要转入handler_pc行进行处理。
到此为止,我们对Code属性算是有了一个基本的了解了,了解它在内部到底是怎么的一个结构。
Exceptions
该属性与前面Code中的异常属性很相似,但事实上它们并不相同,所以不要混淆,Code中的异常属性指的是catch中的异常参数类型,而这里的Exceptions指的是方法声明中的异常说明,也就是平时我们方法后面throws关键字跟着的异常列表。
LineNumberTable
该属性用于描述java源码与字节码行号之间的对应关系。他并不是运行时必需的属性,但会被默认生成到Class文件中。可以在javac中分别使用-g:none或-g:lines选项来取消或要求生产该项信息。如果不产生行号,当发生异常时,我们将无法通过异常信息获知发送异常的行号。
LocalVariableTable
该属性用于描述栈帧中局部变量表中的变量与java源码中定义的变量之间的关系。他并不是运行时必需的属性,但会被默认生成到Class文件中。可以在javac中分别使用-g:none或-g:vars选项来取消或要求生产该项信息。如果不产生,那么编译器将会使用arg0,arg1等占位符来代替原有的参数名。该属性不会影响程序运行,但是在使用代码时将无法通过参数名或者该参数对应的意义。
下面是LocalVariableTable的属性结构表
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | sttraibute_length | 1 |
u2 | local_variable_table_length | 1 |
local_variable_info | local_variable_table | local_variable_table_length |
其中local_variable_info项代表了一个栈帧与源码中的局部变量的关联,该属性结构如下
类型 | 名称 | 数量 |
---|---|---|
u2 | start_pc | 1 |
u2 | length | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | index | 1 |
start_pc代表了局部变量的声明周期开始的字节码
length代表了这个局部变量从start_pc开始的作用范围。与前者相结合就是这个变量的作用域
name_index和descriptor_index都是指向常量池中的常量,分别代表局部变量的名称及其修饰符。
index是这个局部变量在栈帧局部变量表中slot的位置
注意:在jdk1.5引入泛型后,LocalVariableTable还增加了一个LocalVariableTypeTable属性,该属性与LocalVariableTable非常相似,只是将用来修饰变量的描述符descriptor_index替换成了字段的特征签名signature,对于非泛型来说,描述符与特征签名的内容是一致的,但由于描述符中泛型的参数化类型被擦除掉了,描述符就不能准确地描述泛型类型,因此出现了LocalVariableTypeTable
ConstantValue
ConstantValue属性的作用时通知虚拟机自动为静态变量赋值。只有被static修饰的变量才可以使用这个属性。对于类变量来说存在两种方式可以进行赋值:
- 在类构造器<clinit>方法中初始化
- 使用ConstantValue属性
如果目标变量是static final 修饰的基本类型或者字符串将通过ConstantValue属性来完成初始化,否则都将在类构造器中进行初始化。
InnerClasses
InnerClasses属性用于记录内部类与宿主类之间的关联。如果某个类内部定义了内部类,那编译器将会为其生成这个InnerClasses属性。属性结构如下:
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | number_of_classes | 1 |
inner_classes_info | inner_classes | number_of_classes |
number_of_classes:该属性记录了存在多少个内部类信息,每个内部类的信息都是由inner_classes_info表进行描述
inner_classes_info表结构如下
类型 | 名称 | 数量 |
---|---|---|
u2 | inner_class_info_index | 1 |
u2 | outer_class_info_index | 1 |
u2 | inner_name_index | 1 |
u2 | inner_class_access_flags | 1 |
inner_class_info_index:指向常量池中CONSTANT_Class_info型常量的索引,也就是内部类对应的符号引用
outer_class_info_index:指向常量池中CONSTANT_Class_info型常量的索引,也就是外部类对应的符号引用
inner_name_index:指向常量池中CONSTANT_Utf8_info型常量的索引,也就是内部类对应的简单类名,如果是匿名类,那么这项为0
inner_class_access_flags:内部类的访问标识,取值可以参考前面类的访问修饰符取值。
Signature
Signature是在JDK1.5之后出现的。他是一个可选的定长属性,可以出现在类、字段表、方法表结构的属性表中。任何类、接口、初始化方法或成员的泛型签名如果包含类型变量或参数化类型,则Signature属性会为它记录泛型签名信息。因为Java采用伪泛型,在字节码中,泛型的类型变量与参数化类型在编译后都会被擦除,无法在运行时通过反射获取泛型类型。Signature就是为这个缺陷而设计的。现在的反射能够通过反射获取泛型信息也是因为这个属性。
BootstrapMethods
BootstrapMethods是在JDK1.7之后出现的。该属性用于保存invokedynamic指令引用的引导方法限定符。类文件中最多也只能有一个BootstrapMethods属性。