本文是基于周志明的《深入理解Java虚拟机》
Class类文件结构
Class文件
1).是一组以8字节为基础单位的二进制流,
2).各个数据项目严格按照顺序紧凑排列在class文件中,
3).中间没有任何分隔符,这使得class文件中存储的内容几乎是全部程序运行的程序。
Java虚拟机规范规定,Class文件格式采用类似C语言结构体的伪结构来存储数据,这种结构只有两种数据类型:无符号数和表。
无符号数
属于基本数据类型,主要可以用来描述数字、索引符号、数量值或者按照UTF-8编码构成的字符串值,大小使用u1、u2、u4、u8分别表示1字节、2字节、4字节和8字节。
表
是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有的表都习惯以“_info”结尾。
表主要用于描述有层次关系的复合结构的数据,比如方法、字段。需要注意的是class文件是没有分隔符的,所以每个的二进制数据类型都是严格定义的。具体的顺序定义如下:
在class文件中,主要分为魔数、Class文件的版本号、常量池、访问标志、类索引(还包括父类索引和接口索引集合)、字段表集合、方法表集合、属性表集合。
--------------------------------------------------------------------------------------------------------------------------------------
测试类:
package com.changwen.test;用winhex16进制工具打开后结果如下
public class TestClass {
private int m;
public int inc() {
return m+1;
}
}
1、魔数
1).每个Class文件的头4个字节称为魔数(Magic Number)
2).唯一作用是用于确定这个文件是否为一个能被虚拟机接受的Class文件。
3).Class文件魔数的值为0xCAFEBABE。如果一个文件不是以0xCAFEBABE开头,那它就肯定不是Java class文件。
很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如gif或jpeg等在文件头中都存有魔数。使用魔术而不是使用扩展名是基于安全性考虑的——扩展名可以随意被改变!!!
2、Class文件的版本号
紧接着魔数的4个字节是Class文件版本号.
版本号又分为:
1).次版本号(minor_version): 前2字节用于表示次版本号
2).主版本号(major_version): 后2字节用于表示主版本号。
这个的版本号是随着jdk版本的不同而表示不同的版本范围的。Java的版本号是从45开始的。如果Class文件的版本号超过虚拟机版本,将被拒绝执行。
0X0034(对应十进制的50):JDK1.8
0X0033(对应十进制的50):JDK1.7
0X0032(对应十进制的50):JDK1.6
0X0031(对应十进制的49):JDK1.5
0X0030(对应十进制的48):JDK1.4
0X002F(对应十进制的47):JDK1.3
0X002E(对应十进制的46):JDK1.2
0X表示16进制
3、常量池
紧接着魔数与版本号之后的是常量池入口.
常量池简单理解为class文件的资源从库
1).是Class文件结构中与其它项目关联最多的数据类型
2).是占用Class文件空间最大的数据项目之一
3).是在文件中第一个出现的表类型数据项目。
由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值(constant_pool_count)。
从1开始计数。Class文件结构中只有常量池的容量计数是从1开始的
第0项腾出来满足后面某些指向常量池的索引值的数据在特定情况下需要表达"不引用任何一个常量池项目"的意思,这种情况就可以把索引值置为0来表示。但尽管constant_pool列表中没有索引值为0的入口,缺失的这一入口也被constant_pool_count计数在内。例如,当constant_pool中有14项,constant_poo_count的值为15。
常量池之中主要存放两大类常量:
1).字面量: 比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等
2).符号引用: 属于编译原理方面的概念,包括了下面三类常量:
①.类和接口的全限定名
②.字段的名称和描述符
③.方法的名称和描述符
Java代码在进行Java编译的时候,并不像C和C++那样有"连接"这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法和字段的最终内存布局信息,因此这些字段和方法的符号引用不经过转换的话是无法被虚拟机使用的。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析并翻译到具体的内存地址之中。
constant_pool_count:占2字节,本例为0x0016,转化为十进制为22,即说明常量池中有21个常量(只有常量池的计数是从1开始的,其它集合类型均从0开始),索引值为1~22。第0项常量具有特殊意义,如果某些指向常量池索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可以将索引值置为0来表示
constant_pool:表类型数据集合,即常量池中每一项常量都是一个表,共有14种(JDK1.7前只有11种)结构各不相同的表结构数据。这14种表都有一个共同的特点,即均由一个u1类型的标志位开始,可以通过这个标志位来判断这个常量属于哪种常量类型,常量类型及其数据结构如下表所示:
省略了显示结果的后半部分,这里可以看到总共有21个常量,并且可以看到常量的类型,如果常量中保存的为索引值(#),也会提示索引指向常量的具体内容(//后的内容),当然其中也包含了很多特殊的符号(如:()V),这些将会在后面的“六,字段表集合”与“七,方法表集合”中进行说明
3.1、符号引用与直接引用的关联
符号引用是一组符号,用来描述所引用的目标,符号是以任何形式存在的字面量。对于符号引用Java虚拟机并没有严格的限制。规定只需要使用的时候能够无歧义定位到目标就可以。常量池存在于Class文件中,而Class文件是必须首先通过Java虚拟机的类加载机制加载到内存中(确切的说是方法区这个内存区域,回顾一下,方法区存放的主要是对象的实例,这个Class文件是虚拟机对外接受访问的接口)。符号引用属于常量池中的内容,那么是不是说符号引用的目标已经加载到内存中了呢?答案是否定的,因为符号引用与虚拟机的内存布局无关,符号引用的目标并不一定已经加载到内存中了。
直接引用可以是直接指向引用目标的指针、相对偏移量或者是一个能够间接定位到目标的句柄。直接引用是和虚拟机的内存布局有关的,同一个符号引用在不同的虚拟机上翻译的直接引用一般是不同的。如果有了直接引用,那么引用的目标必定是存在内存中的。
4、访问标志(2字节)
常量池之后的数据结构是访问标志(access_flags),
这个标志主要用于识别一些类或接口层次的访问信息,主要包括:
1).这个Class是类还是接口
2).是否定义public
3).是否定义abstract类型
4).如果是类的话是否被声明为final等
具体的标志访问如下:
标志名称 |
标志值 |
含义 |
ACC_PUBLIC |
0x0001 |
是否为public类型 |
ACC_FINAL |
0x0010 |
是否被声明为final,只有类可设置 |
ACC_SUPER |
0x0020 |
是否允许使用invokespecial字节码指令,JDK1.2以后编译出来的类这个标志为真 |
ACC_INTERFACE |
0x0200 |
标识这是一个接口 |
ACC_ABSTRACT |
0x0400 |
是否为abstract类型,对于接口和抽象类,此标志为真,其它类为假 |
ACC_SYNTHETIC |
0x1000 |
标识别这个类并非由用户代码产生 |
ACC_ANNOTATION |
0x2000 |
标识这是一个注解 |
ACC_ENUM |
0x4000 |
标识这是一个枚举 |
access_flags一共有16个标志位可以使用,当前只定义了其中8个(JDK1.5增加后面3种),没有使用到标志位一律为0。
由于TestClass只是一个被public修饰的普通Java类,并使用JDK1.2之后的编译器进行编译,因此测试类的访问标志为ACC_PUBLIC | ACC_SUPER = 0x0001 | 0x0020 = 0x0021
5、类索引、父类索引和接口索引集合
这三项数据主要用于确定这个类的继承关系。
其中类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引(interface)集合是一组u2类型的数据。
类索引(this_class),用于确定这个类的全限定名,占2字节
父类索引(super_class),用于确定这个类父类的全限定名(Java语言不允许多重继承,故父类索引只有一个。除了java.lang.Object类之外所有类都有父类,故除了java.lang.Object类之外,所有类该字段值都不为0),占2字节
接口索引计数器(interfaces_count),占2字节。如果该类没有实现任何接口,则该计数器值为0,并且后面的接口的索引集合将不占用任何字节,
接口索引集合(interfaces),一组u2类型数据的集合。用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句(如果该类本身为接口,则为extends语句)后的接口顺序从左至右排列在接口的索引集合中
this_class、super_class与interfaces按顺序排列在访问标志之后,它们中保存的索引值均指向常量池中一个CONSTANT_Class_info类型的常量,通过这个常量中保存的索引值可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串
测试类对应的this_class的值为0x0003,即常量池中第3个常量,super_class的值为0x0004,即常量池中的第4个常量,interfaces_counts的值为0x0000,故接口索引集合大小为0
6、字段表集合
fields_count:字段表计数器,即字段表集合中的字段表数据个数,占2字节。本测试类其值为0x0001,即只有一个字段表数据,也就是测试类中只包含一个变量(不算方法内部变量)
fields:字段表集合,一组字段表类型数据的集合。字段表用于描述接口或类中声明的变量,包括类级别(static)和实例级别变量,不包括在方法内部声明的变量
在Java中一般通过如下几项描述一个字段:字段作用域(public、protected、private修饰符)、是类级别变量还是实例级别变量(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,其值为0X0002,由下表的标志位可知这个字段由private修饰
标志名称 |
标志值 |
含义 |
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 |
name_index代表字段的简单名称(参见备注2),占2字节,是一个对常量池的引用 。本例测试类对应的值为0x0005,即常量池中第5个常量
descriptor_index代表代表参数的描述符(参见备注3),占2个字节,是一个对常量池的引用,其值为0x0006,即常量池中第6个常量
综上,可以推断出源代码定义的字段为(和测试类完全一样):
private int m;字段表包含的固定数据项到descriptor_index结束,之后跟随一个属性表集合用于存储一些附加信息:
attributes_count(属性计数器,占2字节。本例的测试类值为0x0000,对应字段m,它的属性表计数器为0,所以该字段没有额外需要描述的信息)
attributes(属性表集合,详细说明见后面“八、属性表集合”)
字段表集合中不会列出从父类或父接口中继承的字段,但是可能列出原本Java代码之中不存在的字段,如:内部类为了保持对外部类的访问性,自动添加指向外部类实例的字段
Java语言中字段是不能重载的,2个字段无论数据类型、修饰符是否相同,都不能使用相同的名称;但是对于字节码,只要字段描述符不同,字段重名就是合法的
7、方法表集合
methods_count:方法表计数器,即方法表集合中的方法表数据个数。占2字节,其值为0x0002,即测试类中有2个方法
methods:方法表集合,一组方法表类型数据的集合。方法表结构和字段表结构一样:
类型 |
名称 |
数量 |
u2 |
access_flags |
1 |
u2 |
name_index |
1 |
u2 |
descriptor_index |
1 |
u2 |
attributes_count |
1 |
attribute_info |
attributes |
attributes_count |
第一个方法(由编译器自动添加的默认构造方法):
access_flags为0x0001,即public;name_index为0x0007,即常量池中第7个常量;descriptor_index为0x0008,即常量池中第8个常量
接下来2个字节为属性计数器,其值为0x0001,说明这个方法的属性表集合中有一个属性(详细说明见后面“八、属性表集合”)
属性名称为接下来2位0x0009,指向常量池中第9个常量:Code。
接下来4位为0x0000002F,表示Code属性值的字节长度为47。
接下来2位为0x0001,表示该方法的操作数栈的深度最大值为1。
接下来2位依然为0x0001,表示该方法的局部变量占用空间为1。
接下来4位为0x0000005,则紧接着的5个字节0x2AB70001B1为该方法编译后生成的字节码指令(各字节对应的指令不介绍了,可查询虚拟机字节码指令表)。
接下来2个字节为0x0000,说明Code属性异常表集合为空。
接下来2个字节为0x0002,说明Code属性带有2个属性,
接下来2位0x000A即为Code属性第一个属性的属性名称,指向常量池中第10个常量:LineNumberTable。
接下来4位为0x00000006,表示LineNumberTable属性值所占字节长度为6。
接下来2位为0x0001,即该line_number_table中只有一个line_number_info表,start_pc为0x0000,line_number为0x0003,LineNumberTable属性结束。
接下来2位0x000B为Code属性第二个属性的属性名,指向常量池中第11个常量:LocalVariableTable。该属性值所占的字节长度为0x0000000C=12。
接下来2位为0x0001,说明local_variable_table中只有一个local_variable_info表,按照local_variable_info表结构,start_pc为0x0000,length为0x0005,name_index为0x000C,指向常量池中第12个常量:this,descriptor_index为0x000D,指向常量池中第13个常量:LTestClass;,index为0x0000。
第一个方法结束
第二个方法:
access_flags为0x0001,即public;
name_index为0x000E,即常量池中第14个常量;
descriptor_index为0x000F,即常量池中第15个常量
接下来2个字节为属性计数器,其值为0x0001,说明这个方法有一个方法属性,
接下来2位0x0009,指向常量池中第9个常量:Code。
接下来4位为0x00000031,表示Code属性值的字节长度为49。
接下来2位为0x0002,表示该方法的操作数栈的深度最大值为2。
接下来2位为0x0001,表示该方法的局部变量占用空间为1。
接下来4位为0x0000007,则紧接着的7个字节0x2AB400120460AC为该方法编译后生成的字节码指令。
接下来2个字节为0x0000,说明Code属性异常表集合为空。
接下来2个字节为0x0002,说明Code属性带有2个属性,
接下来2位0x000A即为Code属性第一个属性的属性名称,指向常量池中第10个常量:LineNumberTable。
接下来4位为0x00000006,表示LineNumberTable属性值所占字节长度为6。
接下来2位为0x0001,即该line_number_table中只有一个line_number_info表,start_pc为0x0000,line_number为0x0006,LineNumberTable属性结束。
和第一个方法的LocalVariableTable属性基本相同,唯一的区别是局部变量this的作用范围覆盖的长度为7而不是5。
第二个方法结束
如果子类没有重写父类的方法,方法表集合中就不会出现父类方法的信息;有可能会出现由编译器自动添加的方法(如:最典型的<init>,实例类构造器)
在Java语言中,重载一个方法除了要求和原方法拥有相同的简单名称外,还要求必须拥有一个与原方法不同的特征签名(,由于特征签名不包含返回值,故Java语言中不能仅仅依靠返回值的不同对一个已有的方法重载;但是在Class文件格式中,特征签名即为方法描述符,只要是描述符不完全相同的2个方法也可以合法共存,即2个除了返回值不同之外完全相同的方法在Class文件中也可以合法共存
注意:Java代码的方法特征签名只包括方法名称、参数顺序、参数类型。
而字节码的特征签名还包括方法返回值和受异常表。
javap工具在后半部分会列出分析完成的方法(可以看到和我们的分析结果是一样的):
8、属性表集合
起始2位为0x0001,说明有一个类属性。
接下来2位为属性的名称,0x0010,指向常量池中第16个常量:SourceFile。
接下来4位为0x00000002,说明属性体长度为2字节。
最后2个字节为0x0011,指向常量池中第27个常量:TestClass.java,即这个Class文件的源码文件名为TestClass.java
前面的Class文件、字段表和方法表都可以携带自己的属性信息,这个信息用属性表进行描述,用于描述某些场景专有的信息。
与Class文件中其它数据项对长度、顺序、格式的严格要求不同,属性表集合不要求其中包含的属性表具有严格的顺序,并且只要属性的名称不与已有的属性名称重复,任何人实现的编译器可以向属性表中写入自己定义的属性信息。虚拟机在运行时会忽略不能识别的属性,为了能正确解析Class文件,虚拟机规范中预定义了虚拟机实现必须能够识别的9项属性(预定义属性已经增加到21项):
属性名称 |
使用位置 |
含义 |
Code |
方法表 |
Java代码编译成的字节码指令 |
ConstantValue |
字段表 |
final关键字定义的常量值 |
Deprecated |
类文件、字段表、方法表 |
被声明为deprecated的方法和字段 |
Exceptions |
方法表 |
方法抛出的异常 |
InnerClasses |
类文件 |
内部类列表 |
LineNumberTale |
Code属性 |
Java源码的行号与字节码指令的对应关系 |
LocalVariableTable |
Code属性 |
方法的局部变量描述 |
SourceFile |
类文件 |
源文件名称 |
Synthetic |
类文件、方法表、字段表 |
标识方法或字段是由编译器自动生成的 |
上面出现了简单名称,上文中出现了全限定名,以及这里出现的描述符,三者有什么区别呢?其中全限定名称比较好理解,就是类的完整路径信息。而简单名称则是指没有类型和参数修饰的方法或者字段名称,比如一个方法如下:
PS:
1,全限定名:将类全名中的“.”替换为“/”,为了保证多个连续的全限定名之间不产生混淆,在最后加上“;”表示全限定名结束。例如:"com.test.Test"类的全限定名为"com/test/Test;"
2,简单名称:没有类型和参数修饰的方法或字段名称。例如:"public void add(int a,int b){...}"该方法的简单名称为"add","int a = 123;"该字段的简单名称为"a"
3,描述符:描述字段的数据类型、方法的参数列表(包括数量、类型和顺序)和返回值。根据描述符规则,基本数据类型和代表无返回值的void类型都用一个大写字符表示,而对象类型则用字符L加对象全限定名表示
标识字符 |
含义 |
B |
基本类型byte |
C |
基本类型char |
D |
基本类型double |
F |
基本类型float |
I |
基本类型int |
J |
基本类型long |
S |
基本类型short |
Z |
基本类型boolean |
V |
特殊类型void |
L |
对象类型,如:Ljava/lang/Object; |
对于数组类型,每一维将使用一个前置的“[”字符来描述,如:"int[]"将被记录为"[I","String[][]"将被记录为"[[Ljava/lang/String;"
用描述符描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组"()"之内,如:方法"String getAll(int id,String name)"的描述符为"(I,Ljava/lang/String;)Ljava/lang/String;"
4,Slot,虚拟机为局部变量分配内存所使用的最小单位,长度不超过32位的数据类型占用1个Slot,64位的数据类型(long和double)占用2个Slot