代码编译的结果从本地机器码转变为字节码,是存储格式发展的一小步,却是编程语言 发展的一大步。
1 概述
今天的计算机只能识别0和1,但由于最近10年内虚拟机以及大量建立在虚拟机之上的程序语言如雨后春笋般出现并蓬勃发展,将我们编写的程序编译成二进制本地机器码 (Native Code)已不再是唯一的选择,越来越多的程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式。
2 无关性的基石
如果计算机的CPU指令集只有x86一种,操作系统也只有Windows一种,那也许Java语言 就不会出现。Java在刚刚诞生之时曾经提出过一个非常·著名的宣传口号:“一次编写,到处运行(Write Once,RunAnywhere)”,这句话充分表达了软件开发人员对冲破平台界限的渴求。
各种不同平台的虚拟机与所有平台都统一使用的程序存储格式一一字节码(ByteCode〉 是构成平台无关性的基石·,但本节标题中刻意省略了“平台”二字,那是因为笔者注意到虚拟机的另外一种中立特性一一语言无关性正越来越被开发者所重视。到目前为止,或许大部分程序员都还认为Java虚拟机执行Java程序是一件理所当然和天经地义的事情。但在Java发展之初, 设计者就曾经考虑过并实现了让其他语言运行在Java虚拟机之上的可能性, 他们 在发布规范文峭的时候 , 也刻意把Java的规范拆分成了Java语言规范《The Java Language Specification》及Java虚拟机规范《The Java Virtual Machine Specification》。 并且在1997年 发布的第一版Java虚拟机规范中就曾经承诺过: “ In the future, we will consider bounded extensions to the Java virtual machine to provide better support for other languages ” 〈在未来, 我们会对Java虚拟机进行适当的扩展, 以便更好地支持其他语言运行于JVM之上〉 , 当Java Ki拟机发展到JDK 1.7 ~ 1.8的时候,JVM设计者通过JSR-292基本兑现了这个承诺。
Java语言的各种变量,关键字和运算符号的语言最终都是由多条字节码命令组合而成的,因此字节码命令所能提供的语义描述能力肯定会比Java语言本身更加强大。因此,有一些Java语言本身无法有效支持的语言特性不代表字节码本身无法有效支持,这也为其他语言实现一些有别与Java的语言特性提供了基础。
3 Class类文件的结构
Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符,这使得整个Class文件中存储的内容几乎全部是程序运行的必要数据,没有空隙存在。当遇到需要占用8位以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。
根据Java虚拟机规范的规定,Class文件格式采用一种类似于C语言结构体的为结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表,后面的解析都要以这两种数据类型为基础,所以这里要先介绍这两个概念。
无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表一个字节、两个字节、四个字节、八个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表,它由如下表所示的数据项构成。
无论是无符号数还是表, 当需要描述同一类型但数量不定的多个数据时, 经常会使用一个前置的容量计数器加若干个连续的数据项的形式, 这时称这一系列连续的某一类型的数据 为某一类型的集合。
重复讲一下, Class 的结构不像XML等描述语言, 由于它没 有任何分隔符号, 所以在表6-1中的数据项, 无论是顺序还是数量, 甚至于数据存储的字节序( Byte Ordering, Class 文件中字节序为 Big-Endian )这样的细节, 都是被严格限定的, 哪 个字节代表什么含义, 长度是多少, 先后顺序如何, 都不允许改变。 接下来我们将一起看看这个表中各个数据项的具体含义。
3.1 魔数与Class文件的版本
- 每个Class文件的4个字节称为魔数(Magic Number)
- 唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。
- Class文件魔数的值为0xCAFEBABE。如果一个文件不是以0xCAFEBABE开头,那它就肯定不是Java class文件。
很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如GIF或者jpeg等在文件头中都存有魔数。使用魔数而不是扩展名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛采用过同时又不会引起混淆即可。
Class文件的魔术的获得很有“浪漫气息”,值为0xCAFEBABE(咖啡宝贝),这个魔数值在Java还称作“Oak”语言的时候就已经确定下来了。它还有一段很有趣的历史,据Java开发小组最初的关键成员Patrick Naughton所说:“我们一直在寻找一些好玩的,容易记忆的东西,选择0xCAFEBABE是因为它象征着著名咖啡品牌Peet`s Coffee中深受欢迎的Baristas咖啡”,这个魔数似乎也预示着日后“Java”这个商标名称的出现。
紧接着魔的4个字节存储的是class文件的版本号:
第5和第6个字节是次版本号(Minor Version),
第7和第8个字节是主版本号(Major Version)。
java的版本是从45开始的,JDK1.1之后的每个JDK大版本发布主版本号上加1(JDK1.0-1.1使用了45.0-45.3的版本号),
高版本的JDK能向下兼容以前版本的class文件,但不能运行以后版本的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
本章后面的内容都将以这段小程序使用JDK1.6编译输出的Class文件为基础来进行讲解
1 | 代码清单 6-2 |
图6-2 显示的是使用十六进制编辑器WinHex打开这个Class文件的结果,可以清楚的看见开头4个字节的十六进制表示是0xCAFEBABE,代表次版本号的第5个和第6个字节值为0x0000,而主版本号的值为0x0032,也即是十进制的50,该版本号说明这个文件是可以被JDK1.6或以上版本虚拟机执行的Class文件。
图6-2 java class文件结构
3.2 常量池
紧接着主次版本号之后的是常量池人口, 常量池可以理解为 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的常量值等。
符号引用则属于编译原理方面的概念,包括了下面三类常量:
类和接口的全限定名(Fully Qualified Name)
字段的名称和描述符
方法的名称和描述符
Java代码在进行Java编译的时候,并不像C和C++那样有”连接”这一步骤,而是在虚拟机加载Class文件的时候进行动态连接。也就是说,在Class文件中不会保存各个方法和字段的最终内存布局信息,**因此这些字段和方法的符号引用不经过转换的话是无法被虚拟机使用的。当虚拟机运行时,需要从常量池获得对应的符号引用,再在类创建时或运行时解析并翻译到具体的内存地址之中。 **
constant_pool_count:占2字节,本例为0x0016,转化为十进制为22,即说明常量池中有21个常量(只有常量池的计数是从1开始的,其它集合类型均从0开始),索引值为1~21。第0项常量具有特殊意义,如果某些指向常量池索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况可以将索引值置为0来表示
constant_pool:表类型数据集合,即常量池中每一项常量都是一个表,共有14种(JDK1.7前只有11种)结构各不相同的表结构数据。这14种表都有一个共同的特点,即均由一个u1类型的标志位开始,可以通过这个标志位来判断这个常量属于哪种常量类型。
每种表都有一个共同的特点,那就是表开始的第一位是一个u1类型的标志位(tag,取值见表6-3中标志列),代表当前这个常量属于那种常量类型。表中常量类型所代表的具体含义见表6-3
类型 | 标志 | 描述 |
---|---|---|
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_InterfaceMethodref_info | 11 | 接口中方法的符号引用 |
CONSTANT_NameAndType_info | 12 | 字段或方法的部分符号引用 |
CONSTANT_MethodHandle_info | 15 | 标识方法句柄 |
CONSTANT_MethodType_info | 16 | 标识方法类型 |
CONSTANT_InvokeDtnamic_info | 18 | 表示一个动态方法调用点 |
之所以说常量池是最繁琐的数据,是因为这14中常量类型各自均有自己的结构。
CONSTANT_Class_info的结构,
1 | # 表示类或接口 |
tag 是标志位上面已经讲过了,它用于区分常量类型:
name -index 是一个索引值,它 指向常量池中一个CONSTANT_Utf8 _info 类型常量,此常量代表了这个类〈或者接口)的 全限定名,假设name_index 值〈偏移地址: OxOOOOOOOB)为 Ox0002,也即是指向了常量池中的第二项常量。
CONSTANT_Utf8_info类型的结构
1 | 用于表示字符串常量的值 |
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英文字符的变量或方法名,将会无法编译。
一个专门用于分析Class文件字节码的工具:javap,代码清单6-2中列出了使用javap工具的 -verbose参数输出的TestClass.class文件字节码内容(此清单中省略了常量池以外的信息)。前面我们曾经提到过,class文件中还有很多数据项都要引用常量池中的常量
1 | admindeMacBook-Pro-4:Desktop admin$ javap -verbose TestClass |
从代码清单6-2可以看出,计算机已经帮我们把整个常量池的21项常量都计算了出来,并且第1/2项常量的计算结果与我们手工计算的结果一致。仔细看一下会发现,其中有一些常量似乎从来没有在代码中出现过,如“I”、“V”、“
这部分自动生成的常量的确没有在Java代码里面直接出现过,但它们会被后面即将讲到的字段表(field_info)、方法表(method_info)、属性表(attribute_info)引用到,它们会用来描述一些不方便使用“同定字节”进行表达的内容。譬如描述方法的返回值是什么?有几个参数?每个参数的类型是什么?因为Java巾的“类”是无穷无尽的,无法通过简单的无符号字节来描述一个方法周到了什么类,因此在描述方法的这些信息时,筒要引用常量表中的
符号引用进行表达。这部分内容将在后面进一步阐述。下面是将14种常量项的结构总结为表6-6以供读者参考。
3.3 访问标志
在常量池结束之后, 紧接着的两个字节代表访问标志(access flags), 这个标志用于识别一些类或者接口层次的访问信息, 包括:这个Class是类还是接口:是否定义为public类型:是否定义 为abstract类型:如果是类的话, 是否被声明为final等。 具体的标志位以及标志的含义见表
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 是否为public类型 |
ACC_FINAL | 0x0010 | 是否被声明为final,只有类可以设置 |
ACC_SUPER | 0x0020 | 是否允许使用invokespecial字节码指令的新语意,invokespecial指令的语意在JDK1.0.2发生过改变,为了区别这条指令使用哪种语意,JDK1.0.2之后编译出来的类的这个标志必须为真 |
ACC_INTERFACE | 0x0200 | 标识这是一个接口 |
ACC_ABSTRACT | 0x0400 | 是否为abstract类型,对于接口或者抽象类,此标志为真,其它类型为假 |
ACC_SYNTHETIC | 0x1000 | 标识这个类并非由用户代码生成 |
ACC_ANNOTATION | 0x2000 | 标识这是一个注解 |
ACC_ENUM | 0x4000 | 标识这是一个枚举 |
注:多个标识符的话,access_flags的值累加即可;由于标识符对应的值的特殊性,类加后的结果,可唯一定位有哪
些标识符,如:0x0021只有ACC_SUPER与ACC_PUBLIC的值累加后才得得到;0x0031只有ACC_SUPER与
ACC_FINAL与ACC_PUBLIC的值累加后才得得到。
注:access_flags中一共有16个标志位可以使用,当前只定义了其中八个。
3.4 类索引、父类索引与接口索引集合
类索引(this_class)和父类索引( super_class)都是一个u2类型的数据, 而接口索引集合(interfaces)是一组u2类型的数据的集合,
Class文件中由这三项数据来确定这个类的继承关系。
类索引(this_class)用于确定这个类的全限定名 ,
父类索引( super_class)用于确定这个类的父类的全限定名。 由于Java语言不允许多重继承,所以父类索引只有一个,除了java.lang.Obiect之外,所有 的Java类都存有父类, 因此除了java.lang.Object外, 所有Java类的父类索引都不为0。
接口索引(interfaces) 集合就用来描述这个类实现了哪些接口 , 这些被实现的接口将按implements 语句(如果 这个类本身是一个接口 , 则应当是extends语句〉后的接口顺序从左到在排列在接口索引集合中。
类索引、父类索引和接口索引集合都按顺序排列在访问标志之后,类索引和父类索引两个u2类型的索引值表示,它们各自指向一个类型为CONSTANT_Class_info的类描述符常量,通过CONSTANT_Class_info类型的常量中的索引值就可以找到定义在CONSTANT_Utf8_info类型的常量中的全限定名字符串。图6-6演示了代码清单6-2的代码的类索引查找过程。
对于接口索引集合,入口的第一项:u2类型的数据为接口计数器(interfaces_count)表示索引表的容量。如果该类没有实现任何接口,则该计数器值为0,后面接口的索引表不再占用任何字节。代码清单6-1中的代码的类索引,父类索引与接口索引的内容如图所示:
3.5 字段表集合
字段表(field_info)用于描述接口或类中声明的变量。字段(field)包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量。我们可以想一想在Java中描述一个字段可以包含什么信息?可以包括的信息有:字段的作用域(public、private、protected修饰符),是实例变量还是类变量(static修饰符)、可变性(final)、并发可见性(volatile修饰符),是否强制从主内存读写、可否被序列化(transient修饰符)、字段数据类型(基本类型、对象、数组)、字段名称。上述这些信息中,各个修饰符都是布尔值,要么有某个修饰符,要么没有,很适合使用标志位来表示。而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。表6-8中列出了字段表的最终格式。
类型 | 名称 | 数量 |
---|---|---|
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项目是非常类似的,都是一个u2的数据类型,其中可以设置的标志位和含义见表
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x00 01 | 字段是否为public |
ACC_PRIVATE | 0x00 02 | 字段是否为private |
ACC_PROTECTED | 0x00 04 | 字段是否为protected |
ACC_STATIC | 0x00 08 | 字段是否为static |
ACC_FINAL | 0x00 10 | 字段是否为final |
ACC_VOLATILE | 0x00 40 | 字段是否为volatile |
ACC_TRANSTENT | 0x00 80 | 字段是否为transient |
ACC_SYNCHETIC | 0x10 00 | 字段是否为由编译器自动产生 |
ACC_ENUM | 0x40 00 | 字段是否为enum |
很明显,在实际情况中,ACC_PUBLIC、ACC_PRIVATE、ACCPROTECTED、三个标志最多只能选择其一,ACC_FINAL、ACC_VOLATILE不能同时选择。接口之中的字段不许有ACC_PUBLIC、ACC_STATIC、ACC_FINAL标志,这些都是由Java本身的语言规则所决定的。
跟随access_flags标志的是两项索引:name_index和descriptor_index。 它们都是对常 最池的引用, 分别代表着字段的简单名称以及字段和方法的描述符。 现在需要解释一下“简 单名称”、“描述符” 以及前面出现过多次的 “全限定名 ” 这三种特殊字符串的概念。
全限定名和简单名称很好理解, 以代码消单,6-2中的代码为例,“org/fenixsoft/clazz/ TestClass '’ 是这个类的全限定名, 仅仅是把类企名中的气” 替换成了 “ / ” 而已 , 为了使连续 的多个全限定名之间不产生混淆, 在使用时最后一般会加入一个 “ ;” 表示全限定名结束。 简 单名称是指没有类型和参数修饰的方法或者字段名称, 这个类中的inc()方法和m字段的简 单名称分别是 “ inc ” 和 “ m”
相对于全限定名和简单名称来说.方法和字段的描述符就要复杂一些。描述符的作用是 用来描述字段的数据类型、方法的参数列表(包括数量、类型以及顺序〉和返回值。根据描 述符规则,基本数据类型(byte、char、double、float、int、long、short、boolean)以及代表无返 回值的 void 类型都用一个大写字符来表示, 而对象类型则用字符L 加对象的全限定名来 表示,详见表
标志符 | 含义 |
---|---|
B | 基本数据类型byte |
C | 基本数据类型char |
D | 基本数据类型double |
F | 基本数据类型float |
I | 基本数据类型int |
J | 基本数据类型long |
S | 基本数据类型short |
Z | 基本数据类型boolean |
V | 基本数据类型void |
L | 对象类型 |
对于数组类型, 每一维度将使用一个前置的 “ [” 字符来描述, 如一个定义为 “java.lang. String[][]" 类型的二维数组, 将被记录为: “ [[Ljava/lang/String; ”, 一个整型数组 “ int[] ” 将被记 录为 “ [I ”。
用描述符来描述方法时 , 按照先参数列表, 后返问值的顺序捕述, 参数列表按照参数的严格顺序放在 一组小括号“()”之内。
字段表都包含的固定数据项目到 descriptor_index 为止就结束了, 不过在 descriptor_ index 之后跟随着一个属性表集合用于存储一些额外的信息, 字段都可以在属性表中描述零至多项的额外信息。 对 于本例中的字段m, 它的属性表汁数器为0, 也就是没有需要额外描述的信息,但是如果将字段m的声明改为“final static int m = 123;” 那就可能会存在一项称为ConstantValue的属性,其值指向常量123。
字段表集合中不会列出从父类或者父接口中继承而来的字段,但有可能列出原来Java代码中不存在的字段,譬如在内部类中为了保持对外部类的访问性,会自动添加指向外部类实例的字段.另外,在Java语言中字段是无法重载的,两个字段的数据类型,修饰符不管是否相同,都必须使用不一样的名称,但是对于字节码来讲,如果连个字段的描述符不一致,那字段重名就是合法的.
3.6 方法表集合
如果理解了上一节关于字段表的内容, 那本节关于方法表的内容将会变得很简单。Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式, 方法表的结构如 同字段表一样, 依次包括访问标志(access_flags)、 名称索引 ( name _index) 、 描述符索引 ( descriptor_index)、 属性表集合(attributes)几项, 见表。 这些数据项目的含义也非常 类似, 仅在访问标志和属性表集合的可选项中有所区别。
名称 | 类型 | 数量 |
---|---|---|
access_flags | u2 | 1 |
name_index | u2 | 1 |
descriptor_index | u2 | 1 |
attributes_count | u2 | 1 |
attributes | attribute_info | attributes_count |
因为volatile关键字和transient关键字不能修饰方法,所以方法表的访问标志中没有了ACC_VOLATILE标志。与之相对的,synchronized、native、strictfp和abstract关键字可以修饰方法,所以方法表的访问标志中增加了ACC_SYNCHRONIZED、ACC_NATIVE、ACC_STRJCTFP和ACC_ABSTRACT标志。对于方法表,所有标志位及其取值可参见表
标志名称 | 标志值 | 含义 |
---|---|---|
ACC_PUBLIC | 0x0001 | 方法是否为public |
ACC_PRIVATE | 0x0002 | 方法是否为private |
ACC_PROTECTED | 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 | 方法是否是由编译器自动产生的 |
与字段表集合相对应的, 如果父类方法在子类中没有被重写( Override ), 方法表集合中就不会出现来自父类的方法信息。 但同样的, 有可能会出现由编译器自动添加的方法, 最典型的便是类构造器“
在java中,要重载(Overload)一个方法,除了要与原方法具有相同的简单名称之外,还要求必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数再常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,因此Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。但是在class文件格式中,特征签名的范围更大一些,只要描述符不是完全一致的两个方法也可以共存。也就是说,如果两个方法有相同的名称和特征签名,但返回值不同,那么也是可以合法共存在同一个class文件中的。
3.7 属性表集合
属性表集合(attribute_info)在前面的讲解中已经出现过多次,在class文件、字段、方法表都可以携带自己的属性表集合,一用于描述某些场景专有的信息。
与class文件中其他的数据项目要求严格的顺序,长度和内容不同,属性表集合的限制稍微宽松了一些,不再要求各个属性表具有严格顺序,并且只要不与已有属性名重复,任何人实现的编译器都可以向属性表中写入自己定义的属性信息,java虚拟机运行时会忽略掉它不认识的属性,为了能正确解析class文件,最新的《java虚拟机规范(Java SE 7)》版本中,预定义属性已经增加到21项,具体内容见表。下文中将对其中一些属性中的关键常用的部分进行讲解。
属性名称 | 使用位置 | 含义 |
---|---|---|
Code | 方法表 | Java代码编译成的字节码指令 |
ConstantValue | 字段表 | final关键字定义的常量值 |
Deprecated | 类、方法表、字段表 | 被声明为deprecated的方法和字段 |
Exceptions | 方法表 | 方法抛出的异常 |
EnclosingMethod | 类文件 | 仅当一个类为局部类或者匿名类时才能拥有这个属性,这个属性用于标识这个类所在的外围方法 |
InnerClasses | 类文件 | 内部类列表 |
LineNumberTable | Code属性 | Java源码的行号与字节码指令的对用关系 |
LocalVariableTable | Code属性 | 方法的局部变量描述 |
StackMapTable | Code属性 | JDK1.6中新增的属性,供新的类型检查验证器(Type Checker)检查和处理目标方法的局部变量和操作数栈所需要的类型是否匹配 |
Signature | 类、方法表、字段表 | JDK1.5中新增的属性,这个属性用于支持泛型情况下的方法签名,在Java语言中,任何类、接口、初始化方法或成员的泛型签名如果包含了类型变量(Type Variables)或参数化类型(Parameterized Types),则Signature属性会为他记录泛型签名信息。由于Java的泛型采用擦除法实现,在为了避免类型信息被擦出后导致签名混乱,需要这个属性记录泛型中的相关信息 |
SourceFile | 类文件 | 记录源文件名称 |
SourceDebugExtension | 类文件 | JDK 1.6中新增的属性,SourceDebugExtension属性用于存储额外的调试信息,譬如在进行JSP文件调试时,无法同构Java堆栈来定位到JSP文件的行号,JSR-45规范为这些非Java语言编写,却需要编译成字节码并运行在Java虚拟机中的程序提供了一个进行调试的标准机制,使用SourceDebugExtension属性就可以用于存储这个标准所新加入的调试信息 |
Synthetic | 类、方法表、字段表 | 标识方法或字段为编译器自动生成的 |
LocalVariableTypeTable | 类 | JDK 1.5中新增的属性,他使用特征签名代替描述符,是为了引入泛型语法之后能描述泛型参数化类型而添加 |
RuntimeVisibleAnnotations | 类、方法表、字段表 | JDK 1.5中新增的属性,为动态注解提供支持。RuntimeVisibleAnnotations属性用于指明哪些注解是运行时(实际上运行时就是进行反射调用)可见的 |
RuntimeInVisibleAnnotations | 类、方法表、字段表 | JDK 1.5新增的属性,与RuntimeVisibleAnnotations属性作用刚好相反,用于指明哪些注解是运行时不可见的 |
RuntimeVisibleParameter Annotations | 方法表 | JDK 1.5新增的属性,作用与RuntimeVisibleAnnotations属性类似,只不过作用对象为方法参数 |
RuntimeInVisibleAnnotations Annotations | 方法表 | JDK 1.5中新增的属性,作用与RuntimeInVisibleAnnotations属性类似,只不过作用对象为方法参数 |
AnnotationDefault | 方法表 | JDK 1.5中新增的属性,用于记录注解类元素的默认值 |
BootstrapMethods | 类文件 | JDK 1.7中新增的属性,用于保存invokedynamic指令引用的引导方法限定符 |
对于每一个属性,它的名称需要从常量池中引用一个CONSTANT_Utf8_info类型的常量来表示,而属性值的结构则是完全自定义的,只需要通过一个u4的长度属性去说明属性值所占用的位数即可。一个符合规则的属性表应该满足表中所定义的结构。
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u1 | info | attribute_length |
3.7.1 Code属性
Java 程序方法体中的代码经过 Javac 编译器处理后, 最终变为字节码指令存储在 Code 属性内。 Code 属性出现在方法表的属性集合之中, 但并非所有的方法表都必须存在这个属性, 譬如接口或者抽象类中的方法就不存在 Code 属性, 如果方法表有 Code 属性存在, 那么它的 结构将如表 所示 。
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 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 是一项指向 CONSTANT_Utf8 __ info型常量的索引,常量固定为“code”,它代表了该属性的属性名称,
attribute_length’ 指示了属性值的长度,由于属性名称索引与属性程度一共为6个字节,所以属性值的长度固定为整个属性表程度减去6个字节
max_stack 代者了操作数栈( Operand Stack的深度的最大值。 在方在执行前任意时刻: 操作数栈都不会超过这个深度。 虚拟机运行的时候需要根据这个值来分配栈帧(Stack Frame)中的操作栈深度 。max_locals 代表了局部变量表所需的存储空间。在这里,max_locals的单位是slot,Slot是虚拟机为局部变量分配内存所使用的最小单位。对于byte、char、float、int、short、boolean和returnAddress等长度不超过32位的数据类型,每个局部变量占用1个Slot,而double和long这两种64位的数据类型则需要两个Slot来存放。方法参数(包括实例方法中的隐藏参数“this”)、显示异常处理器的参数(Exception Handler Parameter,就是try-catch语句中catch块所定义的异常)、方法体中定义的局部变量都需要使用局部变量表来存放。另外,并不是在方法中用到了多少个局部变量,就把这些局部变量所占Slot之和作为max_locals的值,原因是局部变量表中的Slot可以重用,当代码执行超出一个局部变量的作用域时,这个局部变量所占的Slot可以被其它局部变量所使用,Javac编译器会根据变量的作用域来分配Slot给各个变量使用,然后计算出max_locals的大小。
code_length 和code用来存储Java源程序编译后生成的字节码指令。code_length 代表字节码长度,code是用于存储字节码指令的一系列字节流。既然叫字节码指令,那么每个指令就是一个u1类型的单字节,当虚拟机读取到code中的一个字节码时,就可以对应找出这个字节码代表的是什么指令,并且可以知道这条指令后面是否需要跟随参数,以及参数应当如何理解。我们知道一个u1数据类型的取值范围为0x00
0xFF,对应十进制的0255,也就是一共可以表达256条指令,目前,java虚拟机规范已经定义了其中约200条编码值对应的指令含义,Code属性是Class文件中最重要的一个属性,如果把一个Java程序中的信息分为代码(Code,方法体里面的Java代码)和元数据(Metadata,包括类、字段、方法定义及其他信息)两部分,那么在整个Class文件中,Code属性用于描述代码,所有的其他数据项都用于描述元数据。了解Code属性是后面学习关于字节码执行引擎内容的必要基础
注意编译后的“Args_size”值
- 实例构造器
() 和 inc() 没有参数, Args_size却为1 - 无论试下参数列表里还是方法体内没有定义任何局部变量 Locals等于1
注:在任何实例方法里面,都可以通过“this”关键字访问到此方法所属的对象。它是通过javac编译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数,因此在实例方法的局部变量表中至少存在一个指向当前对象实例的局部变量,局部变量表也会预留出第一个Slot位来存放对象实例的引用,方法参数值从1开始计算。这个处理只对实例方法有效,如果方法声明为static,那args_size就不会等于1而是0了。
3.7.2 Exceptions 属性
这里的Exceptions 属性是在方法表中与Code属于平级的一项属性,Exceptions属性的作用是列举出方法中可能抛出的受检查异常(Checked Exceptions),也就是方法描述时在throws关键字后面列举的异常。它的结构见表:
类型 | 名称 | 数量 | 类型 | 名称 | 数量 |
---|---|---|---|---|---|
u2 | attribute_name_index | 1 | u2 | number_of_exceptions | 1 |
u4 | attribute_length | 1 | u2 | exception_index_table | number_of_exceptions |
Exceptions属性中的number_of_exceptions项表示方法可能抛出number_of_exceptions种受检查异常,每一种受查异常使用一个exception_index_table项表示,exception_index_table是一个指向常量池中CONSTANT_Class_info型常量的索引,代表了该受查异常的类型
3.7.3 LineNumberTable
LineNumberTable 属性用于描述Java源码行号与字节码行号(字节码的偏移量)之间的对应关系。它并不是运行时必要属性,但默认会生成到Class文件中,可以在 Javac中分别使用-g:none或-g:Jines选项来取消或要求生成这项信息。 如果选择不生成 LineNumberTable属性, 对程序运行产生的最主要的影响就是当抛出 异常 时, 堆栈中将不会 显示出错的行号, 并且在调试程序的时候, 也无法按照源码行来设置断点。 LineNumbcrTablc 属性的结构见表
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | line_number_table_length | 1 |
line_number_info | line_number_table | line_number_table_length |
line_number_table是一个数量为line_number_table_length、类型为line_number_info的集合,line_number_info表包括了start_pc和line_number 两个u2类型的数据项,前者是字节码行号,后者是java源码行号。
3.7.4 LocalVariableTable
LocalVariablelTable属性用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系, 它也不是运行时必能的属性,但默认会生成到Class文件之中, 可以在Javac 中分别使用-g:none或-g:vars选项采取消或要求生成这项信息。 如果没有生成这项属性, 最大的影响就是当其他人引用这个方法时, 所有的参数名称都将会丢失, IDE将会使用诸如arg0、 argl之类的占位符代替原有的参数名, 这对程序运行没有影响, 但是会对代码编写带 来较大不便, 而且在调试期间无法根据参数名称从上下文中获得参数值。 LocalVariablelTable属性的结构见表
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | local_variable_table_length | 1 |
local_variable_info | local_variable_table | local_variable_table_length |
u2 | start_pc | 1 |
u2 | length | 1 |
u2 | name_index | 1 |
u2 | descriptor_index | 1 |
u2 | index | 1 |
start_pc和fength-属性分别代表了这个局部变量的生命周期开始的字节码偏移盘及其作用范围覆盖的长度,两者结合起来就是这个局部变量在字节码之中的作用域范围。
name_index和descriptor_index都是指向常量池中CONSTANT_Utf8_info型常量的索引,分别代表了局部变量的名称以及这个局部变量的描述符。
index是这个局部变量在栈帧局部变量表中Slot的位置。当这个变量数据类型是64位类 型时(double和long),它占用的Slot为index和index + 1 两个。
顺便提一下,在JDK1.5引入泛型之后,LocalVariableTable属性增加了“姐妹属性“LocafVariableTypeTable,这个新增的属性结构与-LocalVariableTable非常相似;仅仅是把记录的字段描述符的-descriptor_index替换成了字段的特征签名(Signafure),对于非 泛型类型来说,描述符和特征签名能描述的信息是基本一致的,但是泛型引术之后,由于描述符中泛型的参数化类型被擦除掉,描述符就不能准确地描述泛型类型了,因此出现了LocafVariableTypeTable
5 SourceFile 属性
SourceFile 属性用于记录生成这个Class文件的源码文件名称。这个属性也是可选的,可以分别使用Javac的-g:none或 -g:source 选项来关闭或要求生成这项信息,在Java中,对于大多数的类来说,类名和文件名是一致的,但是有一些特殊情况(如内部类)例外。如果不生成这项属性,当抛出异常时,堆栈中将不会显示出错代码所属的文件名,这个属性是一个定长的属性,其结构见表
类型 | 名称 | 数量 |
---|---|---|
u2 | attribute_name_index | 1 |
u4 | attribute_length | 1 |
u2 | sourcefile_index |
sourceFile_index 数据项是指向常量池中CONSTANT_Utf8_info 型常量的索引,常量值是源码文件的文件名
4 字节码指令
4.1 字节码与数据类型
Java虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)而构成。
- 除了long和double类型外,每个变量都占局部变量区中的一个变量槽(slot),而long及double会占用两个连续的变量槽。
- 大多数对于boolean、byte、short和char类型数据的操作,都使用相应的int类型作为运算类型。
4.2 加载和存储指令
将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>。
将一个数值从操作数栈存储到局部变量表:istore、istore_<n>、lstore、lstore_<n>、fstore、fstore_<n>、dstore、dstore_<n>、astore、astore_<n>。
将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2_w、aconst_null、iconst_m1、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。
扩充局部变量表的访问索引的指令:wide。
存储数据的操作数栈和局部变量表主要就是由加载和存储指令进行操作,除此之外,还有少量指令,如访问对象的字段或数组元素的指令也会向操作数栈传输数据。
4.3 运算指令
运算或算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶,大体上算术指令可以分为两种:对整形和对浮点型进行算术的指令,无论哪种指令,都使用Java虚拟机的数据类型,由于没有直接支持byte、short、char和boolean类型的算术指令,对于这类数据的运算,应使用操作int类型的指令代替。整数与浮点数的算术指令在溢出和被零除的时候也有各自不同的行为表现,所有的算术指令如下。
- 加法指令:iadd、ladd、fadd、dadd
- 减法指令:isub、lsub、fsub、dsub
- 乘法指令:imul、lmul、fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem
- 取反指令:ineg、lneg、fneg、dneg
- 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
- 按位或指令:ior、lor
- 按位与指令:iand、land
- 按位异或指令:ixor、lxor
- 局部变量自增指令:iinc
- 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp
4.4 类型转换指令
类型转换指令可以将两种不同的数值类型进行相互转换,这些转换操作一般用户实现用户代码中的显示类型转换操作,或者用来处理字节码指令集中数据类型相关指令无法与数据类型一一对应的问题。
Java虚拟机直接支持(即转换时无显示的转换指令)
相对的,处理窄化类型转换时,必须显示地使用转换指令来完成,这些转换指令包括:i2b、i2c、i2s、l2i、f2i、f2l、d2l和d2f。窄化类型转换可能会导致转换结果产生不同的正负号、不同的数量级的情况,转换过程很可能会导致数值的精度丢失。尽管数据类型窄化转换可能会发生上限溢出、下限溢出和精度丢失情况,但是Java虚拟机规范中明确规定数值类型的窄化转换指令永远不可能导致虚拟机抛出运行时异常。
4.5 对象创建与访问指令
虽然类实例和数组都是对象,但Java虚拟机对类实例和数组的创建与操作使用了不同的字节码指令。对象创建后,就可以通过对象访问指令获取对象实例或者数组实例中的字段或者数组元素,这些指令如下。
- 创建类实例的指令:new
- 创建数组的指令:newarray、anewarray、nultianewarray
- 访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield、putfield、getstatic、putstatic
- 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
- 将一个操作数栈的值存储到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
- 取数组长度的指令:arraylength
- 检查类实例类型的指令:instanceof、checkcast
4.6 操作数栈管理指令
如同操作一个普通数据结构中的堆栈那样,Java虚拟机提供了一些用于直接操作操作数栈的指令,包括:
- 将操作数栈的栈顶一个或两个元素出栈:pop、pop2
- 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
- 将栈最顶端的两个数值互换:swap
4.7 控制转移指令
控制转移指令可以让Java虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控制转移指令就是在有条件或无条件地修改PC寄存器的值。控制转移指令如下:
- 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne
- 复合条件分支:tableswitch、lookupswitch
- 无条件分支:goto、goto_w、jsr、jsr_w、ret
4.8 方法调用和返回指令
方法调用(分派、执行过程)的指令
invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
invokeinterface 指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
invokespecial指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
invokestatic 指令用于调用类方法(static方法)
invokedynamic 指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面4条调用指令的分派逻辑都固话在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
方法调用指令与数据类型无关,而方法返回指令是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)lrenturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法以及类和接口的类初始化方法使用。
4.9 异常处理指令
在Java程序中显示抛出异常的操作(throw语句)都由athrow指令来实现,除了用throw语句显示抛出异常情况之外,Java虚拟机规范还规定了许多运行时异常会在其他Java虚拟机指令检测到异常状况时自动抛出。例如,在前面介绍的整数运算中,当除数为零时,虚拟机会在idiv或ldiv指令中抛出ArithmeticException异常
而在Java虚拟机中,处理异常(catch语句)不是由字节码指令来实现的(很久之前曾经使用jsr和ret指令来实现,现在已经不用了),而是采用异常表来完成的。
4.10 同步指令
Java虚拟机可以支持方法级的同步和方法内部一段指令序列的同步,这两种同步结构都是使用管理(Monitor)来支持的。
方法级的同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标志得知一个方法是否声明为同步方法。当当方法调用时,调用指令将会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管理,然后才能执行方法,最后当方法完成(无论是正常还是非正常完成)时释放管理。在方法执行期间,执行线程持有了管理,其他任何线程都无法再获取到同一个管理。如果一个同步方法执行期间抛出了异常,并且在方法内部无法处理此异常,那么这个同步方法所持有的管理将在异常抛到同步方法之外时自动释放。
同步一段指令集序列通常是由java 语言中的synchroniaec语句块表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要javac编译器与java虚拟机两者共同协作支持,譬如代码清单6-6中所示的代码:
1 | 代码清单6-6 代码同步演示 |
1 | admindeMacBook-Pro-4:~ admin$ javap -verbose Desktop/note/TestClass |
编译器必须确保无论通过何种方式完成,方法中调用过测每条monitorenter指令都必须执行其对应的monitorexit指令,而无论这个方法是正常结束还是异常结束。
从代码清单6-6 的字节码序列中可以看到,为了保证在方法异常完成时monitorenter和monitorexit指令依然可以正确配对执行,编译器会自动产生一个异常处理器,这个异常处理器声明可处理所有的异常,它的目的就是用来执行monitorexit指令
参考《深入理解Java虚拟机》