Class类文件的结构
Java技术能够一直保持着非常良好的向后兼容性,Class文件结构的稳定功不可没,任何一门程序 语言能够获得商业上的成功,都不可能去做升级版本后,旧版本编译的产品就不再能够运行这种事 情。本章所讲述的关于Class文件结构的内容,绝大部分都是在第一版的《Java虚拟机规范》(1997年 发布,对应于JDK 1.2时代的Java虚拟机)中就已经定义好的,内容虽然古老,但时至今日,Java发展 经历了十余个大版本、无数小更新,那时定义的Class文件格式的各项细节几乎没有出现任何改变。尽 管不同版本的《Java虚拟机规范》对Class文件格式进行了几次更新,但基本上只是在原有结构基础上 新增内容、扩充功能,并未对已定义的内容做出修改。
注意
任何一个Class文件都对应着唯一的一个类或接口的定义信息
,但是反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以动态生成,直接送入类加载器中)
。本章中, 笔者只是通俗地将任意一个有效的类或接口所应当满足的格式称为“Class文件格式”,实际上它完全不需要以磁盘文件的形式存在。
Class文件是一组以8个字节为基础单位的二进制流
,各个数据项目严格按照顺序紧凑地排列在文 件之中,中间没有添加任何分隔符
,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8个字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8个字节进行存储
。
据《Java虚拟机规范》的规定,Class文件格式采用一种类似于C语言结构体的伪结构来存储数据
,这种伪结构中只有两种数据类型:“无符号数”和“表”
。后面的解析都要以这两种数据类型为基 础,所以这里笔者必须先解释清楚这两个概念。
- 无符号数属于基本的数据类型,
以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个 字节的无符号数
,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。 表是由多个无符号数或者其他表作为数据项构成的复合数据类型
,为了便于区分,所有表的命名都习惯性地以“_info”结尾
。
表用于描述有层次关系的复合结构的数据,整个Class文件本质上也可以视作是一张表,这张表由表6-1所示的数据项按严格顺序排列构成。
无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式
,这时候称这一系列连续的某一类型的数据为某一类型的“集合”。
本节结束之前,笔者需要再强调一次,Class的结构不像XM L等描述语言,由于它没有任何分隔符 号,所以在表6-1中的数据项,无论是顺序还是数量,甚至于数据存储的字节序(Byte Ordering,Class 文件中字节序为Big-Endian)这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少, 先后顺序如何,全部都不允许改变。接下来,我们将一起看看这个表中各个数据项的具体含义。
魔数与Class文件的版本
每个Class文件的头4个字节被称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件
。不仅是Class文件,很多文件格式标准中都有使用魔数来进行身份识别的习惯,譬如图片格式,如GIF或者JPEG等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全考虑,因为文件扩展名可以随意改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过而且不会引起混淆。Class文件的魔数取得很有“浪漫气息”, 值 为0 xC A F E B A B E ( 咖 啡 宝 贝 ? )
。 这 个 魔 数 值 在 J a v a 还 被 称 作 “ O a k ” 语 言 的 时 候 ( 大 约 是 1 9 9 1 年 前 后)就已经确定下来了。它还有一段很有趣的历史,据Java开发小组最初的关键成员Patrick Naughton 所说:“我们一直在寻找一些好玩的、容易记忆的东西,选择0xCAFEBABE是因为它象征着著名咖啡品牌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文件,因为《Java虚拟机规范》在Class文 件校验部分明确要求了即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的Class 文件。
例如,JDK 1.1能支持版本号为45.0~45.65535的Class文件,无法执行版本号为46.0以上的Class文 件,而JDK 1.2则能支持45.0~46.65535的Class文件
。目前最新的JDK版本为13,可生成的Class文件主 版本号最大值为57.0。
为了讲解方便,笔者准备了一段最简单的Java代码(如代码清单6-1所示),本章后面的内容都将 以这段程序使用JDK 6编译输出的Class文件为基础来进行讲解,建议读者不妨用较新版本的JDK跟随 本章的实验流程自己动手测试一遍。
图6-2显示的是使用十六进制编辑器WinHex打开这个Class文件的结果,可以清楚地看见开头4个字 节的十六进制表示是0xCAFEBABE,代表次版本号的第5个和第6个字节值为0x0000,而主版本号的值 为0x0032,也即是十进制的50,该版本号说明这个是可以被JDK 6或以上版本虚拟机执行的Class文件。
表6-2列出了从JDK 1.1到13之间,主流JDK版本编译器输出的默认的和可支持的Class文件版本
常量池
紧接着主、次版本号之后的是常量池入口,常量池可以比喻为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的常量值等。
- 而符号引用则属于编译原理方面的概念,主要包括下面几类常量:
- 被模块导出或者开放的包(Package)
- 类和接口的全限定名(Fully Qualified Name)
- 字段的名称和描述符(Descriptor)
- 方法的名称和描述符
- 方法句柄和方法类型(Method Handle、Method Type、Invoke Dynamic)
- 动态调用点和动态常量(Dynamically-Computed Call Site、Dynamically-Computed Constant)
Java代码在进行Javac编译的时候,并不像C和C++那样有“连接”这一步骤,而是在虚拟机加载Class 文件的时候进行动态连接(具体见第7章)
。也就是说,在Class文件中不会保存各个方法、字段最终在内存中的布局信息,这些字段、方法的符号引用不经过虚拟机在运行期转换的话是无法得到真正的内存入口地址,也就无法直接被虚拟机使用的
。当虚拟机做类加载时,将会从常量池获得对应的符号引用,再在类创建时或运行时解析、翻译到具体的内存地址之中。
常量池中每一项常量都是一个表,最初常量表中共有11种结构各不相同的表结构数据,后来为了更好地支持动态语言调用,额外增加了4种动态语言相关的常量[1],为了支持Java模块化系统 (Jigsaw),又加入了CONSTANT_M odule_info和CONSTANT_Package_info两个常量,所以截至JDK 13,常量表中分别有17种不同类型的常量。
这17类表都有一个共同的特点,表结构起始的第一位是个u1类型的标志位(tag,取值见表6-3中标 志列)
,代表着当前常量属于哪种常量类型。17种常量类型所代表的具体含义如表6-3所示。
之所以说常量池是最烦琐的数据,是因为这17种常量类型各自有着完全独立的数据结构,两两之
间并没有什么共性和联系,因此只能逐项进行讲解。
请读者回头看看图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所示。
l e n gt h 值 说 明 了 这 个 U T F - 8 编 码 的 字 符 串 长 度 是 多 少 字 节 , 它 后 面 紧 跟 着 的 长 度 为 l e n gt h 字 节 的 连 续数据是一个使用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中方法、字段名的最大长度
。而这里的 最 大 长 度 就 是 l e n gt h 的 最 大 值 , 既 u 2 类 型 能 表 达 的 最 大 值 6 5 5 3 5
。 所 以 J a v a 程 序 中 如 果 定 义 了 超 过 6 4 K B 英文字符的变量或方法名,即使规则和全部字符都是合法的,也会无法编译。
本 例 中 这 个 字 符 串 的 l e n gt h 值 ( 偏 移 地 址 : 0 x0 0 0 0 0 0 0 E ) 为 0 x0 0 1 D , 也 就 是 长 2 9 个 字 节 , 往 后 2 9 个 字 节 正 好 都 在 1 ~ 1 2 7 的 A SC I I 码 范 围 以 内 , 内 容 为 “ o r g/ f e n i xs o f t / c l a z z / T e s t C l a s s ” , 有 兴 趣 的 读 者 可 以 自己逐个字节换算一下,换算结果如图6-4中选中的部分所示。
到此为止,我们仅仅分析了TestClass.class常量池中21个常量中的两个,还未提到的其余19个常量 都可以通过类似的方法逐一计算出来,为了避免计算过程占用过多的版面篇幅,后续的19个常量的计 算过程就不手工去做了,而借助计算机软件来帮忙完成。在JDK的bin目录中,Oracle公司已经为我们 准备好一个专门用于分析Class文件字节码的工具:javap 。代码清单6-2中列出了使用javap 工具的- verbose参数输出的TestClass.class文件字节码内容(为节省篇幅,此清单中省略了常量池以外的信 息)。
从代码清单6-2中可以看到,计算机已经帮我们把整个常量池的21项常量都计算了出来,并且第 1、2项常量的计算结果与我们手工计算的结果完全一致。仔细看一下会发现,其中有些常量似乎从来 没 有 在 代 码 中 出 现 过 , 如 “ I ” “ V ” “ < i n i t > ” “ L i n e N u m b e r T a b l e ” “ L o c a l Va r i a b l e T a b l e ” 等 ,
这 些 看 起 来 在 源 代 码中不存在的常量是哪里来的?
这部分常量的确不来源于Java源代码,它们都是编译器自动生成的,会被后面即将讲到的字段表(field_info)、方法表(met hod_info)、属性表(at t ribut e_info)所引用,它们将会被用来描述一些不 方便使用“固定字节”进行表达的内容,譬如描述方法的返回值是什么,有几个参数,每个参数的类型是什么。因为Java中的“类”是无穷无尽的,无法通过简单的无符号数来描述一个方法用到了什么类, 因此在描述方法的这些信息时,需要引用常量表中的符号引用进行表达。这部分内容将在后面进一步 详细阐述。最后,笔者将17种常量项的结构定义总结为表6-6。
访问标志
在常量池结束之后,紧接着的2个字节代表访问标志(access_flags),这个标志用于识别一些类或 者接口层次的访问信息
,包括:这个Class是类还是接口;是否定义为public类型;是否定义为abstract 类型;如果是类的话,是否被声明为final;等等。具体的标志位以及标志的含义见表6-7。
类索引、父类索引与接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,而接口索引集合(interfaces)是一组u2类型的数据的集合,Class文件中由这三项数据来确定该类型的继承关系
。
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于Java语言不允许多 重继承,所以父类索引只有一个,除了java.lang.Object之外,所有的Java类都有父类,因此除了 java.lang.Object外,所有Java类的父类索引都不为0。接口索引集合就用来描述这个类实现了哪些接 口,这些被实现的接口将按implements关键字(如果这个Class文件表示的是一个接口,则应当是
e xt e n d s 关 键 字 ) 后 的 接 口 顺 序 从 左 到 右 排 列 在 接 口 索 引 集 合 中 。
字段表集合
字段表(field_info)用于描述接口或者类中声明的变量。Java语言中的“字段”(Field)包括类级变 量以及实例级变量,但不包括在方法内部声明的局部变量
。读者可以回忆一下在Java语言中描述一个 字段可以包含哪些信息。字段可以包括的修饰符有字段的作用域(public、privat e、protected修饰 符)、是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符,是否强制从主内存读写)、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、 字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标 志位来表示。而字段叫做什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。表6-8中列出了字段表的最终格式。
字段修饰符放在access_flags项目中,它与类中的access_flags项目是非常类似的,都是一个u2的数 据类型,其中可以设置的标志位和含义如表6-9所示。
跟随access_flags标志的是两项索引值:name_index和descrip tor_index
。它们都是对常量池项的引用,分别代表着字段的简单名称以及字段和方法的描述符
。现在需要解释一下“简单名称”“描述符”以 及前面出现过多次的“全限定名”这三种特殊字符串的概念。
全 限 定 名 和 简 单 名 称 很 好 理 解 , 以 代 码 清 单 6 - 1 中 的 代 码 为 例 , “ o r g/ f e n i xs o f t / c l a z z / T e s t C l a s s ” 是 这个类的全限定名,仅仅是把类全名中的“ .”替换成了“ /”而已,为了使连续的多个全限定名之间不产生混 淆,在使用时最后一般会加入一个“;”号表示全限定名结束。简单名称则就是指没有类型和参数修饰 的方法或者字段名称,这个类中的inc()方法和m字段的简单名称分别就是“ inc”和“ m”。
相比于全限定名和简单名称,方法和字段的描述符就要复杂一些。描述符的作用是用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序)和返回值
。根据描述符规则,基本数据类 型(byte、char、double、float、int、long、short、boolean)以及代表无返回值的void类型都用一个大写字符来表示,而对象类型则用字符L加对象的全限定名来表示,详见表6-10。
对于数组类型,每一维度将使用一个前置的“ [”字符来描述,如一个定义为“ java.lang.St ring[][]”类型 的二维数组将被记录成“ [[Ljava/lang/String;”,一个整型数组“ int []”将被记录成“ [I”。
用描述符来描述方法时,按照先参数列表、后返回值的顺序描述
,参数列表按照参数的严格顺序 放在一组小括号“()”之内。如方法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”。
字段表所包含的固定数据项目到descrip tor_index为止就全部结束了,不过在descrip -tor_index之后 跟随着一个属性表集合,用于存储一些额外的信息,字段表可以在属性表中附加描述零至多项的额外 信息。对于本例中的字段m,它的属性表计数器为0,也就是没有需要额外描述的信息,但是,如果将 字段m的声明改为“final static int m=123;”,那就可能会存在一项名称为ConstantValue的属性,其值指 向常量123。关于at t ribut e_info的其他内容,将在6.3.7节介绍属性表的数据项目时再做进一步讲解。
字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能出现原本Java代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问性,编译器就会自动添加指向外部类实例的字段
。另外,在Java语言中字段是无法重载的,两个字段的数据类型、修饰符不管是否相同,都必须使 用不一样的名称,但是对于Class文件格式来讲,只要两个字段的描述符不是完全相同,那字段重名就 是合法的。
方法表集合
如果理解了上一节关于字段表的内容,那本节关于方法表的内容将会变得很简单。Class文件存储 格式中对方法的描述与对字段的描述采用了几乎完全一致的方式,方法表的结构如同字段表一样,依 次包括访问标志(access_flags)、名称索引(name_index)、描述符索引(descrip tor_index)、属性表 集合(attributes)几项,
如表6-11所示。这些数据项目的含义也与字段表中的非常类似,仅在访问标 志和属性表集合的可选项中有所区别。
因为volat ile关键字和t rans ient 关键字不能修饰方法,所以方法表的访问标志中没有了 ACC_VOLATILE标志和ACC_TRANSIENT标志
。与之相对,synchronized、native、strictfp和abstract 关键字可以修饰方法,方法表的访问标志中也相应地增加了ACC_SYNCHRONIZED、 ACC_NATIVE、ACC_STRICTFP和ACC_ABSTRACT标志。对于方法表,所有标志位及其取值可参见 表6-12。
行文至此,也许有的读者会产生疑问,方法的定义可以通过访问标志、名称索引、描述符索引来 表达清楚,但方法里面的代码去哪里了?方法里的Java代码,经过Javac编译器编译成字节码指令之 后,存放在方法属性表集合中一个名为“Code”的属性里面
,属性表作为Class文件格式中最具扩展性的 一种数据项目,将在下一节中详细讲解。
与字段表集合相对应地,如果父类方法在子类中没有被重写(Override),方法表集合中就不会出现来自父类的方法信息
。但同样地,有可能会出现由编译器自动添加的方法,最常见的便是类构造 器“<clinit>()”方法和实例构造器“<init>()”方法
。
在Java语言中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名。特征签名是指一个方法中各个参数在常量池中的字段符号 引用的集合,也正是因为返回值不会包含在特征签名之中,所以Java语言里面是无法仅仅依靠返回值 的不同来对一个已有方法进行重载的。但是在Class文件格式之中,特征签名的范围明显要更大一些, 只要描述符不是完全一致的两个方法就可以共存。也就是说,如果两个方法有相同的名称和特征签 名,但返回值不同,那么也是可以合法共存于同一个Class文件中的。
属性表集合
属性表(attribute_info)在前面的讲解之中已经出现过数次,Class文件、字段表、方法表都可以携带自己的属性表集合,以描述某些场景专有的信息。
与Class文件中其他的数据项目要求严格的顺序、长度和内容不同,属性表集合的限制稍微宽松一 些,不再要求各个属性表具有严格顺序,并且《Java虚拟机规范》允许只要不与已有属性名重复,任 何人实现的编译器都可以向属性表中写入自己定义的属性信息,Java虚拟机运行时会忽略掉它不认识 的属性
。为了能正确解析Class文件,《Java虚拟机规范》最初只预定义了9项所有Java虚拟机实现都应 当能识别的属性,而在最新的《Java虚拟机规范》的Java SE 12版本中,预定义属性已经增加到29项, 这些属性具体见表6-13。后文中将对这些属性中的关键的、常用的部分进行讲解。
对于每一个属性,它的名称都要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示, 而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可
。 一个符合规则的属性表应该满足表6-14中所定义的结构。
1.Code属性
Java程序方法体里面的代码经过Javac编译器处理之后,最终变为字节码指令存储在Code属性内。 Code属性出现在方法表的属性集合之中,但并非所有的方法表都必须存在这个属性,譬如接口或者抽 象类中的方法就不存在Code属性
,如果方法表有Code属性存在,那么它的结构将如表6-15所示。
-
attribute_name_index是一项指向CONSTANT_Utf8_info型常量的索引
,此常量值固定为“Code”,它代表了该属性的属性名称,attribut e_lengt h指示了属性值的长度,由于属性名称索引与属性长度一共为 6个字节,所以属性值的长度固定为整个属性表长度减去6个字节。 -
max_stack代表了操作数栈(Operand Stack)深度的最大值
。在方法执行的任意时刻,操作数栈都不会超过这个深度。虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度。 -
max_locals代表了局部变量表所需的存储空间。在这里,max_locals的单位是变量槽(Slot),变量 槽是虚拟机为局部变量分配内存所使用的最小单位。
对于byte、char、float、int、short、boolean和r e t u r n A d d r e s s 等 长 度 不 超 过 3 2 位 的 数 据 类 型 , 每 个 局 部 变 量 占 用 一 个 变 量 槽 , 而d o u b l e 和 l o n g这 两 种 6 4 位的数据类型则需要两个变量槽来存放
。方法参数(包括实例方法中的隐藏参数“this”)、显式异常处 理程序的参数(Exception Handler Parameter,就是try-catch语句中catch块中所定义的异常)、方法体中 定义的局部变量都需要依赖局部变量表来存放。注意,并不是在方法中用了多少个局部变量,就把这 些局部变量所占变量槽数量之和作为max_locals的值,操作数栈和局部变量表直接决定一个该方法的栈 帧所耗费的内存,不必要的操作数栈深度和变量槽数量会造成内存的浪费。Java虚拟机的做法是将局 部变量表中的变量槽进行重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的变量 槽可以被其他局部变量所使用,Javac编译器会根据变量的作用域来分配变量槽给各个变量使用,根据 同时生存的最大局部变量数量和类型计算出max_locals的大小。
-
c o d e _ l e n gt h 和 c o d e 用 来 存 储 J a v a 源 程 序 编 译 后 生 成 的 字 节 码 指 令
。c o d e _ l e n gt h 代 表 字 节 码 长 度
,code是用于存储字节码指令的一系列字节流
。既然叫字节码指令,那顾名思义每个指令就是一个u1类 型的单字节,当虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的是什么指 令,并且可以知道这条指令后面是否需要跟随参数,以及后续的参数应当如何解析。我们知道一个u1 数据类型的取值范围为0x00~0xFF,对应十进制的0~255,也就是一共可以表达256条指令
。目前, 《Java虚拟机规范》已经定义了其中约200条编码值对应的指令含义,编码与指令之间的对应关系可查 阅本书的附录C“虚拟机字节码指令表”。 -
关于code_length,有一件值得注意的事情,虽然它是一个u4类型的长度值,理论上最大值可以达 到2的32次幂,但是
《Java虚拟机规范》中明确限制了一个方法不允许超过65535条字节码指令
,即它 实际只使用了u2的长度,如果超过这个限制,Javac编译器就会拒绝编译。一般来讲,编写Java代码时 只要不是刻意去编写一个超级长的方法来为难编译器,是不太可能超过这个最大值的限制的。但是, 某些特殊情况,例如在编译一个很复杂的JSP文件时,某些JSP编译器会把JSP内容和页面输出的信息归 并于一个方法之中,就有可能因为方法生成字节码超长的原因而导致编译失败。 -
Code属性是Class文件中最重要的一个属性,
如果把一个Java程序中的信息分为代码(Code,方法体里面的Java代码)和元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,那么在整 个Class文件里,Code属性用于描述代码,所有的其他数据项目都用于描述元数据
。了解Code属性是学 习后面两章关于字节码执行引擎内容的必要基础,能直接阅读字节码也是工作中分析Java代码语义问 题的必要工具和基本技能,为此,笔者准备了一个比较详细的实例来讲解虚拟机是如何使用这个属性 的。
继续以代码清单6-1的TestClass.class文件为例,如图6-10所示,这是上一节分析过的实例构造器 “ < i n i t > ( ) ” 方 法 的 C o d e 属 性 。 它 的 操 作 数 栈 的 最 大 深 度 和 本 地 变 量 表 的 容 量 都 为 0 x0 0 0 1 , 字 节 码 区 域 所占空间的长度为0x0005。虚拟机读取到字节码区域的长度后,按照顺序依次读入紧随的5个字节,并 根据字节码指令表翻译出所对应的字节码指令
。翻译“2AB7000AB1”的过程为:
我们再次使用javap 命令把此Class文件中的另一个方法的字节码指令也计算出来,结果如代码清单 6-4所示。
如果大家注意到javap中输出的“Args_size”的值,可能还会有疑问:这个类有两个方法——实例构 造器()和inc(),这两个方法很明显都是没有参数的,为什么Args_size会为1?而且无论是在参数列 表里还是方法体内,都没有定义任何局部变量,那Locals又为什么会等于1?如果有这样疑问的读者, 大概是忽略了一条Java语言里面的潜规则:在任何实例方法里面,都可以通过“this”关键字访问到此方 法所属的对象
。这个访问机制对Java程序的编写很重要,而它的实现非常简单,仅仅是通过在Javac编 译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法 时自动传入此参数
而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变 量,局部变量表中也会预留出第一个变量槽位来存放对象实例的引用,所以实例方法参数值从1开始计 算。这个处理只对实例方法有效,如果代码清单6-1中的inc()方法被声明为static,那Args_size就不会等 于1而是等于0了。
编译器为这段Java源码生成了三条异常表记录,对应三条可能出现的代码执行路径。从Java代码的 语义上讲,这三条执行路径分别为:
- 如果try 语句块中出现属于Exception或其子类的异常,转到catch语句块处理;
- 如果try 语句块中出现不属于Exception或其子类的异常,转到finally 语句块处理;
- 如果catch语句块中出现任何异常,转到finally 语句块处理。
返回到我们上面提出的问题,这段代码的返回值应该是多少?熟悉Java语言的读者应该很容易说 出答案:如果没有出现异常,返回值是1;如果出现了Excep t ion异常,返回值是2;如果出现了Exception以外的异常,方法非正常退出,没有返回值。我们一起来分析一下字节码的执行过程,从字 节码的层面上看看为何会有这样的返回结果。
字节码中第0~4行所做的操作就是将整数1赋值给变量x,并且将此时x的值复制一份副本到最后一个本地变量表的变量槽中(这个变量槽里面的值在ireturn指令执行前将会被重新读到操作栈顶,作为方法返回值使用。为了讲解方便,笔者给这个变量槽起个名字:returnValue)。
如果这时候没有出现异 常,则会继续走到第5~9行,将变量x赋值为3,然后将之前保存在returnValue中的整数1读入到操作栈 顶,最后ireturn指令会以int形式返回操作栈顶中的值,方法结束。
如果出现了异常,PC寄存器指针转 到第10行,第10~20行所做的事情是将2赋值给变量x,然后将变量x此时的值赋给returnValue,最后再 将变量x的值改为3。方法返回前同样将returnValue中保留的整数2读到了操作栈顶。从第21行开始的代 码,作用是将变量x的值赋为3,并将栈顶的异常抛出,方法结束。
2 . E xc e p t i o n s 属 性
这 里 的 E xc e p t i o n s 属 性 是 在 方 法 表 中 与 C o d e 属 性 平 级 的 一 项 属 性 , 读 者 不 要 与 前 面 刚 刚 讲 解 完 的 异 常表产生混淆。Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Excepitons),也 就是方法描述时在throws关键字后面列举的异常。它的结构见表6-17。
3.LineNumberTable属性
LineNumberTable属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。 它并不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中使用-g:none或-g:lines 选项来取消或要求生成这项信
息。如果选择不生成LineNumberTable属性,对程序运行产生的最主要影响就是当抛出异常时,堆栈中将不会显示出错的行号,并且在调试程序的时候,也无法按照源码行来设置断点。LineNumberTable属性的结构如表6-18所示。
4.LocalVariableTable及LocalVariableTypeTable属性
LocalVariableTable属性用于描述栈帧中局部变量表的变量与Java源码中定义的变量之间的关系
,它 也不是运行时必需的属性,但默认会生成到Class文件之中,可以在Javac中使用-g:none或-g:vars选项 来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所 有的参数名称都将会丢失,譬如IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,这对程 序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获 得参数值。LocalVariableTable属性的结构如表6-19所示。
其中local_variable_info项目代表了一个栈帧与源码中的局部变量的关联
,结构如表6-20所示。
- start_pc和length属性分别代表了这个局部变量的生命周期开始的字节码偏移量及其作用范围覆盖的长度,两者结合起来就是这个局部变量在字节码之中的作用域范围。
- name_index和descrip tor_index都是指向常量池中CONSTANT_Utf8_info型常量的索引,分别代表了 局部变量的名称以及这个局部变量的描述符。
- index是这个局部变量在栈帧的局部变量表中变量槽的位置。当这个变量数据类型是64位类型时 (double和long),它占用的变量槽为index和index+1两个。
顺便提一下,在JDK 5引入泛型之后,LocalVariableTable属性增加了一个“姐妹属性”—— LocalVariableTyp eTable。这个新增的属性结构与LocalVariableTable非常相似,仅仅是把记录的字段描述 符的descrip tor_index替换成了字段的特征签名(Signature)
。对于非泛型类型来说,描述符和特征签名能描述的信息是能吻合一致的,但是泛型引入之后,由于描述符中泛型的参数化类型被擦除掉,描 述符就不能准确描述泛型类型了。因此出现了LocalVariableTypeTable属性,使用字段的特征签名来完 成泛型的描述。
5 . So u r c e F i l e 及 So u r c e D e b u gE xt e n s i o n 属 性
SourceFile属性用于记录生成这个Class文件的源码文件名称
。这个属性也是可选的,可以使用Javac 的-g:none或-g:source选项来关闭或要求生成这项信息。在Java中,对于大多数的类来说,类名和文件名是一致的,但是有一些特殊情况(如内部类)例外。如果不生成这项属性,当抛出异常时,堆栈中将不会显示出错代码所属的文件名。这个属性是一个定长的属性,其结构如表6-21所示。
s o u r c e f i l e _ i n d e x数 据 项 是 指 向 常 量 池 中 C O N ST A N T _ U t f 8 _ i n f o 型 常 量 的 索 引 , 常 量 值 是 源 码 文 件 的 文件名。
为了方便在编译器和动态生成的Class中加入供程序员使用的自定义内容,在JDK 5时,新增了So u r c e D e b u gE xt e n s i o n 属 性 用 于 存 储 额 外 的 代 码 调 试 信 息 。 典 型 的 场 景 是 在 进 行 J SP 文 件 调 试 时 , 无 法 通过Java堆栈来定位到JSP文件的行号。JSR 45提案为这些非Java语言编写,却需要编译成字节码并运 行 在 J a v a 虚 拟 机 中 的 程 序 提 供 了 一 个 进 行 调 试 的 标 准 机 制 , 使 用 So u r c e D e b u gE xt e n s i o n 属 性 就 可 以 用 于 存储这个标准所新加入的调试信息,譬如让程序员能够快速从异常堆栈中定位出原始JSP中出现问题的 行 号 。 So u r c e D e b u gE xt e n s i o n 属 性 的 结 构 如 表 6 - 2 2 所 示 。
6 . C o n s t a n t Va l u e 属 性
7.InnerClasses属性
8.Deprecated及Synthetic属性
9.StackMapTable属性
10.Signature属性
11.BootstrapMethods属性
12.MethodParameters属性
13.模块化相关属性