在了解了Java内存的分布、HotSpot虚拟机对Java对象的管理以及Java垃圾收集机制之后,我们大致了解了Java自动内存管理的部分。接下来,就应该看看Java的类加载机制,看看虚拟机是如何将Java代码文件编译后的class文件加载到Java内存中的。
Java是一门平台无关语言,只要有Java的运行环境,编写的代码可以运行在各种机器上,做到了“一次编码、处处运行”的目的。为了达到平台无关,Sun公司以及其它虚拟机提供商发布了许多可以运行不同平台上的虚拟机,这些虚拟机都可以载入和执行同一种平台无关的字节码,达到了平台无关的目的,如下图:
不过,java虚拟机不仅可以运行Java程序,在设计之初就实现了让其他语言运行在Java虚拟机上的可能性,只要程序编译之后能生成符合虚拟机规范的class文件即可。现在,已经有很多语言可以运行在java虚拟机上了,比如Clojure、Groovy、JRuby、Jython、Scala等。这样,java虚拟机也实现了语言无关性。上面的图就变成了这个样子:
可以看出,语言无关性和平台无关性的关键在于class字节码文件。Java虚拟机规范要求class文件中使用许多强制性以及若干其他辅助信息。接下来就详细了解一下class字节码文件的结构,当然,这里主要以Java语言为主。
1、概述
class文件是一组以8位字节为基础的二进制流,各个数据项目严格按照顺序紧凑地排列在class文件中,中间没有任何分隔符,这点和png、jpg等图片文件格式类似。当遇到需要占用8位字节以上空间的数据项时,则会按照一定的字节顺序分隔为若干个8位字节进行存储。
Java虚拟机规范规定class文件格式采用一种类似于C语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型:无符号数和表。其中无符号数属于基本的数据类型,以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 |
attributes_count |
1 |
attribute_info |
attributes |
attributes_count |
无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会用到一个前置的容量计数器(表中以“_count”结尾的项)加上若干个连续的数据项的形式,这时称这一系列连续的某一类型的数据为某一类型的集合。
为了介绍各个数据项的含义,这里以一个简单的Java程序为例,代码如下:
public class Test{
public static double PI=3.14;
private int m;
public int inc(){
return m+1;
}
}
在命令行下使用javac编译就可以得到class文件,如下:
接下来看看各项的具体含义。
2、魔数与Class文件的版本
这部分的二进制流内容:
class文件的头4个字节称为魔数,它的唯一作用就是确定这个文件时候是一个能被虚拟机接受的class文件。很多图片格式都用一个魔数来标识文件类型,比如png和jpg等。在java的class文件中,这个数是0xcafebabe。
接下来就是class文件的版本号,第5、6个字节是次版本号,第7、8个字节是主版本号。在这里,次版本号是0,主版本号是52,(十六进制是34)。Java的版本号是从45开始的,JDK1.1之后的每一个JDK大版本发布主版本号向上加1,高版本的JDK能向下兼容低版本的JDK。
3、常量池
紧接着主版本号的就是常量池,常量池可以理解为class文件的资源仓库,它是class文件结构中与其它项目关联最多的数据类型,也是占用class文件空间最大的数据项目之一,也是class文件中第一个出现的表类型数据项目。
由于常量池中常量的数量不是固定的,所以常量池入口需要放置一项u2类型的数据,代表常量池中的容量计数。不过,这里需要注意的是,这个容器计数是从1开始的而不是从0开始,也就是说,常量池中常量的个数是这个容器计数-1。将0空出来的目的是满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义。class文件中只有常量池的容量计数是从1开始的,对于其它集合类型,比如接口索引集合、字段表集合、方法表集合等的容量计数都是从0开始的。
常量池中主要存放两大类常量:字面量和符号引用。字面量比较接近Java语言的常量概念,如文本字符串、声明为final的常量等。而符号引用则属于编译原理方面的概念,它包括三方面的内容:
- 类和接口的全限定名(Fully Qualified Name);
- 字段的名称和描述符(Descriptor);
- 方法的名称和描述符;
Java代码在进行javac编译的时候并不像C和C++那样有连接这一步,而是在虚拟机加载class文件的时候进行动态连接。也就是说,在class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,虚拟机也就无法使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址中。
常量池中的每一项都是一个表,在JDK1.7之前有11中结构不同的表结构,在JDK1.7中为了更好的支持动态语言调用,又增加了3种(CONSTANT_MethodHandle_info、CONSTANT_MethodType_info和CONSTANT_InvokeDynamic_info)。不过这里不会介绍这三种表数据结构。
这14个表的开始第一个字节是一个u1类型的tag,用来标识是哪一种常量类型。这14种常量类型所代表的含义如下:
类型 |
标志 |
含义 |
CONSTANT_Utf8_info |
1 |
UTF-8编码的字符串 |
CONSTANT_Integer_info |
3 |
整型字面量 |
CONSTANT_Float_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_InterfaceMethod_info |
11 |
接口中方法的符号引用 |
CONSTANT_NameAndType_info |
12 |
字段或方法的部分符号引用 |
CONSTANT_MethodHandle_info |
15 |
表示方法句柄 |
CONSTANT_MethodType_info |
16 |
标识方法类型 |
CONSTANT_InvokeDynamic_info |
18 |
表示一个动态方法调用点 |
本例的常量池部分如下:
蓝颜色覆盖的是常量池部分,可以看到这部分的内容非常多。因为常量池中的常量比较多,每一中常量还有自己的结构,导致常量池的结构非常复杂,这里也仅仅是简单解析两个例子。
由class文件结构图可知,常量池的开头两个字节0x001A是常量池的容量计数,这里是26,也就是说,这个常量池中有25个常量项。看看这个例子的第一项,容量计数后面的第一个字节标识这个常量的类型,是0A,即10,查表可知是类方法的符号引用,这个常量表的结构如下:
类型 |
名称 |
数量 |
U1 |
tag |
1 |
U2 |
name_index |
1 |
U2 |
descriptor_index |
1 |
按照这个结构,可以知道name_index是7(0x0007),descriptor_index是21(0x0015)。这都是一个索引,指向常量池中的其他常量,其中name描述了这个方法的名称,descriptor描述了这个方法的访问标志(比如public、private等)、参数类型和返回类型。
接下来的tag是9,可知是一个字段的符号引用,它的结构和方法的结构类似,只不过接下来的两个字节表示的是声明这个字段的类或接口的索引,最后的两个字节表示的是这个字段的类型和名字CONSTANT_NameAndType索引,这两个索引分别是6和22,在后面会验证这几个索引。
根据这两个例子可以看出,要准确的描述一个类中所声明的字段和方法的所有信息,仅仅一个符号引用是不够的,还需要继续引用其他的常量池项目。
常量池中接下来的内容也可以这样解析,不过,JDK已经提供了一个工具可以自动计算这些内容,使用javap -verbose命令可以快速的计算出class文件结构的内容,比如这样:
javap -verbose Test
注意Test没有.java 或.class,它是解析Test.class文件的,所以使用前先用javac编译Java文件。结果如下:
Classfile /C:/Users/Liu Guoqiang/Desktop/Test.class
Last modified 2016-5-29; size 357 bytes
MD5 checksum cc9fcfb483f1dc499e7535bfe9f88943
Compiled from "Test.java"
public class Test
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#21 // java/lang/Object."<init>":()V
#2 = Fieldref #6.#22 // Test.m:I
#3 = Double 3.14d
#5 = Fieldref #6.#23 // Test.PI:D
#6 = Class #24 // Test
#7 = Class #25 // java/lang/Object
#8 = Utf8 PI
#9 = Utf8 D
#10 = Utf8 m
#11 = Utf8 I
#12 = Utf8 <init>
#13 = Utf8 ()V
#14 = Utf8 Code
#15 = Utf8 LineNumberTable
#16 = Utf8 inc
#17 = Utf8 ()I
#18 = Utf8 <clinit>
#19 = Utf8 SourceFile
#20 = Utf8 Test.java
#21 = NameAndType #12:#13 // "<init>":()V
#22 = NameAndType #10:#11 // m:I
#23 = NameAndType #8:#9 // PI:D
#24 = Utf8 Test
#25 = Utf8 java/lang/Object
{
public static double PI;
descriptor: D
flags: ACC_PUBLIC, ACC_STATIC
public Test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 1: 0
public int inc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: getfield #2 // Field m:I
4: iconst_1
5: iadd
6: ireturn
LineNumberTable:
line 5: 0
static {};
descriptor: ()V
flags: ACC_STATIC
Code:
stack=2, locals=0, args_size=0
0: ldc2_w #3 // double 3.14d
3: putstatic #5 // Field PI:D
6: return
LineNumberTable:
line 2: 0
}
SourceFile: "Test.java"
可以看出,javap已经将class文件中所有的内容解析出来了,并且以一种友好的方式展示出来。根据这个内容,看看我们手动解析的结果。
首先是一个方法符号引用,内容是7.21,查看结果,可以看到索引为7的常量是一个类符号引用,这个类符号引用的索引是25,然后看看索引是25的常量,是一个Utf8编码的字符串,内容是java/lang/Object。然后看看索引是21的常量,是一个NameAndType类型,这个常量的内容是12:13,索引是12的内容是<init>,索引是13的内容是()V,这表示了一个方法的名称、参数类型和返回类型,具体的含义在后面的方法表中介绍。这样,这个方法的内容就是java/lang/Object."<init>":()V。
看起来这个<init>并没有在Java程序中出现,还有一些内容也没有在Java程序中出现,比如“I”、“V”、“LineNumberTable”等。这是自动生成的常量,但它们会被后面即将介绍到的字段表、方法表和属性表引用到,用来描述一些不方便使用固定字节表示的内容。
最后,给出14种常量项的结构:
常量 |
项目 |
类型 |
含义 |
CONSTANT_Utf8_info |
tag |
U1 |
1 |
length |
U2 |
UTF-8编码的字符串的长度 |
|
bytes |
U1 |
长度为length的UTF-8编码的字符串 |
|
CONSTANT_Integer_info |
Tag |
U1 |
3 |
bytes |
U4 |
按照高位在前的int值 |
|
CONSTANT_Float_info |
tag |
U1 |
4 |
bytes |
U4 |
按照高位在前的float值 |
|
CONSTANT_Long_info |
tag |
U1 |
5 |
bytes |
U8 |
按照高位在前的long值 |
|
CONSTANT_Double_info |
tag |
U1 |
6 |
bytes |
U8 |
按照高位在前的double值 |
|
CONSTANT_Class_info |
tag |
U1 |
7 |
index |
U2 |
指向全限定名常量项的索引 |
|
CONSTANT_String_info |
Tag |
U1 |
8 |
index |
U2 |
指向字符串字面量的索引 |
|
CONSTANT_Fieldref_info |
tag |
U1 |
9 |
index |
U2 |
指向声明字段的类或接口描述符CONSTANT_Class_info的索引项 |
|
index |
U2 |
指向字段描述符CONSTANT_NameAndType_info的索引项 |
|
CONSTANT_Methodref_info |
tag |
U1 |
10 |
index |
U2 |
指向声明方法的类描述符CONSTANT_Class_info的索引项 |
|
index |
U2 |
指向名称及类描述符CONSTANT_NameAndType_info的索引项 |
|
CONSTANT_InterfaceMethod_info |
tag |
U1 |
11 |
index |
U2 |
指向声明方法的接口描述符COSNTANT_Class_info的索引项 |
|
index |
U2 |
指向名称及类描述符CONSTANT_NameAndType_info的索引项 |
|
CONSTANT_NameAndType_info |
tag |
U1 |
12 |
index |
U2 |
指向该字段或方法名称常量池的索引 |
|
index |
U2 |
指向该字段或方法描述符常量池的索引 |
|
CONSTANT_MethodHandle_info |
tag |
U1 |
15 |
reference_kind |
U2 |
值必须在1-9之间,决定了方法句柄的类型,方法句柄累心的值表示方法句柄的字节码行为 |
|
reference_ index |
U2 |
值必须是对常量池的有效索引 |
|
CONSTANT_MethodType_info |
tag |
U1 |
16 |
descriptor_index |
U2 |
值必须是对常量池的有效索引,常量池在改索引处的项必须是CONSTANT_Utf8_info结构,表示方法的描述符 |
|
CONSTANT_InvokeDynamic_info |
tag |
U1 |
18 |
bootstrap_method_attrindex |
U2 |
值必须是对当前Class文件中引导方法表的bootstrap_methods[]数组的有效索引 |
|
name_and_type_index |
U2 |
值必须是对当前常量池的有效索引,常量池在该索引处的项必须是COSTANT_NameAndType_info结构,表示方法名和方法描述符 |
4、访问标志
常量池结束后紧接着的两个字节代表访问标志,用来标识一些类或接口的访问信息,包括:这个Class是类还是接口;是否定义为public;是否定义为abstract;如果是类的话,是否被声明为final等。具体的标志位以及含义如下表:
标志名称 |
标志值 |
含义 |
ACC_PUBLIC |
0x0001 |
是否是public |
ACC_FINAL |
0x0010 |
是否被声明为final,只有类可以设置 |
ACC_SUPER |
0x0020 |
是否允许使用invokespecial字节码指令的新语义,JDK1.0.2之后编译出来的类的这个标志默认为真 |
ACC_INTERFACE |
0x0200 |
标识是一个接口 |
ACC_ABSTRACT |
0x0400 |
是否是abstract,对于接口和抽象类来说为真,其他类都为假 |
ACC_SYNITHETIC |
0x1000 |
标识这个类并非由用户代码产生 |
ACC_ANNOTATION |
0x2000 |
标识这是一个注解 |
ACC_ENUM |
0x4000 |
标识这是一个枚举类 |
这部分的二进制流内容如下:
由于access_flags是两个字节大小,一共有十六个标志位可以使用,当前仅仅定义了8个,没有用到的标志位都是0。对于一个类来说,可能会有多个访问标志,这时就可以对照上表中的标志值取或运算的值。拿上面那个例子来说,它的访问标志值是0x0021,查表可知,这是ACC_PUBLIC和ACC_SUPER值取或运算的结果。所以Test这个类的访问标志就是ACC_PUBLIC和ACC_SUPER,这一点我们可以在javap得到的结果中验证。
5、类索引、父类索引与接口索引集合
在访问标志access_flags后接下来就是类索引(this_class)和父类索引(super_class),这两个数据都是u2类型的,而接下来的接口索引集合是一个u2类型的集合,class文件由这三个数据项来确定类的继承关系。由于Java中是单继承,所以父类索引只有一个;但Java类可以实现多个接口,所以接口索引是一个集合。
类索引用来确定这个类的全限定名,这个全限定名就是说一个类的类名包含所有的包名,然后使用"/"代替"."。比如Object的全限定名是java.lang.Object。父类索引确定这个类的父类的全限定名,除了Object之外,所有的类都有父类,所以除了Object之外所有类的父类索引都不为0.接口索引集合存储了implements语句后面按照从左到右的顺序的接口。
类索引和父类索引都是一个索引,这个索引指向常量池中的CONSTANT_Class_info类型的常量。然后再CONSTANT_Class_info常量中的索引就可以找到常量池中类型为CONSTANT_Utf8_info的常量,而这个常量保存着类的全限定名。
这部分的二进制流内容如下:
以上面的例子来说,this_class的值是0x0006,即十进制的6,指向的CONSTANT_Class_info中的索引是24,常量池中索引是24的CONSTANT_Utf8_info的常量是一个长度为4的字符串,值是“Test”。这样就解析到了这个类的全限定名,类的父类的全限定名也可以这样解析。下图是解析过程:
由于这个类没有实现接口,所以接口索引集合的容量计数是0。如果容量计数是0,就不需要存储接口的信息。
6、字段表集合
字段表用来描述接口或类中声明的变量。字段包括类级变量和实例级变量,但不包括方法内变量。所谓的类级变量就是静态变量,这个变量不属于这个类的任何实例,可以不用定义类实例就可以使用;实例级变量不是静态变量,是和类实例相关联的,需要定义类实例才能使用。
那么,声明一个变量需要哪些信息呢?有:字段的作用域(public、private和protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final修饰符)、并发可见性(volatile修饰符)、是否可被序列化(transient修饰符)、字段的数据类型(基本类型、对象、数组)以及字段名称。包含的信息有点多,不过不需要的可以不写。这些信息中,各个修饰符可以用布尔值表示。而字段叫什么名字、字段被定义为什么类型数据都是无法固定的,只能用常量池中的常量来表示。下面是字段表的格式:
类型 |
名称 |
数量 |
U2 |
access_flags |
1 |
U2 |
name_index |
1 |
U2 |
descriptor_index |
1 |
U2 |
attributes_count |
1 |
attribute_info |
attributes |
attributes_count |
其中的字段修饰符access_flags,和类中的access_flags类似,对于字段来说可以设置的标志位及含义如下:
标志名称 |
标志值 |
含义 |
ACC_PUBLIC |
0x0001 |
字段是否是public |
ACC_PRIVATE |
0x0002 |
字段是否是private |
ACC_PROTECTED |
0x0004 |
字段是否是protected |
ACC_STATIC |
0x0008 |
字段是否是static |
ACC_FINAL |
0x0010 |
字段是否是final |
ACC_VOLATILE |
0x0040 |
字段是否是volatile |
ACC_TRANSIENT |
0x0080 |
字段是否是transient |
ACC_SYNTHETIC |
0x1000 |
字段是否是由编译器自动产生的 |
ACC_ENUM |
0x4000 |
字段是否是enum |
显然,ACC_PUBLIC、ACC_PRIVATE和ACC_PROTECTED只能选择一个,ACC_FINAL和ACC_VOLATILE不能同时选择。接口中的字段必须有ACC_PUBLIC、ACC_STATIC和ACC_FINAL标志,这是Java语言本身的规则决定的。
access_flags给出了字段中所有可以用布尔值表示的修饰符,剩下的信息就是字段的名字、变量类型等信息。access_flags后面的是name_index和descriptor_index,前者是字段名的常量池索引,后者是字段描述符的常量池索引。name_index可以描述字段的名字,descriptor_index可以描述字段的数据类型。不过,对于方法的描述符来说就要复杂一些,因为一个方法除了返回值类型,还有参数类型,而且参数的个数还不确定。根据描述符规则,这些类型都使用一个大写字母来表示,如下表:
标识字符 |
含义 |
标识字符 |
含义 |
B |
byte |
J |
long |
C |
char |
S |
short |
D |
double |
Z |
boolean |
F |
float |
V |
void |
I |
int |
L |
对象类型,如Ljava/lang/Object |
对于数组类型,每一个维度将使用一个前置的“[”字符来描述。比如定义一个java.lang.String[][]类型的二维数组,将记录为"[[Ljava/lang/String",一个double数组"double[]"将标记为"[D"。
当描述符用来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号"()"内。比如方法void inc()的描述符是:()V。方法java.lang.String toString()的描述符是:()Ljava/lang/String。方法int indexOf(char[] source,int sourceOffset,int sourceCount,char[] target,int targetOffset,int targetCount,int fromIndex)的描述符是:([CII[CIII)I。
descriptor_info后面是属性信息,这会在后面属性表集合中介绍。
本部分的二进制流内容:
以上面的例子为例,前两个字节代表字段的个数,这里是2个。接下来就是具体的字段信息。第一个字段表内容是:0009 0008 0009 0000,首先访问标志是9,可以看出是ACC_PUBLIC和ACC_STATIC,是一个静态常量,name_index是8,指向的常量项是CONSTANT_Utf8_info,内容是PI,描述符是8,常量池中的常量是CONSTANT_Utf8_info,内容是D,即double类型。所以这个常量是:public static double PI。和我们声明的一样,不过还有一点就是,我们声明的PI还有一个值:3.14,这个数在常量池中可以找到,索引是3的常量,不过这个值是如何与PI关联起来的,后面会介绍。
同样的道理也能解析出第二个字段是:private int m。
字段表集合中不会列出从父类或接口中继承来的字段,但有可能会出现原本Java程序中没有的字段。比较典型的例子是内部类,为了在内部类中保持对外部类的访问性,会增加一个指向外部类实例的字段。另外,在Java语言中字段无法重载,也就是字段名不能重复,即使两个字段的数据类型、修饰符都不相同。不过对于字节码来说,如果两个字段的描述符不一致,那么就可以有重复的字段名。
7、方法表集合
在字段表集合中介绍了字段的描述符和方法的描述符,对于理解方法表有很大帮助。class文件存储格式中对方法的描述和对字段的描述几乎相同,方法表的结构也和字段表相同,这里就不再列出。不过,方法表的访问标志和字段的不同,列出如下:
标识名称 |
标志值 |
含义 |
ACC_PUBLIC |
0x0001 |
方法是否是public |
ACC_PRIVATE |
0x0002 |
方法是否是private |
ACC_PUBLICPROTECTED |
0x0004 |
方法是否是protected |
ACC_STATIC |
0x0008 |
方法是否是static |
ACC_FINAL |
0x0010 |
方法是否是final |
ACC_SYNCHRONIZED |
0x0020 |
方法是否是synchronized |
ACC_BRIDGE |
0x0040 |
方法是否是由编译器产生的桥接方法 |
ACC_VARARGS |
0x0080 |
方法是否接受不定参数 |
ACC_NATIVE |
0x0100 |
方法是否是native |
ACC_ABSTRACT |
0x0400 |
方法是否是abstract |
ACC_STRICTFP |
0x0800 |
方法是否是strictfp |
ACC_SYNTHETIC |
0x1000 |
方法是否是由编译器自动产生的 |
本部分二进制流内容:
从这里可以看到,方法表集合中一共有3个方法,按照字段的解析方法,可以得到每个方法的定义。分别是:
public void <init>();
public int inc();
static void <clinit>();
可是我们的代码里只定义了一个inc方法,怎么会多出来两个方法?
其实,Java类都要有一个构造方法,如果没有的话编译器会自动构造一个无参的构造方法,就是上面的第一个名叫<init>的方法;同时,如果一个类中含有静态代码块或者静态变量,那么就需要首先执行类的构造方法,来执行静态代码块和初始化静态变量,这就是上面的第三个名为<clinit>的方法。
不过,方法比字段还多了方法体呢,那方法体中的代码哪去了?
在每一个方法表中descriptor_index后描述属性的时候,0001表明属性的个数为1,再后面的000E是指向常量池中的CONSTANT_Utf8_info常量,内容是Code,说明后面属性中存放的就是方法体里面编译后的字节码指令。
在Java中,要重载一个方法,除了要与原方法具有相同的方法名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是特征签名只包含参数个数和类型,并不包含返回值类型,所以Java语言中是无法仅仅依靠返回值的不同来对一个方法重载的。但是在class文件格式中,特征签名还包括返回值类型,也就是说只有返回值类型不同的两个方法也可以存在。这一点在泛型中编译后类型擦除后生成的桥接方法上有所体现。不过这里就不过多介绍了。
8、属性表集合
属性表在前面出现了多次,在class文件、字段表和方法表都可以携带自己的属性表集合,来描述某些场景专有的信息。
与class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制比较少,不要求严格的顺序,只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自定义的属性信息,Java虚拟机会在运行时忽略掉那些不认识的信息。为了能正确解析class文件,《Java虚拟机规范(第二版)》中预定义了9项虚拟机应当识别的属性。现在,属性已经达到了21项。具体信息如下表,这里仅对常见的属性做介绍:
属性名称 |
使用位置 |
含义 |
Since |
Code |
方法表 |
Java代码编译后的字节码指令 |
《Java虚拟机规范(第二版)》 |
ConstantValue |
字段表 |
final关键字定义的常量值 |
《Java虚拟机规范(第二版)》 |
Deprecated |
类、方法表、字段表 |
被声明为deprecated的方法和字段 |
《Java虚拟机规范(第二版)》 |
Exception |
方法表 |
方法抛出的异常 |
《Java虚拟机规范(第二版)》 |
EnclosingMethod |
类文件 |
仅当一个类为局部类或匿名类时才能拥有这个属性,来标识这个类所在的外围方法 |
《Java虚拟机规范(第二版)》 |
InnerClasses |
类文件 |
内部类列表 |
《Java虚拟机规范(第二版)》 |
LineNumberTable |
Code属性 |
Java源码的行号与字节码指令的对应关系 |
《Java虚拟机规范(第二版)》 |
LocalVariableTable |
Code属性 |
方法的局部变量描述 |
《Java虚拟机规范(第二版)》 |
StackMapTable |
Code属性 |
供新的类型检查验证器检查和处理目标方法的局部变量和操作数栈所需要的类型检查 |
JDK 1.6 |
Signature |
类、方法表、字段表 |
用于保存泛型中的类型信息 |
JDK 1.5 |
SourceFile |
类文件 |
记录源文件名称 |
《Java虚拟机规范(第二版)》 |
SourceDebugExtension |
类文件 |
用于存储额外的调试信息 |
《Java虚拟机规范(第二版)》 |
Synthetic |
类、方法表、字段表 |
标识方法或字段为编译器自动生成 |
《Java虚拟机规范(第二版)》 |
LocalVariableTypeTable |
类 |
使用特征签名代替描述符,为了描述泛型参数化类型 |
JDK 1.5 |
RuntimeVisibleAnnotations |
类、方法表、字段表 |
为动态注解提供支持 |
JDK 1.5 |
RuntimeInvisibleAnnotations |
类、方法表、字段表 |
与RuntimeVisibleAnnotations作用相反 |
JDK 1.5 |
RuntimeVisbleParameterAnnotations |
方法表 |
与RuntimeVisibleAnnotations类似 |
JDK 1.5 |
RuntimeInvisbleParameterAnnotations |
方法表 |
与RuntimeInvisibleAnnotations类似 |
JDK 1.5 |
AnnotationDefault |
方法表 |
用于记录注解类元素的默认值 |
JDK 1.5 |
BootstrapMethods |
类文件 |
用于保存invokedynamic指令引用的引导方法限定符 |
JDK 1.7 |
从上表可以看出,属性表集合存在的位置也是不确定的,不仅可以存储在class文件结尾处,还可以作为数据项存在于类、方法表集合和字段表集合中。对于存在于class类文件中的属性表集合很好理解,毕竟在开头的class文件结构图中的最后一部分就是属性表集合,这时属性表集合作为构成class文件结构的一个大部分。剩下的存在于类中、方法表集合与字段表集合中的属性表集合,其实是作为它们的一个数据项存在的。
存在于类中的属性表集合,存储了关于这个类的一些信息。比如这个类是否是过时的(Deprecated)、在泛型中保存类的类型参数(由于生成class文件后会进行类型擦除,Java中的泛型是一种伪泛型)和动态注解等信息;存放在方法表集合中的属性表集合存储了关于方法的信息,最主要的就是Code属性,存储了字节码指令;存放于字段表集合中的属性表集合存储了关于字段的信息,我们这里的例子没有涉及到字段的属性,不过当在类中定义了静态常量(static final)并且这个常量有初始值时会将这个值作为属性存储在字段表中的属性表集合中。
由于属性表集合的限制较小,每个属性都会有自己的格式,因此class文件对于属性的格式要求也比较宽松,只需要满足一些特定的条件即可。下表是属性的结构:
类型 |
名称 |
数量 |
U2 |
attribute_name_index |
1 |
U4 |
attribute_length |
1 |
U1 |
info |
attribute_length |
从上表可以看出,class文件规定的属性格式只有前6个字节:两个字节的属性名称的索引和4个字节的属性长度,接下来就要按照这个长度存储属性值了。这样的宽松格式使得属性表的结构可以多样变化,甚至可以在属性的内容中再加入一个属性,比较常用的就是方法表集合中的Code属性,在Code属性中还有LineNumberTable属性和LocalVariableTable属性等。
接下来就简单介绍一下常用的属性。
(1)Code属性
最常用的属性恐怕就是Code属性了,因为大多数的方法都会有编译后的字节码指令,这些指令就存储在方法表中的Code属性中。如果一个Java程序的信息可以分为代码(方法体中的代码)和元数据(包括类、字段、方法定义以及其它信息),那么Code属性存储的就是代码,其它所有的结构存储的都是元数据。不过并非所有的方法表都有这个Code属性,比如接口或抽象类中的方法表就不存在Code属性(JDK 1.8中的接口也可以定义方法了)。Code属性的结构如下:
类型 |
名称 |
数量 |
U2 |
attribute_name_index |
1 |
U4 |
attribute_lenght |
1 |
U2 |
max_stack |
1 |
U2 |
max_locals |
1 |
U4 |
code_length |
1 |
U1 |
code |
code_length |
U2 |
exception_table_length |
1 |
exception_info |
exception_table |
exception_table_length |
U2 |
attributes_count |
1 |
attribute_info |
attributes |
attributes_count |
其中attribute_name_index和attribute_length前面已经介绍过了。
max_stack代表了操作数栈的最大深度。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机执行时需要根据这个值来分配栈帧中的操作栈深度。
max_locals代表了局部变量表所需要的存储空间。在这里,max_locals的单位是slot,在之前的文章中了解了HotSpot虚拟机在分配对象时使用的单位就是slot。方法参数(包括隐式参数this)、显式异常处理器的参数(try-catch块中catch块中定义的异常)以及方法体中定义的局部变量都需要局部变量表来存放。需要注意的是,由于局部变量表中的slot可以重用,所以并不是所有的局部变量的总slot就是max_locals。编译器会根据变量的作用域来分配slot给各个变量使用,然后计算max_locals的大小。
code_length和code用来存储字节码指令。Java的字节码指令的长度都是一个字节,即最多可以有256个指令,实际上一共有大约200条指令。对于字节码指令这里不过多介绍。
(2)SourceFile属性
本部分的class内容:
SourceFile属性记录生成这个class文件的源码文件名称。在上面的数据中,0001表示属性表集合中有一个属性,0013(即十进制19)是属性名的索引值,查找常量池可以知道是SourceFile,00000002是这个属性的长度,即两个字节,最后的两个字节就是这个属性的内容,是一个常量池索引,0014,十进制20,结果是Test.java。
还有很多常用的属性,不过由于篇幅关系就不列出来了。
这样,我们就分析完了整个Class文件的结构,了解这个文件结构对于理解类加载机制很有帮助。