JVM 字节码简介
这一节将会介绍有关 JVM 字节码的基础知识。 更多有关 JVM 运行机制和指令集的介绍,请移步官方文档。 请注意:本次实验生成的字节码以 Java 8 版本 (major version 52) 为准。
Class 文件的组织
JVM 字节码以类为单位进行组织,每个类对应于一个 class 文件。例如考虑这个简单的 Decaf 类:
为其生成的 Main.class
文件的内容(使用 javap -v Main
指令反汇编)是:
可以看出,一个 class 文件由以下三部分构成:
元信息:包括类名、版本号 (
minor version
,major version
)、访问权限 (ACC_PUBLIC
)、父类 (super_class
)、接口信息 (interfaces
) 等。常量池:包括该文件中所有会用到的常量,如
#17
表示hello world
常量字符串;以及 JVM 对各类成员及其类型的内部引用,如#24
是对java.io.PrintStream.print
方法的引用。类成员:包括该类的所有成员(继承自父类且没有覆盖的除外),每个成员都会记录其访问权限(以
ACC_
开头的那些)和类型(JVM 中叫做 descriptor)。对于方法,其代码段 (code) 会记录下 JVM 指令序列,以及元信息,包括局部变量个数 (
locals
)、参数列表大小 (args_size
) 等。
数据的存储
除了把常量存放在常量池以外,JVM 还支持以下两种方式存储数据:
操作数栈
JVM 是一种栈式虚拟机。在这种计算模型中,几乎所有指令都依赖于从栈顶弹出一个或多个数据作为操作数,指令运行完成后,结果再压入栈顶。 例如,为了实现整数加法运算,我们先将两个操作数压栈,再执行 IADD
指令,结果会存入栈顶。 使用栈的一个好处在于,指令能省下很多地址(操作数),因为我们约定了一个指令的输入输出都在栈的哪些位置,无需再在指令中给出。 在上面这个例子中,IADD
就是一条零地址的指令。而在 TAC 中,ADD
是一条三地址指令。 由于指令更加简单,这也大大简化代码生成的工作。 与 TAC 相比,在本阶段,我们无需用类似于 Temp
的变量缓存下指令的输出结果。
临时变量
虽然操作数栈很好用,但它毕竟是个栈,而栈并不能很好地支持按照偏移量来访问其中的元素。 为了弥补这个缺陷,JVM 在每个方法的调用帧内存储一个临时变量数组,并提供相应指令完成按下标进行读写。 临时变量的作用有:(1) 函数调用时传参;(2) 记录程序局部变量的值。 简而言之,临时变量数组相当于 TAC 中的临时变量 (Temp) 构成的数组。
类型
与 TAC 不同,JVM 区分不同位数的整数、数组以及对象引用,并为它们分别设计了相匹配的指令。 Decaf 中的各类型与 JVM 类型的对应关系如下表:
Decaf | JVM |
|
|
|
|
|
|
数组 | 数组类型 |
对象 | 引用类型 |
需要特别强调的是:虽然 JVM 支持布尔类型,但是没有针对布尔类型提供相应的指令。 因此,在默认情况下我们总是用一个 JVM 的 32位整数表示该布尔值——0
表示 false
,1
表示 true
。 一个特别的情况是,布尔类型的数组 (Decaf bool[]
) 直接用 JVM 的布尔数组类型表示(其底层实现是每个元素占一个字节)。
在 JVM 中,类型都用一个字符串来表示,被称为descriptor。其中,域(field,即成员变量)的 descriptor 的语法表示为:
其中,我们会涉及到的基本类型 (BaseType
) 有:'B'
表示字节,'I'
表示整数,'Z'
表示布尔。 对象类型 (ObjectType
) 以 'L'
开头,后面跟上类名,并以一个分号结束。 如 Ljava/lang/String;
就表示 Java 的字符串类型 java.lang.String
,注意在这里 .
被替换成了 /
。 数组类型 (ArrayType
) 以 '['
开头,后面跟上数组元素的类型。如 [Ljava/lang/String;
表示字符串数组类型。
方法的 descriptor 的语法表示为:
一个方法的 descriptor 由两部分组成:(1) 各参数的类型,它们直接拼接起来,并用一对圆括号括起来; (2) 返回值的类型,注意无返回值时用 'V'
(i.e. void) 表示。 例如,主函数的 descriptor 为 ([Ljava/lang/String;)V
,即 Java 里面的 void(String[])
。 (IZLjava/lang/Thread;)Ljava/lang/Object;
表示 Java 里面的 Object(int, boolean, Thread)
。
指令集概述
以下简要介绍我们在 Decaf 中用到到一些 JVM 指令。 更加详细的请查阅 文档。 为方便,本节列出的所有指令都链接了它们的文档,请点击指令名称查看。
Load/Store
这里的 load/store 跟 TAC 是完全不同的!JVM 的 load/store 是指在临时变量与操作数栈之间进行数据交换。
把常量压入操作数栈:
LDC
上述指令都需要输入一个参数——目标临时变量的编号,即它在那个临时变量数组里的下标。 LOAD 指令执行完成后,栈顶元素为读取的数据。STORE 指令要求在执行前,栈顶元素为待写入的数据。 作为 JVM 的惯例,以 I
开头的指令操作整数,以 A
开头的指令操作引用。
注意:JVM 要求在读取一个临时变量前该变量已经有值(即被写入过)。因此,我们在程序中声明一个变量时,就立即将其写为初始值。 没有指定初始值的,我们可以赋默认值 0。
Arithmetic & Logical
以下是整数相关的算术、逻辑运算指令:
指令执行前,要求右操作数位于次栈顶,且左操作数位于栈顶。 指令执行完毕,栈顶元素为计算结果。
Control Transfer
JVM 支持类似于 TAC 和汇编中的条件/无条件跳转指令,以实现高级语言中的如分支、循环等包含控制流转让的语句。
无条件跳转:
GOTO
条件跳转:
IF_ICMP<cond>
/IF_ACMP<cond2>
/IFNULL
参数为要跳转到的目标标签。
其中,IF_ICMP
系列适用于比较两个整数,条件 <cond>
取 EQ
, NE
, LE
, LT
, GE
, GT
之一(含义显然)。 指令执行前,要求待比较的两个数分别位于次栈顶和栈顶。当二者满足 <cond>
条件时发生跳转,否则继续往后顺序执行。 而 IF_ACMP
系列适用于比较两个引用,条件 <cond2>
只能取 EQ
和 NE
,即引用无法比较大小。
Array
JVM 内置数组类型。 采用 NEWARRAY
/ ANEWARRAY
指令创建新数组。 其中,NEWARRAY
用于创建基本类型的数组,其参数为数组元素的类型;而 ANEWARRAY
用于创建引用的数组,无参数。 指令执行前,要求栈顶元素为数组长度。执行完毕后,栈顶元素为数组的引用。
用 ARRAYLENGTH
指令来获取数组长度。 指令执行前,要求栈顶元素为数组引用。执行完毕后,栈顶元素为长度。
类似于 ILOAD
/ ISTORE
,数组也支持 LOAD / STORE 操作(第1个字母标识类型,第2个字母 A
表示 array):
Object
JVM 不支持直接操作内存的指令。我们在创建对象(实例)的时候需要使用指令 NEW
,其参数为该对象的类型。新建对象的引用将被压入操作数栈。
对于类中的成员域(成员变量),我们采用 GETFIELD
和 PUTFIELD
指令分别进行读和写。 参数为待读写的域名称,操作数栈的栈顶元素为读取结果或待写入值。
此外,INSTANCEOF
指令支持 Decaf 和 Java 的 instance-of 检查。 该指令的参数为一个类型 A
,且指令执行前待检查对象的引用需要位于栈顶。 如果该对象是 A
的子类型,那么检查结果为真,1
被压入栈顶;否则 0
被压入栈顶。 类似地,使用 CHECKCAST
指令可以完成向上转型。 若转型失败,运行时会抛出 ClassCastException
。
Method Invocation
目前的框架中使用到了以下三种 JVM 支持的函数调用方式:
INVOKESTATIC
:调用类里面声明的静态方法INVOKESPECIAL
:调用构造方法INVOKEVIRTUAL
:调用成员方法
这些指令的参数为待调用函数的名称,且参数已经依次按压入操作数栈。
此外,JVM 还支持 INVOKEINTERFACE
和 INVOKEDYNAMIC
。
函数返回采用 RETURN
指令(无返回值时); 或者 IRETURN
等指令(有返回值时),这时要求操作数栈栈顶元素为待返回的值。
注意:JVM 要求任何函数必须要在最后一条指令结束时返回。 但是,我们知道一个无返回值的函数体内可能没有返回语句。一个简单的解决方法是,若最后一条指令不是返回指令时,则强行插入一条
RETURN
指令。
指令使用案例
这里 给出了诸多 Java 代码翻译成 JVM 指令的案例。
调用惯例
JVM 在调用一个需要 k
个参数的函数时,先从栈顶弹出 k
个元素,依次作为第 k, k - 1, ... 1
个参数。 调用发生后,程序执行的控制权由调用者 (caller) 转移到被调用者 (callee)。 在被调用者的临时变量数组中,前 k
个元素被依次初始化为第 1, 2, ..., k
个参数。 此后,程序从调用者代码的第一条开始执行。 执行到返回指令时,控制权由被调用者回到调用者,且此时操作数栈栈顶记录了返回值(如果函数返回值不是 void
)。
特别地,对于成员方法,我们需要在本来的参数列表前面加入一个 self 参数传入 this 对象。 因此,0 号临时变量(若存在)的值总是 this 对象的引用。
类型擦除
由于 JVM 不支持泛型,因此高级语言中的泛型在翻译为 JVM 字节码时,需要进行类型擦除 (type erasure)。 例如 Java 和 Scala 的编译器都需要在生成 JVM 字节码前进行类型擦除——把泛型参数全部擦掉,并用语言中的“超基类”(Java 的 java.lang.Object
,Scala 的 scala.Any
)替代。 由此,JVM 将无法识别如下所示的重载方法:
Scala 编译器报错:
即 List[T]
直接被擦除为 List
,由此在 JVM 中,两个方法的签名都是 (x: List)Unit
,出现冲突。
目前的 Decaf 暂时不支持泛型,框架中无需进行类型擦除的相关处理。
Last updated