在本章关于Class文件结构的讲解中,我们将以《Java虚拟机规范(第2版 )》 (1999年发布,对应于JDK 1.4时代的Java虚拟机)中的定义为主线,这部分内容虽然古老,但它所包含的指令、属性是Class文件中最重要和最基础的。同时,我们也会以后续JDK 1.5〜 JDK 1.7中添加的内容为支线进行较为简略的、介绍性的讲解,如果读者对这部分内容特别感兴趣, 建议参考笔者所翻译的《Java虚拟机规范( Java SE 7 ) 》中文版,可以在笔者的网站(http://icyfenix.iteye.com/)上下载到这本丰的全文PDF。
注意任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。 本章中 ,笔者只是通俗地将任意一个有效的类或接口所应当满足的格式称为“Class文件格式”,实际上它并不一定以磁盘文件的形式存在。
Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位字节以上空间的数据项时 ,则会按照高位在前的方式分割成若干个8位字节进行存储。
根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据 ,这种伪结构中只有两种数据类型:无符号数和表,后面的解析都要以这两种数据类型为基础,所以这里要先介绍这两个概念。
无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地 以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表,它由表6-1所示的数据项构成。
无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列连续的某一类型的数据为某一类型的集合。
本节结束之前,笔者需要再重复讲一下,Class的结构不像XML等描述语言,由于它没有任何分隔符号,所以在表6-1中的数据项,无论是顺序还是数量,甚至于数据存储的字节序( Byte Ordering,Class文件中字节序为Big-Endian)这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。接下来我们将一起看看这个表中各个数据项的具体含义。
每个Class文件的头4个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。很多文件存储标准中都使用魔数来进行身份识别, 譬如图片格式,如运域者jpeg等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。Class文件的魔数的获得很有“浪漫气息” ,值为: OxCAFEBABE ( 咖啡宝贝?),这个魔数值在Java还称做“Oak”语言的时候(大约是1991年前后)就已经确定下来了。它还有一段很有趣的历史, 据Java开发小组最初的关键成员Patrick Naughton所说 :“我们一直在寻找一些好玩的、容易记忆的东西,选择OxCAFEBABE是因为它象征着著名咖啡品牌Peet’s Coffee中深受欢迎的Baristas咖啡”,这个魔数似乎也预示着日后“Java”这个商标名称的出现。
紧接着魔数的4个字节存储的是Class文件的版本号:第5和第6个字节是次版本号( Minor Version) ,第7和第8个字节是主版本号(Major Version)。 Java的版本号是从45开始的 , JDK 1.1之后的每个JDK大版本发布主版本号向上加1(JDK 1.0〜1.1使用了45.0〜45.3的版本号),高版本的JDK能向下兼容以前版本的Class文件,但不能运行以后版本的Class文件 ,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class文件。
例如, JDK 1.1能支持版本号为45.0〜45.65535的Class文件,无法执行版本号为46.0以上的Class文 件 ,而JDK 1.2则能支持45 .0〜46.65535的Class文件。现 在 ,最新的JDK版本为 1.7,可生成的Class文件主版本号最大值为51.0。
为了讲解方便,笔者准备了一段最简单的Java代码(见代码清单6-1 ) ,本章后面的内容都将以这段小程序使用JDK 1.6编译输出的Class文件为基础来进行讲解。
package org.fenixsoft.clazz;
public class TestClass {
private int m;
public int inc() {
return m + 1;
}
}
图6-2显示的是使用十六进制编辑器WinHex打开这个Class文件的结果,可以清楚地看见开头4个字节的十六进制表示是OxCAFEBABE,代表次版本号的第5个和第6个字节值为 0x0000 ,而主版本号的值为0x0032 ,也即是十进制的50,该版本号说明这个文件是可以被 JDK 1.6或以上版本虚拟机执行的Class文件。
表6-2列出了从JDK 1.1到JDK 1.7 , 主流JDK版本编译器输出的默认和可支持的Class文件版本号。
这种顺序称为“Big-Endian” ,具体是指最高位字节在地址最低位、最低位字节在地址最高位的顺序来存储数据,它是SPARC、PowerPC等处理器的默认多字节存储顺序,而x86等处理器则是使用了相反的“Little-Endian”顺序来存储数据。
紧接着主次版本号之后的是常量池入口,常量池可以理解为Class文件之中的资源仓库, 它是Class文件结构中与其他项目关联最多的数据类型,也是占用Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目。
由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据 ,代表常量池容量计数值(constant_pool_count) 。与Java中语言习惯不一样的是,这个容量计数是从1而不是0开始的,如图6-3所示,常量池容量(偏移地址:0x00000008 )为十六进制数0x0016,即十进制的22 ,这就代表常量池中有21项常量,索引值范围为1〜21。在Class文件格式规范制定之时,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况就可以把索引值置为0来表示。Class文件结构中只有常量池的容量计数是从1开 始,对于其他集合类型,包括接口索引集合、字段表集合、方法表集合等的容量计数都与一般习惯相同,是从0开始的。
常量池中主要存放两大类常量:字面量( Literal ) 和符号引用( Symbolic References )。 字面量比较接近于Java语言层面的常量概念,如文本字符串、声明为final的常量值等。而符号引用则属于编译原理方面的概念,包括了下面三类常量:
Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无法直接被虚拟机使用。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
常量池中每一项常量都是一个表,在JDK 1.7之前共有11种结构各不相同的表结构数据 ,在JDK 1.7中为了更好地支持动态语言调用,又额外增加了3种
( CONSTANT_MethodHandle_info、 CONSTANT_MethodType_info和 CONSTANT_InvokeDynamic_info,本章不会涉及种新增的类型 ,在第8章介绍字节码执行和方法调用时,将会详细讲解)。
这14种表都有一个共同的特点,就是表开始的第一位是一个u1类型的标志位(tag,取值见表6-3中标志列),代表当前这个常量属于哪种常量类型。这14种常量类型所代表的具体 含义见表6-3。
之所以说常量池是最烦琐的数据,是因为这14种常量类型各自均有自己的结构。回头看看图6-3中常量池的第一项常量,它的标志位(偏移地址: 0x0000000A ) 是0x07,查表6-3的标志列发现这个常量属于CONSTANT_Class_info类 型 ,此类型的常量代表一个类或者接口的符号引用。CONSTANT_Class_info的结构比较简单,见表6-4。
tag是标志位,上面已经讲过了,它用于区分常量类型;name_index是一个索引值,它指向常量池中一个CONSTANT_Utf8_info类型常量,此常量代表了这个类(或者接口)的全限定名,这里name_index值(偏移地址:0x0000000B)为0x0002 ,也即是指向了常量池中的第二项常量。继续从图6-3中查找第二项常量,它的标志位(地址:0x0000000D)是0x01 ,查表6-3可知确实是一个CONSTANT_Utf8_info类型的常量。CONSTANT_Utf8_info类型的结构见表6-5:
length值说明了这个UTF-8编码的字符串长度是多少字节,它后面紧跟着的长度为length字节的连续数据是一个使用UTF-8缩略编码表示的字符串。UTF-8缩略编码与普通UTF-8编码 的区别是:从‘\u0001’到’\u007f之间的字符(相当于1 〜127的ASCII码 )的缩略编码使用一个字节表示,从‘\u0080’到‘\u07ff’之间的所有字符的缩略编码用两个字节表示,从’\u0800’到’\uffff’之间的所有字符的缩略编码就按照普通UTF-8编码规则使用三个字节表示。
顺便提一下,由于Class文件中方法、字段等都需要引用CONSTANT_Utf8_info型常量来描述名称,所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、字段名的最大长度。而这里的最大长度就是length的最大值,既u2类型能表达的最大值65535。所以Java程序中如果定义了超过64KB英文字符的变量或方法名,将会无法编译。
本例中这个字符串的length值(偏移地址:0x0000000E)为0x001D, 也就是长29字节, 往后29字节正好命在1〜127的ASCII码范围以内,内容为“org/fenixsofl/clazz/TestClass”,有兴趣的读者可以自己逐个字节换算一下,换算结果如图6-4选中的部分所示。
到此为止,我们分析了TestClass.class常量池中21个常量中的两个,其余的19个常量都可以通过类似的方法计算出来。为了避免计算过程占用过多的版面,后续的19个常量的计算过程可以借助计算机来帮我们完成。在JDK的bin目录中,Oracle公司已经为我们准备好一个专门用于分析Class文件字节码的工具:javap ,代码清单6-2中列出了使用javap工具的-verbose 参数输出的TestClass.class文件字节码内容(此清单中省略了常量池以外的信息)。前面我们曾经提到过,Class文件中还有很多数据项都要引用常量池中的常量,所以代码清单6-2中的内容在后续的讲解过程中还要经常使用到。
从代码清单6-2中可以看出,计算机已经帮我们把整个常量池的21项常量都计算了出来 ,并且第1、2项常量的计算结果与我们手工计算的结果一致。仔细看一下会发现,其中有—些常量似乎从来没有在代码中出现过,如“I”、“V”、“
这部分自动生成的常量的确没有在Java代码里面直接出现过,但它们会被后面即将讲到的字段表( field_info ) 、方法表(method_info ) 、属性表(attribute_info ) 引用到,它们会用来描述一些不方便使用“固定字节”进行表达的内容。譬如描述方法的返回值是什么?有几个参数?每个参数的类型是什么?因为Java中的“类”是无穷无尽的,无法通过简单的无符号字节来描述一个方法用到了什么类 ,因此在描述方法的这些信息时,需要引用常量表中的符号引用进行表达。这部分内容将在后面进一步阐述。最后 ,笔者将这14种常量项的结构定义总
结为表6-6以供读者参考。
在常量池结束之后,紧接着的两个字节代表访问标志(access_flags ) ,这个标志用于识别一些类或者接口层次的访问信息,包括:这个Class是类还是接口 ; 是否定义为public类型 ;是否定义为abstract类型 ;如果是类的话,是否被声明为final等。具体的标志位以及标志的含义见表6-7。
access_flags中一共有16个标志位可以使用,当前只定义了其中8个’没有使用到的标志位要求为0。以代码清单6-1中的代码为例,TestClass是一个普通Java类 ,不是接口、枚举或者注解,被public关键字修饰但没有被声明为final和abstract, 并且它使用了JDK 1.2之后的编译器进行编译,因此它的ACC_PUBLIC、ACC_SUPER标志应当为真,而ACC_FINAL、 ACC_INTERFACE、ACC_ABSTRACT、 ACC_SYNTHETIC、 ACC_ANNOTATION、 ACC_ENUM这6个标志应S 为假,因此它的access_flags的值为:0x000110x0020=0x0021。 从图;5中可以看出,access_flags标志(偏移地址:0x000000EF ) 的确为0x0021。
在Java虚拟机规范中,只定义了开头5种标志。了DK 1.5中增加了后面3种。这些标志为在JSR-202规范中声明,是对 《Java虚拟机规范(第2版 )》的补充。本书介绍的访问标志以 JSR-202规范为准。
类索引( this class ) 和父类索引( superclass ) 都是一个u2类型的数据,而接口索引集合 ( interfaces ) 是一组u2类型的数据的集合,Class文件中由这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java 类都有父类,因此除了java.lang.Object外 ,所有Java类的父类索引都不为0。接口索引集合就用来描述这个类实现了哪些接口,这些被实现的接口将按implements语句 (如果这个类本身是一个接口,则应当是extends语句 )后的接口顺序从左到右排列在接口索引集合中。
类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引用两个u2类型的索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量 ,通过CONSTANT_Class_info类型的常量中的索引值可以找到S 义在 C0NSTANT_Utf8_mfo类型的常量中的全限定名字符串。图6-6演示了代码清单6-1的代码的类索引查找过程 。
对于接口索引集合,入口的第一项——u2类型的数据为接口计数器
( interfaces_count), 表示索引表的容量。如果该类没有实现任何接口,则该计数器值为0 ,后面接口的索引表不再占用任何字节。代码清单6-1中的代码的类索引、父类索引与接口表 索引的内容如图6-7所示。
从偏移地址0x000000F1开始的3个U2类型的值分别为0x0001、0x0003、0x0000, 也就是类索引为1 ,父类索引为3 ,接口索引集合大小为0 ,查询前面代码清单6-2中javap命令计算出来的常量池,找出对应的类和父类的常量,结果如代码清单6-3所示。
字段表( field_info ) 用于描述接口或者类中声明的变量。字段( field )包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。我们可以想一想在Java中描述一个字段可以包含什么信息?可以包括的信息有:字段的作用域(public、private、protected修饰符)、是实例变量还是类变量(static修饰符)、可变性( final ) 、并发可见性( volatile修饰符 ,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组 )、字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符 ,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型 ,这些都是无法固定的,只能引用常量池中的常量来描述。表6-8中列出了字段表的最终格式。
字段修饰符放在access_flags项目中,它与类中的access_flags项目是非常类似的,都是一 个u2的数据类型,其中可以设置的标志位和含义见表6-9。
很明显,在实际情况中,ACC_PUBLIC、ACC_PRIVATE、ACC_PROTECTED三个标志最多只能选择其一 , ACC_FINAL、ACC_VOLATILE不能同时选择。接口之中的字段必须有 ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志 ,这些都是由Java本身的语言规则所决定的。
跟随access_flags标志的是两项索引值:name_index和descriptor_index。它们都是对常量池的引用,分别代表着字段的简单名称以及字段和方法的描述符现在需要解释一下“简单名称” 、“ 描述符” 以及前面出现过多次的“ 全限定名” 这三种特殊字符串的概念。
全限定名和简单名称很好理解,以代码清单6-1中的代码为
例 ,“org/fenixsoft/clazz/TestClass”是这个类的全限定名,仅仅是把类全名中的“.”替换成了“/”而已,为了使连续的多个全限定名之间不产生混淆,在使用时最后一般会加入一个“;”表示全限定名结束。简单名称是指没有类型和参数修饰的方法或者字段名称,这个类中的inc() 方法和m字段的简单名称分别是“inc”和“m”。
相对于全限定名和简单名称来说,方法和字段的描述符就要复杂一些。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值。根据描述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示 ,详见表6-10。
对于数组类型,每一维度将使用一个前置的“[”字符来描述,如一个定义 为“java.lang.String[][]”类型的二维数组,将被记录为:“[[Ljava/lang/String ;”,一个整型数组将被记录为“ [I ”。
用描述符来描述方法时,按照先参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号“ ()”之内。如方法void inc ( ) 的描述符为“ ()V”方法 java.lang.String toString() 的描述符为“ ()Ljava/lang/String;” ,方法int indexOf(char[]source,int sourceOffset,int sourceCount,char[] target,int targetOffset,int targetCountintfromlndex)的描述符为“ ([CII[CIII) I”。
对于代码清单6-1中的TestClass.class文件来说,字段表集合从地址0x000000F8开始,第1个u2类型的数据为容量计数器fields_count,如图6-8所示,其值为0x0001 ,说明这个类只有一个字段表数据。接下来紧跟着容量计数器的是access_flags标志,值为0x0002,代表private修饰符的ACC_PRIVATE标志位为真(ACC_PRIVATE标志的值为0x0002 ) ,其他修饰符为假。代表字段名称的name index的值为0x0005,从代码清单6-2列出的常量中可查得第5项常量是一个CONSTANT_Utf8_info类型的字符串,其值为“m ” ,代表字段描述符的descriptor_index的值为0x0006,指向常量池的字符串“I” ,根据这些信息,我们可以推断出原代码定义的字段为: “private int m;”。
字段表都包含的固定数据项目到descriptor_index为止就结束了,不过在descriptor_index之后跟随着一个属性表集合用于存储一些额外的信息,字段都可以在属性表中描述零一至多项的额外信息。对于本例中的字段m ,它的属性表计数器为0 ,也就是没有需要额外描述的信息 ,但是 ,如果将字段m的声明改为“final static int m=123 ; ”,那就可能会存在一项名称为ConstantValue属性,其值指向常量123。关于attribute_info的其他内容,将在6.3.7节介绍属性表的数据项目时再进一步讲解。
字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段。另外 ,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果两个字段的描述符不一致, 那字段重名就是合法的。
void类型在虚拟机规范之中单独列出为“VoidDescnptor” ,笔者为了结构统一,将其列在基本数据类型中一起描述。
如果理解了上一节关于字段表的内容,那本节关于方法表的内容将会变得很简单。 Class文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式,方法表的结构如同字段表一样,依次包括了访问标志(access_flags ) 、名称索引(name_index)、描述符索引 ( descriptor_index ) 、属性表集合( attributes ) 几项,见表6-11。这些数据项目的含义也非常类似 ,仅在访问标志和属性表集合的可选项中有所区别。
因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有了ACC_VOLATILE标志和ACC_TRANSIENT标志。与之相对的,synchronized、native、 strictfp 和abstract关键字可以修饰方法,所以方法表的访问标志中增加了ACC_SYNCHRONIZED、 ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。对于方法表,所有标志位及其取值可参见表6-12。
行文至此,也许有的读者会产生疑问,方法的定义可以通过访问标志、名称索引、描述符索引表达清楚,但方法里面的代码去哪里了?方法里的Java代码 ,经过编译器编译成字节码指令后,存放在方法属性表集合中一个名为“Code”的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目,将在6.3.7节中详细讲解。
我们继续以代码清单6-1中的Class文件为例对方法表集合进行分析,如图6-9所示,方法表集合的入口地址为:0x00000101, 第一个u2类型的数据(即是计数器容量)的值为 0x0002 , 代表集合中有两个方法(这两个方法为编译器添加的实例构造器
与字段表集合相对应的,如果父类方法在子类中没有被重写(Overnde ) ,方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器“
在Java语言中,要重载(Overload ) 一个方法,除了要与原方法具有相同的简单名称之外 ,还要求必须拥有一个与原方法不同的特征签名 ,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,因此Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在Class文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法也可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。
在《Java虚拟机规范(第2版)》的“§4.4.4 Signatures”章节及《Java语言规范(第3版)》 的“§8.4.2 Method Signature”章节中都分别定义了字节码层面的方法特征签名以及Java代码层面的方法特征签名,Java代码的方法特征签名只包括了方法名称、参数顺序及参数类型,而字节码的特征签名还包括方法返回值以及受查异常表,请读者根据上下文语境注意区分。
属性表( attribute_info ) 在前面的讲解之中已经出现过数次,在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。
与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松了一些,不再要求各个属性表具有严格顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识的属性。为了能正确解析Class文 件 ,《Java虚拟机规范(第2版 )》中预定义了9项虚拟机实现应当能识别的属性,而在最新的《Java虚拟机规范( Java SE 7 ) 》版中,预定义属性已经增加到21项 ,具体内容见表6-13。下文中将对其中一些属性中的关键常用的部分进行讲解。
对于每个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。一个符合规则的属性表应该满足表6-14中所定义的结构。
Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内。Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽象类中的方法就不存在Code属性 ,如果方法表有Code属性存在,那么它的结构将如表6-15所示。
attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引,常量值固定为“Code” ,它代表了该属性的属性名称, attribute_length指示了属性值的长度 ,由于属性名称索引与属性长度一共为6字节,所以属性值的长度固定为整个属性表长度减去6个字节。
max_stack代表了操作数栈( Operand Stacks ) 深度的最大值。在方法执行的任意时刻, 操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧( Stack Frame ) 中的操作栈深度。
max_locals代表了局部变量表所需的存储空间。在这里, max_locals的单位是Slot,Slot是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、float、int、short、 boolean 和retumAddress等长度不超过32位的数据类型,每个局部变量占用1个Slot,而double和long这 两种64位的数据类型则需要两个Slot来存放。方法参数(包括实例方法中的隐藏参数“this” )、显式异常处理器的参数( Exception Handler Parameter,就是try-catch语句中catch块所定义的异常)、方法体中定义的局部变量都需要使用局部变量表来存放。另外 ,并不是 在方法中用到了參少个局部变量,就把这些局部变量所占Slot之和作为max_locals的i值,原因是局部变量表中的Slot可以重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的Slot可以被其他局部变量所使用,Javac编译器会根据变量的作用域来分配Slot给各个变量使用,然后计算出max_locals的大小。
code_length和code用来存储Java源程序编译后生成的字节码指令。 code length代表字节码长度 ,code是用于存储字节码指令的一系列字节流。既然叫字节码指令,那么每个指令就是 一个u1类型的单字节,当虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的是什么指令,并且可以知道这条指令后面是否需要跟随参数,以及参数应当如何理解。我们知道一个u1数据类型的取值范围为0x00〜OxFF,对应十进制的0〜255,也就是一共可以表达256条 指令 ,目前 ,Java虚拟机规范已经定义了其中约200条编码值对应的指令含义 ,编码与指令之间的对应关系可查阅本书的附录B“虚拟机字节码指令表”。
关于code_length,有一件值得注意的事情,虽然它是一个说类型的长度值,理论上最大值可以达到232-1 ,但是虚拟机规范中明确限制了 一个方法不允许超过65535条字节码指令, 即它实际只使用了u2的长度,如果超过这个限制,Javac编译器也会拒绝编译。一般来讲,编写Java代码时只要不是刻意去编写一个超长的方法来为难编译器,是不太可能超过这个最大值的限制。但是,某些特殊情况,例如在编译一个很复杂的JSP文件时,某些JSP编译器会把JSP内容和页面输出的信息归并于一个方法之中,就可能因为方法生成字节码超长的原因而导致编译失败。
Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码( Code,方法体里面的Java代码)和元数据(Metadata,包括类、字段、方法定义及其他信息 )两部分,那么在整个Class文件中,Code属性用于描述代码,所有的其他数据项目都用于描述元数据。了解Code属性是学习后面关于字节码执行引擎内容的必要基础,能直接阅读字节码也是工作中分析Java代码语义问题的必要工具和基本技能,因此笔者准备了一个比较详细的实例来讲解虚拟机是如何使用这个属性的。
继续以代码清单6-1的TestClass.class文件为例,如图6-10所示,这是上一节分析过的实例构造器“
这段字节码虽然很短,但是至少可以看出它的执行过程中的数据交换、方法调用等操作都是基于栈(操作栈)的。我们可以初步猜测:Java虚拟机执行字节码是基于栈的体系结构。但是与一般基于堆栈的零字节指令又不太一样,某些指令(如invokespecial)后面还会带有参数,关于虚拟机字节码执行的讲解是后面两章的重点,我们不妨把这里的疑问放到第8章去解决。
我们再次使用javap命令把此Class文件中的另外一个方法的字节码指令也计算出来,结果如代码清单6-4所示。
如果大家注意到javap中输出的“Args_size”的值,可能会有疑问:这个类有两个方法——实例构造器
在字节码指令之后的是这个方法的显式异常处理表(下文简称异常表)集合 ,异常表对于Code属性来说并不是必须存在的,如代码清单6-4中就没有异常表生成。
异常表的格式如表6-16所示 ,它包含4个字段 ,这些字段的含义为:如果当字节码在第 start_pc行到第end_pc行之间(不含第end_pc行)出现了类型为catch_type或者其子类的异常 ( catch_type为指向一个CONSTANT_Class_info型常量的索引),则转到第handler_pc行继续处理。当catch_type的值为0时 ,代表”任意异常情况都需要转向到handler_pc处进行处理。
异常表实际上是Java代码的一部分,编译器使用异常表而不是简单的跳转命令来实现Java异常及finally处理机制。
代码清单6-5是一段演示异常表如何运作的例子,这段代码主要演示了在字节码层面中try-catch-finally是如何实现的。在阅读字节码之前,夫家不妨先看看下面的Java源码 ,想一下这段代码的返回值在出现异常和不出现异常的情况下分别应该是多少?
编译器为这段Java源码生成了3条异常表记录,对应3条可能出现的代码执行路径。从Java代码的语义上讲,这3条执行路径分别为:
返回到我们上面提出的问题,这段代码的返回值应该是多少?对Java语言熟悉的读者应该很容易说出答案:如果没有出现异常,返回值是1 ; 如果出现了Exception异常,返回值是2 ;如果出现了Exception以外的异常,方法非正常退出,没有返回值。我们一起来分析一下字节码的执行过程,从字节码的层面上看看为何会有这样的返回结果。
字节码中第0〜4行所做的操作就是将整数1赋值给变量x ,并且将此时x的值复制一份副本到最后一个本地变量表的Slot中(这个Slot里面的值在ireturn指令执行前将会被重新读到操作栈顶,作为方法返回值使用。为了讲解方便,笔者给这个Slot起了个名字: returnValue )。 如果这时没有出现异常,则会继续走到第5〜9行 ,将变量x赋值为3 ,然后将之前保存在returnValue中的整数1读入到操作栈顶,最后ireturn指令会以int形式返回操作栈顶中的值,方法结束。如果出现了异常,PC寄存器指针转到第10行 ,第10〜20行所做的事情是将2赋值给变量x ,然后将变量x此时的值赋给returnValue ,最后再将变量x的值改为3。方法返回前同样 将returnValue中保留的整数2读到了操作栈顶。从第21行开始的代码,作用是变量x的值賦为3 ,并将栈顶的异常拋出,方法结束。
尽管大家都知道这段代码出现异常的概率非常小,但并不影响它为我们演示异常表的作用。如果大家到这里仍然对字节码的运作过程比较模糊,其实也不要紧,关于虚拟机执行字节码的过程,本书第8章中将会有更详细的讲解。
这里的Exceptions属性是在方法表中与Code属性平级的一项属性,读者不要与前面刚刚讲解完的异常表产生混淆。Exceptions属性的作用是列举出方法中可能拋出的受查异常( Checked Excepitons ) ,也就是方法描述时在throws关键字后面列举的异常。它的结构见表 6-17。
Exceptions属性中的number_of_exceptions项表示方法可能拋出number_of_exceptions种受查异常 ,每一种受查异常使用一个exception_index_table项表示 ,exception_index_table是一个指向常量池中CONSTANT_Class_info型常量的索引,代表了该受查异常的类型。
LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。它并不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用-g:none或-g:lines选项来取消或要求生成这项信息。如果选择不生成LineNumberTable 属性 ,对程序运行产生的最主要的影响就是当拋出异常时,堆栈中将不会显示出错的行号, 并且在调试程序的时候,也无法按照源码行来设置断点。LineNumberTable属性的结构见表6- 18。
line_nimber_table是一个数量为line_number_table_length、类型为line_number_info的集 合 , line_number_info表包括了start_pc和line_number两个u2类型的数据项,前者是字节码行号 ,后者是Java源码行号。
LocalVariableTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系,它也不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中分别使用-g:none或-g:vars选项来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,IDE将会使用诸如arg0、 arg1之类的占位符代替原有的参数名,这对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获得参数值。LocalVariableTable属性的 结构见表6-19。
其中,local_variable_info项目代表了一个栈帧中与源码中局部变量的关联,结构见6-20:
start_pc和length属性分别代表了这个局部变量的生命周期开始的字节码偏移量及其作用范围覆盖的长度,两者结合起来就是这个局部变量在字节码之中的作用域范围。
name_index和descriptor_index都是指向常量池中CONSTANT_Utf8_info型常量的索引,分别代表了局部变量的名称以及这个局部变量的描述符。
index是这个局部变量在栈帧局部变量表中Slot的位置。当这个变量数据类型是64位类型时 (double和long ) , 它占用的Slot为index和index+1两个。
顺便提一下,在JDK1.5引入泛型之后,LocalVariableTable属性增加了一个“姐妹属性” : LocalVariableTypeTable ,这个新增的属性结构与LocalVariableTable非常相似,仅仅是把记录的字段描述符的descriptor_index替换成了字段的特征签名(Signature),对于非泛型类型来说 ,描述符和特征签名能描述的信息是基本一致的,但是泛型引入之后,由于描述符中泛型的参数化类型被擦除掉,描述符就不能准确地描述泛型类型了,因此出现了 LocalVariableTypeTable。
SourceFile属性用于记录生成这个Class文件的源码文件名称。这个属性也是可选的,可以分别使用Javac的-g:none或-g:source选项来关闭或要求生成这项信息。在Java中 ,对于大多数的类来说,类名和文件名是一致的,但是有一些特殊情况(如内部类)例外。如果不生成这项属性,当拋出异常时,堆栈中将不会显示出错代码所属的文件名。这个属性是一个定长的属性,其结构见表6-21。
sourcefile_index数据项是指向常量池中CONSTANT_Utf8_info型常量的索引,常量值是源码文件的文件名。
ConstantValue属性的作用是通知虚拟机自动为静态变量赋值。只有被static关键字修饰的变量(类变量)才可以使用这项属性。类似“int x=123”和“static int x=123”这样的变量定义在Java程序中是非常常见的事情,但虚拟机对这两种变量賦值的方式和时刻都有所不同。对于非static类型的变量(也就是实例变量)的賦值是在实例构造器
虽然有final关键字才更符合“ConstantValue” 的语义 ,但虚拟机规范中并没有强制要求字段必须设置了ACC_FINAL标志 ,只要求了有ConstantValue属性的字段必须设置ACC_STATIC 标志而已,对final关键字的要求是Javac编译器自己加入的限制。而对ConstantValue的属性值只能限于基本类型和String,不过笔者不认为这是什么限制,因为此属性的属性值只是一个常量池的索引号,由于Class文件格式的常量类型中只有与基本属性和字符串相对应的字面量 ,所以就算ConstantValue属性想支持别的类型也无能为力。ConstantValue属性的结构见表 6- 22。
从数据结构中可以看出,ConstantValue属性是一个定长属性,它的attribute_length数据项值必须固定为2。constantvalue_index数据项代表了常量池中一个字面量常量的引用,根据字段类型的不同,字面量可以是CONSTANT_Long_info、CONSTANT_Float_info、 CONSTANT_Double_info、CONSTANT_Integer_info、CONSTANT_Sring_info常量中的一种。
ImerClasses属性用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成InnerClasses属性。该属性的结构见表6-23。
数据项number_of_classes表需要记录多少个内部类信息,每一个内部类的信息都由一个inner_classes_info表进行描述。inner_classes_info表的结构见表6-24。
inner_class_info_index和outer_class_info_index都是指向常量池中CONSTANT_Class_info 型常量的素引,分别代表了内部类和宿主类的符号引用。
imer_name_index是指向常量池中CONSTANT_Utf8_info型常量的索引,代表这个内部类的名称,如果是匿名内部类,那么这项值为0。
inner_class_access_flags是内部类的访问标志,类似于类的access_flags ,它的取值范围见表6-25.
Deprecated和Synthetic两个属性都属于标志类型的布尔属性,只存在有和没有的区别,没有属性值的概念。
Deprecated属性用于表示某个类、字段或者方法,已经被程序作者定为不再推荐使用,它可以通过在代码中使用@deprecated注释进行设置。
Synthetic属性代表此字段或者方法并不是由Java源码直接产生的,而是由编译器自行添加的 ,在JDK 1.5之后 ,标识一个类、字段或者方法是编译器自动产生的,也可以设置它们访问标志中的ACC_SYNTHETIC标志位,其中最典型的例子就是Bridge Method。所有由非用户代码产生的类、方法及字段都应当至少设置Synthetic属性和ACC_SYNTHETIC标志位中的一项,唯一的例外是实例构造器“
Deprecated和Synthetic属性的结构非常简单,见表6-26。
其中attribute_length数据项的值必须为0x00000000,因为没有任何属性值需要设置。
StackMapTable属性在JDK 1.6发布后增加到了Class文件规范中,它是一个复杂的变长属性 ,位于Code属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器( Type Checker )使用(见7.3.2节),目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。
这个类型检查验证器最初来源于Sheng Liang(听名字似乎是虚拟机团队中的华裔成员) 为Java ME CLDC实现的字节码验证器。新的验证器在同样能保证Class文件合法性的前提下 ,省略了在运行期通过数据流分析去确认字节码的行为逻辑合法性的步骤,而是在编译阶段将一系列的验证类型( Verification Types ) 直接记录在Class文件之中,通过检查这些验证类型代替了类型推导过程,从而大幅提升了字节码验证的性能。这个验证器在JDK 1.6中首次提供 ,并在JDK 1.7中强制代替原本基于类型推断的字节码验证器。关于这个验证器的工作原理,《Java虚拟机规范( Java SE 7版 )》花费了整整120页的篇幅来讲解描述,并且分析证明新验证方法的严谨性,笔者在此不再赘述。
StackMapTable属性中包含零至多个栈映射帧( Stack Map Frames ) ,每个栈映射帧都显式或隐式地代表了一个字节码偏移量,用于表示该执行到该字节码时局部变量表和操作数栈的验证类型。类型检查验证器会通过检查目标方法的局部变量和操作数栈所需要的类型来确定一段字节码指令是否符合逻辑约束。StackMapTable属性的结构见表6-27。
Signature属性在JDK 1.5发布后增加到了Class文件规范之中,它是一个可选的定长属性, 可以出现于类、属性表和方法表结构的属性表中。在JDK 1.5中大幅增强了Java语言的语法, 在此之后,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type _Variables ) 或参数化矣型(Parameterized Types ) , 则Signature属性会为它记录适型签名信息。之所以要专门使用这样一个属性去记录泛型类型,是因为Java语言的泛型采用的是擦除法实现的伪泛型,在字节码(Code属性)中 ,泛型信息编译(类型变量、参数化类型)之后 都通通被擦除掉。使用擦除法的好处是实现简单(主要修改Javac编 译 器 ,虚拟机内部只做
了很少的改动)、非常容易实现Backport , 运行期也能够节省_ 些类型所占的内存空间。但坏处是运行期就无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的普通类型同等对待 ,例如运行期做反射时无法获得到泛型信息。Signature属性就是为了弥补这个缺陷而 增设的,现在Java的反射API能够获取泛型类型,最终的数据来源也就是这个属性。关于Java泛型、Signature属性和类型擦除,在第10章介绍编译器优化的时候会通过一个具体的例子来 讲解。Signature属性的结构见表6-28。
其中signature_index项的值必须是一个对常量池的有效索引。常量池在该索引处的项必须 是CONSTANT_Utf8_info结构,表示类签名、方法类型签名或字段类型签名。如果当前的Signature属性是类文件的属性,则这个结构表示类签名,如果当前的Signature属性是方法表的属性 ,则这个结构表示方法类型签名,如果当前Signature属性是字段表的属性,则这个结构表示字段类型签名。
BootstrapMethods属性在JDK 1.7发布后增加到了Class文件规范之中,它是一个复杂的变长属性 ,位于类文件的属性表中。这个属性用于保存invokedynamic指令引用的引导方法限定符。 《 Java虚拟机规范(Java SE 7版)》规定,如果某个类文件结构的常量池中曾经出现过 CONSTANT_InvokeDymmic_info类型的常量,那么这个类文件的属性表中必须存在一个明确的BootstrapMethods属性 ,另外 ,即使CONSTANT_InvokeDymmic_info类型的常量在常量池中出现过多次,类文件的属表中最姜也只能有一个BootstrapMethods属性。BootstrapMethods 属性与JSR-292中fi^InvokeDymmic指令和java.lang.Invoke包关系非常密切,要介绍这个属性的作用 ,必须先弄清楚InovkeDymmic指令的运作原理,笔者将在第8章专门用1节篇幅去介绍它们,在此先暂时略过。
目前的Javac暂时无法生成InvokeDynamic指令和BootstrapMethods属性,必须通过一些非常规的手段才能使用到它们,也许在不久的将来,等JSR-292更加成熟一些,这种状况就会改变。BootstrapMethods属性的结构见表6-29。
其中引用到的bootstrap_method结构见表6-30。
BootstrapMethods属性中, num_bootstrap_methods项的值给出了bootstrap_methods[]数组中 的引导方法限定符的数量。而bootstrap_methods[]数组的每个成员包含了一不指向常量池 CONSTANT_MethodHandle结构的索引值,它代表了 一个引导方法,还包含了这个引导方法 静态参数的序列(可能为空)。bootstrap_methods[]数组中的每个成员必须包含以下3项内容。
bootstrap_method_ref : bootstrap_method_ref项的值必须是一个对常量池的有效索引。常量池在该索引处的值必须是一个CONSTANT_MethodHandle_info结构。
num_bootstrap_arguments : num_bootstrap_arguments项的值给出了bootstrap_arguments[]数组成员数量。
bootstrap_arguments[] : bootstrap_arguments[]数组的每个成员必须是一个对常量池的有效索引。常量池”在该索引处必须是下列结构之一 : CONSTANT_String_info、
CONSTANT_Class_info、CONSTANT_Integer_info、CONSTANT_Long_info、 CONSTANT_Float_info、CONSTANT_Double_info、CONSTANT_MethodHandle_info或 CONSTANT_MethodType_info.
注JDK1.4.2之前的Javac编译器采用了jsr和ret指令实现finally语句,但1.4.2之后已经改为编译器自动在每段可能的分支路径之后都将finally语句块的内容冗余生成一遍来实现finally语 义。在JDK 1.7中 ,已经完全禁止Class文件中出现jsr和ret指令,如果遇到这两条指令,虚拟机会在类加载的字节码校验阶段拋出异常。
Class文件结构自Java虚拟机规范第1版订立以来,已经有十多年的历史。这十多年间 ,Java技术体系有了翻天覆地的改变,JDK的版本号已经从1.0提升到了 1.7。相对于语言、API以及Java技术体系中其他方面的变化,Class文件结构一直处于比较稳定的状态,Class文 件的主体结构、字节码指令的语义和数量几乎没有出现过变动,所有对Class文件格式的改进 ,都集中在向访问标志、属性表这些在设计上就可扩展的数据结构中添加内容。
如果以《Java虚拟机规范(第2版 )》为基准进行比较的话,那么在后续Class文件格式的发展过程中,访问标志里新加入了ACC_SYNTHETiC、ACC_ANNOTATION、ACC_ENUM、ACC_BRIDGE、ACC_VARARGS共5个标志。而属性表集合中,在JDK 1.5到 JDK 1.7版本之间一共增加了12项新的属性,这些属性大部分用于支持Java中许多新出现的语言特性 ,如枚举、变长参数、泛型、动态注解等。还有一些是为了支持性能改进和调试信息 ,譬如JDK 1.6的新类型校验器的StackMapTable属性和对非Java代码调试中用到的 SourceDebugExtension属性。
Class文件格式所具备的平台中立(不依赖于特定硬件及操作系统)、紧凑、稳定和可扩展的特点,是Java技术体系实现平台无关、语言无关两项特性的重要支柱。
十余年间,字节码的数量和语义只发生过屈指可数的几次变动,例如,JDK1.0.2时改动过invokespecial指令的语义;JDK1.7增加了invokedynamic指令,禁止了ret和jsr指令。