Class类文件结构
在了解了Java内存的散布、HotSpot虚拟机对Java对象的管理和Java垃圾搜集机制以后,我们大致了解了Java自动内存管理的部份。接下来,就应当看看Java的类加载机制,看看虚拟机是如何将Java代码文件编译后的class文件加载到Java内存中的。 Java是1门平台无关语言,只要有Java的运行环境,编写的代码可以运行在各种机器上,做到了“1次编码、处处运行”的目的。为了到达平台无关,Sun公司和其它虚拟机提供商发布了许多可以运行不同平台上的虚拟机,这些虚拟机都可以载入和履行同1种平台无关的字节码,到达了平台无关的目的,以下图: 不过,java虚拟机不但可以运行Java程序,在设计之初就实现了让其他语言运行在Java虚拟机上的可能性,只要程序编译以后能生成符合虚拟机规范的class文件便可。现在,已有很多语言可以运行在java虚拟机上了,比如Clojure、Groovy、JRuby、Jython、Scala等。这样,java虚拟机也实现了语言无关性。上面的图就变成了这个模样: 可以看出,语言无关性和平台无关性的关键在于class字节码文件。Java虚拟机规范要求class文件中使用许多强迫性和若干其他辅助信息。接下来就详细了解1下class字节码文件的结构,固然,这里主要以Java语言为主。 1、概述class文件是1组以8位字节为基础的2进制流,各个数据项目严格依照顺序紧凑地排列在class文件中,中间没有任何分隔符,这点和png、jpg等图片文件格式类似。当遇到需要占用8位字节以上空间的数据项时,则会依照1定的字节顺序分隔为若干个8位字节进行存储。 Java虚拟机规范规定class文件格式采取1种类似于C语言结构体的伪结构来存储数据,这类伪结构只有两种数据类型:无符号数和表。其中无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节。无符号数可以用来描写数字、索引援用、数量值或依照utf⑻编码构成的字符串值。而表是由多个无符号数或其他表构成的复合数据结构,所有的表都以“_info”结尾。表用于描写有层次关系的复合结构的数据,其实,全部class文件就是1张表。它的整体结构以下图: 在这张图中,每行表示两个字节长度,依照从上到下、从左到右的顺序描写了class文件的结构。其中,浅色彩的部份是无符号数,深色彩的部份是表。下面以表格的情势详细描写1下具体的信息:
不管是无符号数还是表,当需要描写同1类型但数量不定的多个数据时,常常会用到1个前置的容量计数器(表中以“_count”结尾的项)加上若干个连续的数据项的情势,这时候称这1系列连续的某1类型的数据为某1类型的集合。 为了介绍各个数据项的含义,这里以1个简单的Java程序为例,代码以下: public class Test{
public static double PI=3.14;
private int m;
public int inc(){
return m+1;
}
} 在命令行下使用javac编译就能够得到class文件,以下: 接下来看看各项的具体含义。 2、魔数与Class文件的版本这部份的2进制流内容: class文件的头4个字节称为魔数,它的唯1作用就是肯定这个文件时候是1个能被虚拟机接受的class文件。很多图片格式都用1个魔数来标识文件类型,比如png和jpg等。在java的class文件中,这个数是0xcafebabe。 接下来就是class文件的版本号,第5、6个字节是次版本号,第7、8个字节是主版本号。在这里,次版本号是0,主版本号是52,(106进制是34)。Java的版本号是从45开始的,JDK1.1以后的每个JDK大版本发布主版本号向上加1,高版本的JDK能向下兼容低版本的JDK。 3、常量池紧接着主版本号的就是常量池,常量池可以理解为class文件的资源仓库,它是class文件结构中与其它项目关联最多的数据类型,也是占用class文件空间最大的数据项目之1,也是class文件中第1个出现的表类型数据项目。 由于常量池中常量的数量不是固定的,所以常量池入口需要放置1项u2类型的数据,代表常量池中的容量计数。不过,这里需要注意的是,这个容器计数是从1开始的而不是从0开始,也就是说,常量池中常量的个数是这个容器计数⑴。将0空出来的目的是满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不援用任何1个常量池项目”的含义。class文件中只有常量池的容量计数是从1开始的,对其它集合类型,比如接口索引集合、字段表集合、方法表集合等的容量计数都是从0开始的。 常量池中主要寄存两大类常量:字面量和符号援用。字面量比较接近Java语言的常量概念,如文本字符串、声明为final的常量等。而符号援用则属于编译原理方面的概念,它包括3方面的内容:
Java代码在进行javac编译的时候其实不像C和C++那样有连接这1步,而是在虚拟机加载class文件的时候进行动态连接。也就是说,在class文件中不会保存各个方法、字段的终究内存布局信息,因此这些字段、方法的符号援用不经过运行期转换的话没法得到真实的内存入口地址,虚拟机也就没法使用。当虚拟机运行时,需要从常量池取得对应的符号援用,再在类创建时或运行时解析、翻译到具体的内存地址中。 常量池中的每项都是1个表,在JDK1.7之前有11中结构不同的表结构,在JDK1.7中为了更好的支持动态语言调用,又增加了3种(CONSTANT_MethodHandle_info、CONSTANT_MethodType_info和CONSTANT_InvokeDynamic_info)。不过这里不会介绍这3种表数据结构。 这14个表的开始第1个字节是1个u1类型的tag,用来标识是哪种常量类型。这14种常量类型所代表的含义以下:
本例的常量池部份以下: 蓝色彩覆盖的是常量池部份,可以看到这部份的内容非常多。由于常量池中的常量比较多,每中常量还有自己的结构,致使常量池的结构非常复杂,这里也仅仅是简单解析两个例子。 由class文件结构图可知,常量池的开头两个字节0x001A是常量池的容量计数,这里是26,也就是说,这个常量池中有25个常量项。看看这个例子的第1项,容量计数后面的第1个字节标识这个常量的类型,是0A,即10,查表可知是类方法的符号援用,这个常量表的结构以下:
依照这个结构,可以知道name_index是7(0x0007),descriptor_index是21(0x0015)。这都是1个索引,指向常量池中的其他常量,其中name描写了这个方法的名称,descriptor描写了这个方法的访问标志(比如public、private等)、参数类型和返回类型。 接下来的tag是9,可知是1个字段的符号援用,它的结构和方法的结构类似,只不过接下来的两个字节表示的是声明这个字段的类或接口的索引,最后的两个字节表示的是这个字段的类型和名字CONSTANT_NameAndType索引,这两个索引分别是6和22,在后面会验证这几个索引。 根据这两个例子可以看出,要准确的描写1个类中所声明的字段和方法的所有信息,仅仅1个符号援用是不够的,还需要继续援用其他的常量池项目。 常量池中接下来的内容也能够这样解析,不过,JDK已提供了1个工具可以自动计算这些内容,使用javap -verbose命令可以快速的计算出class文件结构的内容,比如这样: javap -verbose Test 注意Test没有.java 或.class,它是解析Test.class文件的,所以使用前先用javac编译Java文件。结果以下: Classfile /C:/Users/Liu Guoqiang/Desktop/Test.class
Last modified 2016⑸⑵9; 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,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文件中所有的内容解析出来了,并且以1种友好的方式展现出来。根据这个内容,看看我们手动解析的结果。 首先是1个方法符号援用,内容是7.21,查看结果,可以看到索引为7的常量是1个类符号援用,这个类符号援用的索引是25,然后看看索引是25的常量,是1个Utf8编码的字符串,内容是java/lang/Object。然后看看索引是21的常量,是1个NameAndType类型,这个常量的内容是12:13,索引是12的内容是<init>,索引是13的内容是()V,这表示了1个方法的名称、参数类型和返回类型,具体的含义在后面的方法表中介绍。这样,这个方法的内容就是java/lang/Object."<init>":()V。 看起来这个<init>并没有在Java程序中出现,还有1些内容也没有在Java程序中出现,比如“I”、“V”、“LineNumberTable”等。这是自动生成的常量,但它们会被后面行将介绍到的字段表、方法表和属性表援用到,用来描写1些不方便使用固定字节表示的内容。 最后,给出14种常量项的结构:
4、访问标志常量池结束后紧接着的两个字节代表访问标志,用来标识1些类或接口的访问信息,包括:这个Class是类还是接口;是不是定义为public;是不是定义为abstract;如果是类的话,是不是被声明为final等。具体的标志位和含义以下表:
这部份的2进制流内容以下: 由于access_flags是两个字节大小,1共有106个标志位可使用,当前仅仅定义了8个,没有用到的标志位都是0。对1个类来讲,可能会有多个访问标志,这时候就能够对比上表中的标志值取或运算的值。拿上面那个例子来讲,它的访问标志值是0x0021,查表可知,这是ACC_PUBLIC和ACC_SUPER值取或运算的结果。所以Test这个类的访问标志就是ACC_PUBLIC和ACC_SUPER,这1点我们可以在javap得到的结果中验证。 5、类索引、父类索引与接口索引集合在访问标志access_flags后接下来就是类索引(this_class)和父类索引(super_class),这两个数据都是u2类型的,而接下来的接口索引集合是1个u2类型的集合,class文件由这3个数据项来肯定类的继承关系。由于Java中是单继承,所以父类索引只有1个;但Java类可以实现多个接口,所以接口索引是1个集合。 类索援用来肯定这个类的全限定名,这个全限定名就是说1个类的类名包括所有的包名,然后使用"/"代替"."。比如Object的全限定名是java.lang.Object。父类索引肯定这个类的父类的全限定名,除Object以外,所有的类都有父类,所以除Object以外所有类的父类索引都不为0.接口索引集合存储了implements语句后面依照从左到右的顺序的接口。 类索引和父类索引都是1个索引,这个索引指向常量池中的CONSTANT_Class_info类型的常量。然后再CONSTANT_Class_info常量中的索引就能够找到常量池中类型为CONSTANT_Utf8_info的常量,而这个常量保存着类的全限定名。 这部份的2进制流内容以下: 以上面的例子来讲,this_class的值是0x0006,即10进制的6,指向的CONSTANT_Class_info中的索引是24,常量池中索引是24的CONSTANT_Utf8_info的常量是1个长度为4的字符串,值是“Test”。这样就解析到了这个类的全限定名,类的父类的全限定名也能够这样解析。下图是解析进程: 由于这个类没有实现接口,所以接口索引集合的容量计数是0。如果容量计数是0,就不需要存储接口的信息。 6、字段表集合字段表用来描写接口或类中声明的变量。字段包括类级变量和实例级变量,但不包括方法内变量。所谓的类级变量就是静态变量,这个变量不属于这个类的任何实例,可以不用定义类实例就能够使用;实例级变量不是静态变量,是和类实例相干联的,需要定义类实例才能使用。 那末,声明1个变量需要哪些信息呢?有:字段的作用域(public、private和protected修饰符)、是实例变量还是类变量(static修饰符)、可变性(final修饰符)、并发可见性(volatile修饰符)、是不是可被序列化(transient修饰符)、字段的数据类型(基本类型、对象、数组)和字段名称。包括的信息有点多,不过不需要的可以不写。这些信息中,各个修饰符可以用布尔值表示。而字段叫甚么名字、字段被定义为何类型数据都是没法固定的,只能用常量池中的常量来表示。下面是字段表的格式:
其中的字段修饰符access_flags,和类中的access_flags类似,对字段来讲可以设置的标志位及含义以下:
明显,ACC_PUBLIC、ACC_PRIVATE和ACC_PROTECTED只能选择1个,ACC_FINAL和ACC_VOLATILE不能同时选择。接口中的字段必须有ACC_PUBLIC、ACC_STATIC和ACC_FINAL标志,这是Java语言本身的规则决定的。 access_flags给出了字段中所有可以用布尔值表示的修饰符,剩下的信息就是字段的名字、变量类型等信息。access_flags后面的是name_index和descriptor_index,前者是字段名的常量池索引,后者是字段描写符的常量池索引。name_index可以描写字段的名字,descriptor_index可以描写字段的数据类型。不过,对方法的描写符来讲就要复杂1些,由于1个方法除返回值类型,还有参数类型,而且参数的个数还不肯定。根据描写符规则,这些类型都使用1个大写字母来表示,以下表:
对数组类型,每个维度将使用1个前置的“[”字符来描写。比如定义1个java.lang.String[][]类型的2维数组,将记录为"[[Ljava/lang/String",1个double数组"double[]"将标记为"[D"。 当描写符用来描写方法时,依照先参数列表,后返回值的顺序描写,参数列表依照参数的严格顺序放在1组小括号"()"内。比如方法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进制流内容: 以上面的例子为例,前两个字节代表字段的个数,这里是2个。接下来就是具体的字段信息。第1个字段表内容是:0009 0008 0009 0000,首先访问标志是9,可以看出是ACC_PUBLIC和ACC_STATIC,是1个静态常量,name_index是8,指向的常量项是CONSTANT_Utf8_info,内容是PI,描写符是8,常量池中的常量是CONSTANT_Utf8_info,内容是D,即double类型。所以这个常量是:public static double PI。和我们声明的1样,不过还有1点就是,我们声明的PI还有1个值:3.14,这个数在常量池中可以找到,索引是3的常量,不过这个值是如何与PI关联起来的,后面会介绍。 一样的道理也能解析出第2个字段是:private int m。 字段表集合中不会列出从父类或接口中继承来的字段,但有可能会出现本来Java程序中没有的字段。比较典型的例子是内部类,为了在内部类中保持对外部类的访问性,会增加1个指向外部类实例的字段。另外,在Java语言中字段没法重载,也就是字段名不能重复,即便两个字段的数据类型、修饰符都不相同。不过对字节码来讲,如果两个字段的描写符不1致,那末就能够有重复的字段名。 7、方法表集合在字段表集合中介绍了字段的描写符和方法的描写符,对理解方法表有很大帮助。class文件存储格式中对方法的描写和对字段的描写几近相同,方法表的结构也和字段表相同,这里就不再列出。不过,方法表的访问标志和字段的不同,列出以下:
本部份2进制流内容: 从这里可以看到,方法表集合中1共有3个方法,依照字段的解析方法,可以得到每一个方法的定义。分别是: public void <init>(); public int inc(); static void <clinit>(); 可是我们的代码里只定义了1个inc方法,怎样会多出来两个方法? 其实,Java类都要有1个构造方法,如果没有的话编译器会自动构造1个无参的构造方法,就是上面的第1个名叫<init>的方法;同时,如果1个类中含有静态代码块或静态变量,那末就需要首先履行类的构造方法,来履行静态代码块和初始化静态变量,这就是上面的第3个名为<clinit>的方法。 不过,方法比字段还多了方法体呢,那方法体中的代码哪去了? 在每个方法表中descriptor_index后描写属性的时候,0001表明属性的个数为1,再后面的000E是指向常量池中的CONSTANT_Utf8_info常量,内容是Code,说明后面属性中寄存的就是方法体里面编译后的字节码指令。 在Java中,要重载1个方法,除要与原方法具有相同的方法名称以外,还要求必须具有1个与原方法不同的特点签名,特点签名就是1个方法中各个参数在常量池中的字段符号援用的集合,也就是特点签名只包括参数个数和类型,其实不包括返回值类型,所以Java语言中是没法仅仅依托返回值的不同来对1个方法重载的。但是在class文件格式中,特点签名还包括返回值类型,也就是说只有返回值类型不同的两个方法也能够存在。这1点在泛型中编译后类型擦除后生成的桥接方法上有所体现。不过这里就不过量介绍了。 8、属性表集合属性表在前面出现了屡次,在class文件、字段表和方法表都可以携带自己的属性表集合,来描写某些场景专有的信息。 与class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制比较少,不要求严格的顺序,只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写入自定义的属性信息,Java虚拟机会在运行时疏忽掉那些不认识的信息。为了能正确解析class文件,《Java虚拟机规范(第2版)》中预定义了9项虚拟机应当辨认的属性。现在,属性已到达了21项。具体信息以下表,这里仅对常见的属性做介绍:
相关内容
|