本篇总结自《深入了解 Java 虚拟机 第三版》 第 6 章的内容。只有理解了字节码文件,才能真正理解 Java 的跨平台特性1。同时,它也是虚拟机类加载机制和虚拟机字节码执行引擎等部分的前置内容。
全文的核心内容可分为两部分。第一部分为 ( 需放大查看 ):
第二部分为:
1. 无关性基石
“Write Once, Run anywhere.” 这是 Java 初诞生时曾提出的口号。如果所有计算机的指令集只有 x86 一种,操作系统也只有 Windows,或许 Java 当初就不会诞生。事实上是,我们不希望世界上任何一家公司能够垄断 IT 界,不同硬件体系接口,不同的操作系统必然会长期并存和发展。Oracle 公司以及其它虚拟机发行商都推出过能够运行在各大操作系统的 Java 虚拟机,从而在应用层的层面实现了 "一次编写,到处运行"。
Java 的平台无关性将高级语言推进到了一个新的高度。编程语言未来的发展还会是什么样的?
其实,Java 及其 JVM 的设计者们很早就认真地考虑过:也许将来运行在 JVM 的程序,未必就一定是由 Java 语言开发的。因此,他们在当初就有意地将 Java 规范拆分为了 《 Java 语言规范 》 和 《 Java 虚拟机规范 》。早在 1997 年,第一版 《 Java 虚拟机规范 》 就曾留下了这样的承诺:“在未来,我们会对 Java 虚拟机做适当的拓展,以便更好的支持其它语言运行于 Java 虚拟机之上”。
Java 虚拟机发展到今天,这个承诺可以说是被兑现了。现在已经发展出了一大批运行在 Java 虚拟机之上的语言,比如 Scala ( 笔者正在学习的语言 ),Kotlin,Jython,Groovy 等。无论是哪个语言,在编译阶段,它们会被统一翻译成遵循 《 Java 虚拟机规范 》的字节码格式,而 Java 虚拟机丝毫不关心字节码来源于何种编程语言。
无论是平台无关性,还是语言无关性,字节码文件的地位举足轻重。图灵完备的字节码文件没有与任何一个语言进行强绑定,它仅为那些想要运行在 Java 虚拟机的语言提供了应有的规范和约束,这些语言所表述的各种语法,关键字,常量变量,运算符等语义最终会被转换为统一格式的字节码指令来表达。
2. Class 文件结构
当初的 Class 文件为何不考虑用可读性更强的 XML 等描述语言或者是其它类型的字符文件来实现?
<class language = "Java" version = "1.8.241">
<className>HelloJava</className>
<contant_info>...</contant_info>
</class>
首先,字符文件总是要考虑到编码问题,其次,即便是 《 Java 虚拟机规范 》 统一了 Class 文件的字符集格式,还要考虑到当初 Java 诞生的时代背景:在 90 年代,网络资源的传输效率还很低,加上 Class 文件本身要包含更多的内部信息来保证源代码可以被正确解读并运行,人们不希望被编译后的 Class 文件成为一个 ”庞然大物“ ,这显然违背了 "到处运行" 的初衷 —— 一个字节本可以用于表达 256 条指令,或者是用于表示 0x00 ~ 0xFF 之间的数值,而对于字符文件来说,单是一个字符 ( 比如一个汉字 ) 就会耗费最多三个字节 ( 24 位) 的空间。
因此,当初 Java 虚拟机的设计者自然就使用结构更加紧凑的字节文件的形式来实现。Class 文件是一个以 8 字节为基础单位的二进制流文件,各个数据项目严格按照顺序紧凑排列在文件内,哪个字节表示什么信息,长度多少都是被严格规定的。
2.1 Class 文件的数据结构
《 Java 虚拟机规范 》 规定,Class 文件格式采用类 C 语言风格的伪结构体来存储数据,因此这类伪数据结构中只有两种数据类型:"无符号数" 和 "表" 。无符号数是基本的数据类型,以 u1, u2,u4,u8 来代表该数据的长度为 1 ,2,4,8 个字节。无符号数本身可以描述数字,索引引用,或者是 UTF-8 编码的字符串。
而表是由多个无符号数或者子表构成的复合数据类型,命名上都会以 _info 后缀来结尾。整个 Class 文件也可以被视作一张描述了一个类的所有信息的总表。Class 文件的大致结构如下表所示,从命名上能推测它大致描述了类的这些信息:文件版本号,常量池,访问标志,类信息,父类信息,接口信息,字段域,方法域,以及 "属性"。在后文我们会探讨各个数据项所表示的意义。
| 类型 | 名称 | 数量 |
|---|---|---|
| u4 | magic | 1 |
| u2 | minor_version | 1 |
| u2 | major_version | 1 |
| u2 | constant_pool_count | 1 |
| cp_info | constant_pool | constant_pool_count-1 |
| u2 | access_flags | 1 |
| u2 | this_class | 1 |
| u2 | super_class | 1 |
| u2 | interfaces_count | 1 |
| u2 | interfaces | interfaces_count |
| u2 | fields_count | 1 |
| field_info | fields | fields_count |
| u2 | methods_count | 1 |
| method_info | methods | methods_count |
| u2 | attributes_count | 1 |
| attribute_info | attributes | attributes_count |
2.2 魔数
开头的四个字节 magic 表征了 Class 文件类型,而且这个值的十六进制表示恰好是 0xCAFE BABE ( 它似乎暗示了 Java 名称和其 "咖啡" 图标的由来,Java 其实是一个著名的咖啡品牌 Peet's Coffee 的咖啡名 ) 。虚拟机不会轻易地将一个 .class 为后缀的文件实别为 Class 文件,而是靠这个魔数来实别,因为用户可以自由更改文件的后缀名。实际上大部分软件都不太依赖后缀名来区分不同类型的文件,将文件类型隐藏在字节序列中显然是更加安全的做法。
紧随其后的四个字节描述了该 Class 的版本号,其中 minor_version 是次版本号,而 major_version 则作为主版本号。《 Java 虚拟机规范 》针对版本号做出了严格的规定:高版本 JDK 一定要保证向下兼容低版本的 Class 文件,但不会运行更高版本的 Class 文件。即便在高版本的 Class 文件全部是向前兼容的,虚拟机也必须拒绝执行。
JDK 5 的版本号对应为 49,此后每升级一个主版本,版本号对应加一。这里不妨自己动手测试一遍,下面的代码笔者将在 JDK 8 环境下编译:
public class TestJava {
public static void main(String[] args){
int a = 1;
int b = 2;
int c = a + b;
System.out.println(c);
}
}
下图是使用装载 Hex-Editor 插件的 Notepad ++ 打开其编译出的 Class 文件的显示结果:
主版本号的值是 0x0034 ,经过计算后的十进制为 52,正好对应 JDK 8 的版本号,换句话说这是一个可以在 8 及以下版本的 JDK 运行的 Class 文件。另外,次版本号在 JDK 1.3 ~ JDK 12 之前都为 0x0000 。
2.3 常量池
常量池相当于 Class 文件自带的数据仓库。由于常量的数量不固定,因此首先使用 2 个字节的长度用于计数,且和其它表都不同的一点是:从 1 开始计数。这样,实际的常量个数等于 constant_pool_count - 1 。每一个常量以单独的 constant_pool 项紧随其后。下标 0 被保留的原因是:它被用于表示 "不引用任何常量池项目"。
常量池存放的内容有两个:
第一类是字面量 ( Literal ),指代那些文本字符串,被声明为 final 的常量值。
第二类是符号引用 ( Symbolic References ),指代:
- 被模块导出或开放的包 ( Package );
- 类和接口的全限定名 ( Fully Qualified Name );
- 字段的名称或者是描述符 ( Descriptor );
- 方法的名称或者是描述符;
- 方法句柄和方法类型 ( Method Handle,Method Type,Invoke Dynamic );
- 动态调用点和动态常量 ( Dynamically-Computed Call Site,Dynamically-Computed Constant )。
和 C/C++ 语言不同,虚拟机只有在加载这个 Class 文件中才会做动态连接,符号引用只有在运行期间才会获得真正的内存地址。
常量池的每一个常量都会以表的形式保存,每种表都以 CONSTANT_XXX_info 来命名,XXX 代表了该常量所属的类型。截至 JDK 13 版本,已经有 17 种类型的常量池表,且每一种表的结构,所描述的内容各不相同,但是都会至少存在一个 u1 类型的标志位相互区分。
| 类 型 | 标 志 | 描 述 |
|---|---|---|
| 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_InvokeDynamic_info | 18 | 表示一个动态方法调用点 |
以其中一个 CONSTANT_Utf8_info 型表为例子,Class 文件中各种方法,字段的名称都会以 UTF-8 缩略码2形式保存在其中。
| 类型 | 名称 | 数量 |
|---|---|---|
| u1 | tag | 1 |
| u2 | length | 1 |
| u1 | bytes | length |
由于 length 的长度限制,如果某个方法名或者字段的英文名称超过了 64KB 长度,这个类文件将无法通过编译。另外,字符串类型的常量是使用另一个 CONSTANT_String_info 来保存的。
不过,即便使用 Notepad++ 工具,按字节流逐项解析 Class 文件还是一件很费劲的事情。这里不妨直接使用 javap 工具来完成解析前文的 TestClass : ( 下面省略了常量池以外的内容 )
Constant pool:
#1 = Methodref #5.#14 // java/lang/Object."<init>":()V
#2 = Fieldref #15.#16 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #17.#18 // java/io/PrintStream.println:(I)V
#4 = Class #19 // TestJava
#5 = Class #20 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 main
#11 = Utf8 ([Ljava/lang/String;)V
#12 = Utf8 SourceFile
#13 = Utf8 TestJava.java
#14 = NameAndType #6:#7 // "<init>":()V
#15 = Class #21 // java/lang/System
#16 = NameAndType #22:#23 // out:Ljava/io/PrintStream;
#17 = Class #24 // java/io/PrintStream
#18 = NameAndType #25:#26 // println:(I)V
#19 = Utf8 TestJava
#20 = Utf8 java/lang/Object
#21 = Utf8 java/lang/System
#22 = Utf8 out
#23 = Utf8 Ljava/io/PrintStream;
#24 = Utf8 java/io/PrintStream
#25 = Utf8 println
#26 = Utf8 (I)V
其中,常量池还额外存储了诸如 <init>,()V,(I)V 等 "额外的" 常量。这些常量来源于字段表 ( field_info ),方法表 ( method_info ),属性表 ( attribute_info ) 。譬如字段名,方法名无法简单地用标志位形容,故这些不固定的值被存放在了 CONSTANT_Utf8_info 项中。
2.4 访问标志
常量池之后的 2 个字节是访问标志 ( access_flags ),用于标记类或者接口的层次信息,比如描述这个 Class 是类还是接口,是否被 final,abstract,public 等关键词修饰等等。刚才 javap -v 命令的解析结果显示了这样一段信息:
public class TestJava
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool: ....
其中,flags 项的 ACC_PUBLIC 表示了这个类是公开的类。另外,所有在 JDK 1.2 以上编译的 Class 文件中都会附带 ACC_SUPER 标志位。
2.5 类索引,父类索引与接口索引集合
类索引 ( this_class ) 和父类索引 ( super_class ) 都是一个 u2 类型的数据结构,而接口索引是一组 u2 类型的数据集合。这三者描述出了该 Class 的继承关系。 Java 不允许多重继承,因此除了 java.lang.Object 以外的类均只有一个非 0 索引 ( 0 号索引只有 java.lang.Object 可以使用 ) 。Java 允许实现多个接口,这些接口会以从左到右的顺序依次排列到类接口索引集合内。如果 interfaces_count 值为 0 ,则说明该类没有实现任何接口。
2.6 字段表集合
方法表集合记录了 Class 文件内部的所有字段 ( Field ) ,这包括了类级别 ( static ) 的变量和实例级别的变量,但是不包括方法内定义的局部变量。一个字段允许的修饰符包括了:类权限修饰符,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 |
每个字段可以添加属性表 ( attributes_count 和 attributes ) 来描述这个变量额外的属性。其中 access_flags 可选的标志位为:
| 标志名称 | 标志值 | 含义 |
|---|---|---|
| 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 和 descriptor_index 都是对常量池的引用。name_index 存取的是字段的简单名称,而后者描述了该字段的数据类型。
用于描述字段和方法参数/返回值的数据类型都简单用一个大写英文字母来表示,比如前文的 "V" 代表这个方法的返回值是 void:
| 标识字符 | 含义 |
|---|---|
| B | 基本数据类型 byte |
| C | 基本数据类型 char |
| D | 基本数据类型 double |
| F | 基本数据类型 float |
| I | 基本数据类型 int |
| J | 基本数据类型 long |
| S | 基本数据类型 short |
| Z | 基本数据类型 boolean |
| V | 特殊类型 Void |
L{全限定名} | 对象类型 |
这里要说清 描述符 ( descriptor ) 的概念:
对于普通字段而言,标识字符就是其描述符。对于方法而言,描述符指代 ([入参标识字符])<返回标识字符> 的紧凑排列。比如:
//([Ljava.lang.String)V
public static void main(String[] args){...}
//(ILjava.lang.Integer)I
public int add(int i1,Integer i2){...}
其中,对象类型的实际表示法为以 "L" 为前缀的全限定名,比如 Ljava.lang.String,Ljava.lang.OBject。而符号 "V" 被《 Java 虚拟机规范 》 列为了 "VoidDescriptor",显然它只会作为方法的返回值中出现。Java 中的各类数组是独立的对象类型,对于 n 维数组,它们的标识字符前面还会添加 n 个 [ 符号。比如:[Ljava.lang.String,[[Ljava.lang.String 。
字段表集合不包含从父类/父接口继承来的字段,但是可能包含源代码中不存在的字段,比如为了使内部类能够自由访问外部类,编译器会额外添加一个指向外部类实例的一个字段。Class 文件对字段重名现象更加包容,只要其描述符不完全一致即可,但 Java 语言本身不支持对字段进行重载。
2.7 方法表集合
方法表集合记录了 Class 文件内部的所有方法,每一个方法单独使用一个方法表来保存。
| 类型 | 名称 | 数量 |
|---|---|---|
| u2 | access_flags | 1 |
| u2 | name_index | 1 |
| u2 | descriptor_index | 1 |
| u2 | attributes_count | 1 |
| attribute_info | attributes | attributes_count |
方法同样包含了访问标志,名称索引,描述符索引以及额外包含的属性表,因此方法表和字段表从结构上完全一致,仅访问标志存在部分差异,因为有些修饰符是方法独有的,或者是字段独有的。
| 标志名称 | 标志值 | 含义 |
|---|---|---|
| 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_VARAGES | 0x0080 | 方法是否接受不定参数 |
| ACC_NATIVE | 0x0100 | 方法是否为本地方法 |
| ACC_ABSTRACT | 0x0400 | 方法是否为 abstract |
| ACC_STRICT | 0x0800 | 方法是否为 strictfp ( Strict Float Point ) |
| ACC_SYNTHETIC | 0x1000 | 是否由编译器产生的方法 |
如果子类没有显式重写 ( Override ) 父类的方法,则该方法不会出现在当前 Class 文件的方法表集合当中。但同样,部分方法会由编译器直接生成,比如说默认的无参构造函数 <init> 或者是类构造器 <clinit> 。
《 Java 语言规范 》中特征签名指:方法名,参数列表内的参数类型和参数顺序,但不包括返回值。正因如此 Java 无法根据返回值作为判断方法重载 ( Overload ) 的依据。而《 Java 虚拟机规范 》 中的特征签名则更宽泛一些,只要是描述符不完全相同,则可认为两者的特征签名不同。
2.8 属性表集合
Class 文件,字段表,方法表都可以携带 attributes_count 和 attributes 组成的属性表集合来描述各自专有的一个或多个额外信息,每一条额外信息都是单独的一个属性 ( Attribute ) ,每一个属性又是统一使用以下表结构来描述的:
| 类型 | 名称 | 数量 |
|---|---|---|
| u2 | attribute_name_index | 1 |
| u4 | attribute_length | 1 |
| u1 | info | attribute_length |
每个属性有互不重复的名称,通过引用一个 CONSTANT_Utf8_info 类型的常量池值来表述。任何属性只需要保证有一个 attribute_length 标识出此属性的 info 部分即可,内部的具体内容由属性自由定义。此外,属性内部可以再嵌套一个属性表集合,比如 Code 属性。
在 《 Java 虚拟机规范》 的 Java SE 12 版本中,预定义属性已经增加到了 29 项。属性表的内容多而杂,并且可以出现在任何需要额外信息描述的其它地方 ( 如字段表,方法表,乃至类文件中 )。笔者在这里仅介绍三个重要的,和描述方法相关的三个属性。
2.8.1 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 是一个指向字符串常量的值,它记录了 Code 属性的名字 "Code"。再除去 attribute_length 占用的 4 个字节,Code 属性的有效内容占据的长度为表长度 - 6 字节。
max_stack 代表当执行此方法时,操作数栈的最大深度。虚拟机在加载 Class 文件以及各种方法时,将根据这个值分配栈帧 ( Stack Frame ) 的操作栈深度。
max_locals 代表了局部变量表占用空间,单位为 槽 ( Slot ) 。它是虚拟机在运行时为局部变量分配内存的最小单位。对于 byte,char,float,int,short,boolean,reference 和 returnAddress 等不超过 4 字节的数据类型,使用 1 个变量槽。而对于 long 和 double 类型则使用 2 个变量槽存放。
除了方法内部的局部变量以外,方法参数 ( 任何实例方法都包含隐藏的参数 this ) ,显式异常处理的参数 ( try-catch 语句块中传入 catch 内的异常 ) 都需要保存到局部变量表中。由于节省操作栈深度和内存的考量, javac 编译器采取了变量槽复用策略,因此实际上 max_locals 的大小并不是简单的各种变量占用空间的累加和。
code_length 和 code 存储了由源代码转化得来的,以字节流表示的字节码指令。《Java 虚拟机规范》使用 1 字节规定了约 200 条基本的字节码指令,理论上最多能够规定 256 条指令。
使用 javap 工具可以直接以助记符形式解读字节码指令3。其中,code_length 是一个 u4 类型的值,虽然从理论上来说一个方法可以包含 2^32 条指令,但实际上《 Java 虚拟机规范 》 约定只允许使用 u2 的长度。
以前文的 TestJava 类解析为例,下面是主函数经 javap 命令解析得到的结果:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: iadd
7: istore_3
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: iload_3
12: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
15: return
LineNumberTable:
line 9: 0
line 10: 2
line 11: 4
line 12: 8
line 13: 15
}
字节码的 0 ~ 1 行,2 ~ 3 行分别将局部变量 a 和 b 压入操作数栈,而 4 ~ 6 行执行了加载,加和,并通过 istore_3 指令将计算的结果保存。而第 8 行 ~ 第 12 行,从注释可以分析出这是调用了 System.out::println 方法,并将结果输出到控制台。( 每条指令的具体含义可参考后文的加载和存储指令 )
主方法传入了一个 [Ljava/lang/String 类型的参数,因此 args_size=1 。值得注意的是:对于实例方法,args_size 的最小值是 1,因为编译器总是隐式地将当前对象的 this 引用传入进去,这样在实例方法内就可以自由地访问实例属性了。而对于不接收任何参数的类方法而言,args_size 为 0 。
LineNumberTable 记录了 java 源代码和字节码指令的行号对应关系,我们稍后再介绍。
如果源代码通过 try-catch 语句尝试捕捉异常,则编译后的字节码指令后面还会附带上异常表 Exception_table,它是 Code 内部信息的一部分。用下面的这段代码做演示:
public static void main(String[] args) {
try {
int a = 1;
int b = 2;
int c = a / b;
System.out.println(c);
} catch (Exception e) {
e.printStackTrace();
}
}
它的编译结果是:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: iconst_1
1: istore_1
2: iconst_2
3: istore_2
4: iload_1
5: iload_2
6: idiv
7: istore_3
8: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
11: iload_3
12: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
15: goto 23
18: astore_1
19: aload_1
20: invokevirtual #5 // Method java/lang/Exception.printStackTrace:()V
23: return
Exception table:
from to target type
0 15 18 Class java/lang/Exception
Exception table 表明了这样的信息:如果第 0 ~ 15 行抛出了 java/lang/Exception 异常,跳转到第 18 行指令继续执行 ( 调用 java.lang.Exception::printStackTrace ),否则通过 goto 指令直接跳转到 23 行并返回。
每一行 from,to,target,type 都对应了 Exception_table 的如下表结构:
| 类型 | 名称 | 数量 |
|---|---|---|
| u2 | start_pc | 1 |
| u2 | end_pc | 1 |
| u2 | handler_pc | 1 |
| u2 | catch_type | 1 |
2.8.2 Exceptions 属性
Exceptions 属性是和 Code 属性平级的独立属性,注意和上述的 Exception_table 区分。记录了一个方法通过 throws 关键字显式声明的可能抛出的异常,它和上述代码的 Exception_table 是不同的。下面是一个抛出 IOException 的方法:
public void io() throws IOException{}
它的编译结果是:
public void io() throws java.io.IOException;
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=0, locals=1, args_size=1
0: return
LineNumberTable:
line 23: 0
Exceptions:
throws java.io.IOException
throws 允许抛出多个异常,这些都会存在一个 Exceptions 属性表内。每一个异常是一项 exception_index_table ,它是一个指向 CONSTANT_Class_info 型常量的索引,表示该受检异常的类型。
| 类型 | 名称 | 数量 |
|---|---|---|
| u2 | attribute_name_index | 1 |
| u4 | attribute_length | 1 |
| u2 | number_of_exceptions | 1 |
| u2 | exception_index_table | number_of_exceptions |
2.8.3 LineNumberTable 属性
它是 Code 属性的 “子属性”。刚才笔者介绍了 LineNumberTable 属性的用途,它其实是一个非必须的属性,而编译器默认情况下总是会携带它。如果取消生成这段信息,最大的影响是当运行期间抛出异常时,堆栈无法提供出错的源码行号,IDE 也无法通过源码行来设置断点。
| 类型 | 名称 | 数量 |
|---|---|---|
| u2 | attribute_name_index | 1 |
| u2 | attribute_length | 1 |
| u2 | attribute_table_length | 1 |
| line_number_info | line_number_table | line_number_table_length |
每一条对应信息都是一个 line_number_info 类型的数据结构,其内部包含了 start_pc ( 记录字节码指令的行号,或称偏移量 ) 和 line_number ( 对应的源码行数 )。
2.9 小结
java 文件和 Class 文件的对应关系可以大致上如此表述:
即便是如此错综复杂的关系图,也仅仅描述了 Class 文件结构的一小部分。属性表还有其它的类型,并且作用范围和功能各不相同。篇幅原因,这里仅简单列举一部分。比如:
SourceFile 属性用于记录生成此 Class 文件的源码文件名称;
InnerClass 属性用于记录内部类和宿主类之间的关联;
Signature 属性允许用户在运行期间动态获取泛型的类型;
Synthetic 属性标记该方法,字段,或者类本身是否为编译器直接生成的;
BootstrapMethods 属性和 invokeDynamic 字节码指令相关;
StackMapTable 属性用于在虚拟机类加载的字节码验证阶段供新类型检查验证其使用;
其中,最后两个属性 BootstrapMethods 和 StackMapTable 涉及的功能比较复杂,笔者会在之后的文章中提及。
3. 字节码指令
字节码指令由一字节长度表示的 操作码 ( Opcode ) 和该指令所需的零个至多个 操作数 ( Operand ) 组成。由于操作码的长度受限在一字节内,因此这限定了虚拟机的字节码指令至多不超过 256 条。此外,由于 Java 是面向操作数栈而非寄存器的架构,所以大部分字节码仅包含一个 Opcode 。
Java 虚拟机解释器的工作可以用下面最基本的执行模型来解释:
do{
PC 值 +1
取出 PC 值指示位置的操作码
if(该操作码包含操作数) 取出操作数;
执行该字节码指令;
}while ( 字节码流剩余长度 > 0)
大部分字节码指令都指明了该操作数的数据类型,比如助记符 iload (0x15) 的 i 表示将一个局部变量表内的 int 型数据加载到操作数栈中。其余的还有:l 表示 long,s 表示 short,b 表示 byte,c 表示 char,f 表示 float,d 代表 double,a 代表一个 reference。
由于字节码指令的长度限制,对于部分同时需要 Opcode 和多个 Operands 表示的字节码指令有可能无法表示,因此指令集被特意设计成是非完全独立的 ( 指并非每一种数据类型和操作都有对应的操作指令 )。这样,有些指令会在必要的时候将不支持的类型转换成支持的数据类型。
实际上,大部分指令都不直接操作 byte,char 和 short,甚至没有一条指令支持 boolean 。编译器会将 byte 和 short 类型的数据通过符号扩展 ( Sign-Extend ) 4成对应的 int 型数据;将 char 和 boolean 类型的数据通过零位扩展 ( Zero-Extend ) 5转换成 int 型数据。
如此来看,字节码指令实际上可以看作是只操作五类数据类型:i,l,f,d,a 。下面按照功能介绍字节码指令。
3.1 加载和存储指令
该类指令负责数据从栈帧的局部变量表到操作数栈之间的来回传输。包括:
将局部变量加载到操作数栈的 ?load,?load_<n> 指令;
将常量加载到操作数栈的 bipush,sipush,ldc,ldc_w,ldc2_w,aconst_null,iconst_m1,iconst_<i>,lconst_<l>,fconst_<f>,dconst_<d>;
将数值从操作数栈存储到局部变量表的 ?store,?store_<n> 指令。
其中,? 代表了可能的操作类型 i,l,f,d,a 。_<n> 后缀代表了_0,_1,比如 iload_0 表示将局部变量表中的第一个 int 型数值送入操作数栈。这样的操作码已经隐含地表达出了操作数,因此就不再需要额外提供操作数了。
3.2 运算指令指令
运算指令主要包括了:四则运算,求余取反,位运算和部分其它运算。这些指令包括了:
加法指令 ?add,减法指令 ?sub,乘法指令 ?mul,除法指令 ?div,求余指令 ?rem,取反指令 ?neg,其中 ? 包含 i,;,f,d ;
位移指令 ?shr,?shl,?ushr,按位或指令 ?or,按位与指令 ?and,按位异或指令 ?xor ,其中 ? 仅包含 l 和 i ;
局部自增指令 iinc;
比较指令 dcmpg,dcmpl,fcmpg,fcmpl,lcmp。
数据运算有可能运算溢出,比如下面的代码:
int a = Integer.MAX_VALUE;
int b = Integer.MAX_VALUE;
// 运算结果是 -2
System.out.println(a + b);
这是因为有限的位数无法完整表示运算出的值,所以得到了数学意义上的错误结果。解决方案是用更长的位数存放数据:
int a = Integer.MAX_VALUE;
int b = Integer.MAX_VALUE;
long la = a;
long lb = b;
// 运算结果是 2^32 = 4294967294
System.out.println(la + lb);
《 Java 虚拟机规范 》 并没有规定数据溢出时应抛出异常。只有当执行 ?div 和 ?rem 时,若除数为 0,则抛出 ArithmeticException 异常,其余的整形数运算都不应该抛出异常。当虚拟机计算浮点数时,会严格遵守 IEEE 754 定义的非规格化浮点数 ( Denormalized Floating-Point Number ) 6和 "逐级下溢" 的运算规则进行运算,并将计算结果舍入到合适的精度。
3.3 类型转换指令
该类指令用于不同数值类型之间的相互转换,一般是因为用户在源代码中使用了显式转换,或者是为了解决部分指令不支持此数据结构的问题。虚拟机直接支持从小范围到大范围数据的转换,即:
int=>long,float,double;long=>float,double;float=>double;
但是反之,窄化类型转换 ( Narrowing Numeric Conversion ) 则需要借助指令来完成,这些指令助记符形式上是 ?2?,即 i2b,i2c,i2s,l2i,f2i,f2l,d2l,d2f。窄化的具体过程按照 IEEE 754 标准去执行,转换过程会不可避免地造成精度损失,甚至出现上限溢出,下限溢出的情况,但是虚拟机不会因此抛出运行时异常。
3.4 对象创建与访问指令
Java 中的各种类实例和数组都属于对象,但是虚拟机选择使用不同的字节码指令创建它们 ( 创建对象和创建数组的细节也是不同的 )。除此之外,还需要规定访问类字段,访问数组元素等指令。
创建类实例 new;
创建数组指令 newarray,anewarray,multianewarray;
访问类字段和实例字段的指令 getfield,putfield,getstatic,putstatic;
将某一个数组元素加载到操作数栈的指令 ?store,? 在此处可指代八种数据类型的任意一种;
取数组长度的指令 arraylength;
检查类类型的指令 instanceof,checkcast。
3.5 操作数栈指令
虚拟机提供了直接控制操作数栈的指令,包括:
弹出栈顶的一个 / 两个元素 pop1,pop2;
复制栈顶的一个 / 两个元素并复制一份 / 两份重新压入栈顶 dup,dup2,dup1_x1,dup1_x2,dup2_x1,dup2_x2;
交换栈顶端的两个数 swap。
3.6 控制转移指令
程序计数器 PC 在不受干扰的情况下总是顺序执行的,该部分的指令可以使 PC 有条件或无条件地直接跳转到指令流的某个位置执行,包括:
条件分支 ( 比较 ) 指令 if?,ificmp?,ifacmp?;
注:cmp 是 compare 的缩写。此外,这里的 ? 指代 eq ( equal ),ne ( not equal ),lt( litter than ( 小于 )),le ( litter or equal ( 小于等于 )),gt ( greater than ( 大于)),ge ( greater or equal ( 大于等于 )),可参考 Shell 脚本的比较命令。
条件分支 ( 判空 ) 指令 ifnull,ifnonnull;
复合条件分支指令 tableswitch,lookupswitch;
无条件分支 goto,goto_w,jsr,jsr_w,ret。
3.7 方法调用和返回指令
这里仅介绍方法调用和返回相关的指令,这些指令的助记符均以 invoke 为前缀。包括:
invokevirtual 指令:用于动态调用对象的实例方法;
invokeinterface 指令:用于调用接口方法,虚拟机会在执行该指令时搜索一个实现了该接口的对象,然后再调用对应的方法;
invokespecial 指令:调用一些需要特殊处理的方法,包括实例初始化方法,私有方法和父类方法;
invokestatic 指令,调用类静态方法;
invokedynamic 指令,简称 inDy ,该指令原本用在其它运行在 JVM 的动态语言上,直到 JDK 8 版本时才正式被引入到 Java 中,它和 Java 的 λ 表达式有关联。该指令大体上实现了以方法 ( 该方法被称之为 Bootstrap Method ) 动态调用方法,下面给出一个包含 λ 表达式的 Java 源代码:
import java.util.function.Function;
public class JavaTest{
public static int iHof(int a,Function<Integer,Integer> op) {
return op.apply(a);
}
public static void main(String[] args) {
System.out.println(iHof(1,(a) -> 2 * a));
}
}
main 方法的 Code 区如下,在调用 iHof 方法之前,编译器插入了一条 invokedynamic 指令用于 "动态生成" (a) -> 2 * a 的表达式,同时,类文件还出现了 BootstrapMethods 属性 ( 本文未提及 )。
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=1, args_size=1
0: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iconst_1
4: invokedynamic #7, 0 // InvokeDynamic #0:apply:()Ljava/util/function/Function;
9: invokestatic #8 // Method iHof:(ILjava/util/function/Function;)I
12: invokevirtual #9 // Method java/io/PrintStream.println:(I)V
15: return
LineNumberTable:
line 11: 0
line 12: 15
...
BootstrapMethods:
0: #35 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#36 (Ljava/lang/Object;)Ljava/lang/Object;
#37 invokestatic JavaTest.lambda$main$0:(Ljava/lang/Integer;)Ljava/lang/Integer;
#38 (Ljava/lang/Integer;)Ljava/lang/Integer;
有关于该命令的详细内容笔者在后续的学习中介绍 ( 见 《 深入理解 Java 虚拟机 》 第 8 章 )。
方法调用指令和数据类型无关,但是方法返回指令则取决于该方法的返回值。指令分为 ?return 和 return ,前者的 ? 可指代 i,l,f,d,a。( char,byte,short,boolean 会被转换为 int 型数据处理 ) 后者则用于处理方法返回值为 void 时的情况。
3.8 异常处理指令
Java 代码中的抛出异常全部由字节码指令的 athrow 指令来实现,比如:
public void iflt0(int i){
if(i < 0) throw new ArithmeticException("need a num greater or equal 0");
}
它对应的编译结果如下,第 1 行的 ifge 指令表示若条件正确,则直接执行第 14 行的 return 指令,否则顺序执行,直到第 13 行的 athrow 指令抛出异常。
public void iflt0(int);
descriptor: (I)V
flags: ACC_PUBLIC
Code:
stack=3, locals=2, args_size=2
0: iload_1
1: ifge 14
4: new #6 // class java/lang/ArithmeticException
7: dup
8: ldc #7 // String need a num greater than 0
10: invokespecial #8 // Method java/lang/ArithmeticException."<init>":(Ljava/lang/String;)V
13: athrow
14: return
LineNumberTable:
line 10: 0
line 11: 14
try-catch 形式的处理异常由 Code 属性内的 Exception_table 表来做记录,许久之前虚拟机是通过 jsr 和 ret 指令来实现的,但是现在已经不再使用了。
3.9 同步指令
同步可以分为方法间的同步和方法内某段代码块的同步。
方法之间的同步,可以在源码层次中直接通过 synchronized 关键字来实现,虚拟机将通过访问标志中的 ACC_SYNCHRONIZED 标志进行检查,不需要额外的字节码指令来维护。对于这样的方法,线程在执行它时总要获取一个管程 ( 相当于该方法的 "锁" ),直到执行完毕之后才释放此管程。如果同步方法内部出现了无法处理的异常,则管程会随着异常被抛出到方法边界之外时被自动释放。
如果是对某段代码块 ( 对于编译器来说则是某段字节码指令区间 ) 的同步,编译器会通过插入 monitorenter ( 相当于 "进入临界区" ) 和 monitorexit ( 相当于 "退出临界区" ) 两个指令来实现。下面是一段简单的同步代码:
public static void main(String[] args){
Integer a = 0;
synchronized (a){
a++;
System.out.println(a);
}
}
main 方法编译出的 Code 属性如下:
Code:
stack=2, locals=4, args_size=1
0: new #9 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1
8: aload_1
9: dup
10: astore_2
11: monitorenter
12: getstatic #10 // Field java/lang/System.out:Ljava/io/PrintStream;
15: aload_1
16: invokevirtual #11 // Method java/lang/Object.toString:()Ljava/lang/String;
19: invokevirtual #12 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
22: aload_2
23: monitorexit
24: goto 32
27: astore_3
28: aload_2
29: monitorexit
30: aload_3
31: athrow
32: return
Exception table:
from to target type
12 24 27 any
27 30 27 any
第 11 行和第 23 行分别出现了 monitorenter 和 monitorexit 指令,中间包裹的指令就是需要被同步执行的部分。在正常执行这段同步指令的情况下在执行完第 24 行指令之后就可以直接 return,否则,根据 Exception_table 的指示跳转到 27 行字节码开始继续执行,等待退出同步区域之后抛出异常并返回。
为了保证被同步的字节码指令序列在出现异常时仍然能够通过 monitorexit 安全退出临界区,编译器总是会自动为其生成 try-catch 结构的字节码指令,并且声明 athrow 指令可抛出 any 类型的异常。
4. 参考链接
《 深入了解虚拟机 JVM 》 第 6 章中涉及的各种完整表格可以参考:Java Class文件格式、常量池项目的类型、表的结构_转眼-CSDN博客
Footnotes
-
笔者曾以计算机语言的发展角度谈论了 Java 的跨平台特性:杂谈:Java 为何可以跨平台? (juejin.cn) ↩
-
和普通 UTF-8 编码的区别是:从
\u0001到\u0071之间的字符使用 1 个字节存储,\u0080到\u07ff之间的字符使用 2 个字节存储,剩下的字符再使用普通的 UTF-8 编码规则存储。 ↩ -
完整的编码以及对应的助记符指令可参考:CSDN:字节码指令集 ↩
-
符号扩展,即高位填充的数字 0 或 1 和原符号位数字保持一致。 ↩
-
零位扩展,即高位全部填充 0。 ↩
-
非规格浮点数的详细内容参考 IEEE754 浮点表示法详解 ↩