JVM(6):字节码执行引擎
1. 运行时栈帧
JVM
以方法作为最基本的执行单元,栈帧 ( $Stack\ \ Frame$ ) 是用于支持虚拟机进行方法调用和方法执行背后的数据结构,也是虚拟机运行时数据区中的虚拟机栈 ( $Virtual\ \ Machine\ \ Stack$ ) 的栈元素,存储了方法的局部变量表、操作数栈、动态连接和方法返回地址和一些额外的附加信息,方法表的 $Code$ 属性包含了栈帧所需的局部变量表和操作数栈的大小。对于执行引擎来讲,在每个线程中,只有处于调用堆栈栈顶的方法才是正在运行的方法,称为当前栈帧 ( $Current\ \ Stack\ \ Frame$ ),与这个栈帧关联的方法称为当前方法 ( $Current\ \ Method$ )。
1.1 局部变量表
局部变量表 ( $Local\ \ Variable\ \ Table$ ) 是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量,其最大容量由方法 $Code$ 属性中的 $max_-locals$ 数据项决定。局部变量表以变量槽 ( $Variable\ \ Slot$ ) 为最小单位,每个变量槽都应该能存放一个 $boolean$ 、$byte$ 、$char$ 、$short$ 、$int$ 、$float$ 、$reference$ 或 $returnAddress$ 类型的数据。这些数据长度一般都为 $32$ 位 ( $reference$ 可能为 $64$ 位 ),也意味着变量槽的长度至少为 $32$ 位。而对于 $64$ 位的数据类型,比如 $long$ 和 $double$ ,会通过高位对齐的方式分配两个连续的变量槽。
JVM
通过索引定位的方式使用局部变量表。对于 $32$ 位数据类型的变量,使用一个索引值;对于 $64$ 位数据类型的变量,使用相邻的两个索引值。当方法被调用时,JVM
使用局部变量表将参数值传递到参数变量列表,即从实参到形参。如果方法为实例方法,那么局部变量表第 $0$ 位索引的变量槽默认为传递方法所属对象实例的引用。在参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。因为方法中定义的变量的作用域不一定会覆盖整个方法体,因此变量槽是可以重用的。
局部变量槽会影响系统的垃圾收集行为,可以观察以下三种情况:
public static void main(String[] args) {
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}
在前两种情况中进行垃圾收集并不能回收 $placeholder$ 中的空间,但第三种情况可以。根本原因是局部变量表的变量槽中是否还存在 $placeholder$ 数组对象的引用。局部变量表是GC Roots
的一部分,$placeholder$ 是其所关联的对象,因此无法被回收。如果想要回收这种变量,可以通过手动设置为 $null$ 的方法,但是在即时编译中,这种赋值往往会被优化掉。
1.2 操作数栈
操作数栈 ( $Operand\ \ Stack$ ),也被称为操作栈。同局部变量表一样,其长度也在编译时被写入 $Code$ 属性的 $max_-stacks$ 数据项之中。操作数栈的每一个元素都可以是任意数据类型,包括 $long$ 和 $double$ 。$32$ 位数据类型所占的栈容量为 $1$ ,$64$ 位数据类型所占的栈容量为 $2$ 。
在方法执行的开始,它的操作数栈为空。随着方法的执行,各种字节码指令会被压入操作数栈或者从操作数栈中弹出。操作数栈中的元素的数据类型必须与字节码指令的序列严格匹配。在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,应该是完全独立的。但是大多数虚拟机都会进行优化,将上部分栈帧的部分局部变量表与下部分栈帧的部分操作数栈重叠,节省空间的同时还可以实现共用部分数据。
1.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接 ( $Dynamic\ \ Linking$ )。Class
文件常量池中存有大量符号引用,字节码的方法调用指令以其中指向方法的符号引用作为参数。这些符号引用的一部分会在类加载阶段或者第一次被使用时转为直接引用,这种转化称为静态解析。另外未加载的一部分则在运行时期转化为直接引用,这部分就称为动态连接。
1.4 方法返回地址
方法在执行后,一种退出方式是遇到方法返回的字节码指令,这种退出方式称为正常调用完成 ( $Normal\ \ Method\ \ Invocation\ \ Completion$ )。另一种退出方式是执行过程中遇到未处理的异常,这种退出方式称为异常调用完成 ( $Abrupt\ \ Method\ \ Invocation\ \ Completion$ )。无论采用何种方式,方法都需要在栈帧中存储一些信息,用于恢复到上层主调方式的执行状态。一般情况下,以第一种方式退出的方法可以直接将上层方法的PC
计数器的值作为返回地址,栈帧中也可能保存这个计数值。而以第二种方式退出的方法就要通过异常处理表确定,栈帧中无法保存。
2. 方法调用
方法调用阶段的任务是确定哪个方法被调用。
2.1 解析
所有方法的调用目标都是Class
文件中一个常量池的符号引用。在类加载的解析阶段,其中一部分符号会转化为直接引用,前提是方法只有一个确定的可调用版本,并且在运行期不改变。这类方法调用称为解析 ( $Resolution$ )。
JVM
支持以下 $5$ 条方法调用字节码指令,分别是:
- $invokestatic$ :调用静态方法;
- $invokespecial$ :调用实例构造器的 <$init$>$(\ )$ 方法、私有方法和父类中的方法;
- $invokevirtual$ :调用所有虚方法;
- $invokeinterface$ :调用接口方法,运行时会确定一个接口实例;
- $invokedynamic$ :由用户设计的引导方法决定,在运行时动态解析出调用点限定符所引用的方法。
能被 $invokestatic$ 和 $invokespecial$ 调用的方法就可以在解析阶段确定版本,包括静态方法、私有方法、实例构造器、父类方法以及被 $final$ 修饰的方法 ( 使用 $invokevirtual$ 指令调用 ) 。这些方法统称为非虚方法 ( $Non-Virtual\ \ Method$ ),与之对应的方法称为虚方法 ( $Virtual\ \ Method$ )。
2.2 分派
2.2.1 静态分派
静态类型 ( $Static\ \ Type$ ) ,也叫外观类型 ( $Apparent\ \ Type$ ) ,是声明的类型。对象的实际类型 ( $Actual\ \ Type$ ) 也就是运行时类型 ( $Runtime\ \ Type$ )。静态类型的变化仅在使用时才发生,并且在编译期可知;而实际类型只有在运行时才可以确定。由于编译阶段只能获取静态类型,因此Javac
编译器会根据参数的静态类型决定调用哪个版本的方法,而所有依赖于静态类型的分派都称为静态分派。静态分派最典型的表现方式就是重载。如果存在多个优先级相同的选择,那么会编译失败,并提示类型模糊 ( $Type\ \ Ambiguous$ ),这时候就需要使用类型转换显示指定。
2.2.2 动态分派
静态分派与重载相关,而动态分派与重写相关。具体表现为调用类中的重写方法时,会调用实际类型的方法而非静态类型的方法。实现这种模式的关键是 $invokespecial$ 指令,其解析过程可以分为以下几步:
- 找到操作数栈栈顶的第一个元素指向的对象的实际类型,记为 $C$ ;
- 如果在 $C$ 中找到了与常量中描述符和简单名称都相符的方法,则检验访问权限。如果可以访问则返回该方法,否则抛出 $java.lang.IllegalAccessError$ 异常;
- 按照继承关系从下往上寻找;
- 如果没有找到符合的方法,抛出 $java.lang.AbstractMethodError$ 异常。
正因为 $invokevirtual$ 在第一步就是确定运行期中接收者的实际类型,因此会根据接收者的实际类型选择方法版本,这也是Java
重写的本质,通过这种方式确定方法版本的分派称为动态分派。需要注意的是,动态分派只对方法起效,对字段不奏效,因为并不存在虚字段的概念。如果子类声明了与父类相同的字段,虽然在内存中两个字段都会存在,但是子类字段会掩盖父类字段。
2.2.3 单分派与多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为根据一个宗量进行选择的单分派和根据多个宗量进行选择的多分派两种。对于静态分派,选择方法的依据有两个:静态类型和方法参数,因此静态分派属于多分派;对于动态分派,由于在编译期就已经决定了方法签名,唯一影响的因素只有接收者的实际类型,因此动态分派属于单分派。因此Java
是一门静态多分派、动态单分派的语言。
2.2.4 动态分派的实现
动态分派要求运行时在接收者类型的方法元数据中搜索合适的目标方法。为了性能考虑,JVM
建立了一个虚方法表 ( $Virtual\ \ Method\ \ Table$ ,$vtable$ ),用来代替元数据查找。虚方法表中存放着各个方法的实际入口地址。如果方法未被重写,那么子类和父类的入口地址相同;如果方法被重写,那么子类虚方法表的入口地址会被替换为子类实现版本的入口地址。为了程序实现的方便,具有相同签名的方法在子类和父类中的索引序号都相同,这样在类型改变时只需要从虚方法表中按索引转换出入口地址即可。虚方法表一般在类加载的连接阶段初始化,在子类的变量初始值准备时一同初始化。除了虚方法表之外,JVM
还会使用类型继承关系分析 ( $Class\ \ Hierarcht\ \ Analysis$ ,$CHA$ )、守护内联 ( $Guarded\ \ Inlining$ )、内联缓存 ( $Inline\ \ Cache$ ) 等多种非稳定的激进优化来争取性能空间。
3. 动态类型语言支持
3.1 java.lang.invoke
包
$java.lang.invoke$ 包的目的是为了提供除了依靠符号引用确定调用的目标方法之外的动态确定目标方法的机制,称为方法句柄 ( $Method\ \ Handle$ ),类似于C
/C++
中的函数指针。
public class MethodHandleTest {
private static MethodHandle getPrintlnMH(Object receiver) throws Throwable {
// MethodType代表方法类型,包含了方法的返回值(第一个参数)和具体参数
MethodType mt = MethodType.methodType(void.class, String.class);
// lookup()用于查找符合给定的方法名称、类型和调用权限的句柄
// 此处查找虚方法并调用,根据Java的规则,方法的第一个参数隐式为this,以前是通过参数列表进行传递,如今可以使用bingTo()方法
return MethodHandles.lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
}
public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
getPrintlnMH(obj).invokeExact("icyfenix");
}
static class ClassA {
public void println(String s) {
System.out.println(s);
}
}
}
$MethodHandle$ 可以视为方法的一个“引用”。与反射相比,$Reflection$ 是在模拟Java
代码层次的调用,而 $MethodHandle$ 是在模拟字节码层次的调用。
3.2 invokedynamic
指令
$invokedynamic$ 是JDK 7
中新增的一条字节码指令,用于实现对动态类型语言的支持。动态类型语言的关键特征是类型检查的主体过程是在运行期而不是编译期进行的,因此Java
是一门静态类型语言。但是JVM
中不只可以运行Java
,还有其他语言比如Groovy
和Clojure
等,它们都是动态类型语言。$invokedynamic$ 的引入是为了更好的支持动态类型语言。从某种意义上,$invokedynamic$ 的作用与 $MethodHandle$ 是一样的,即将查找目标方法的决定权转嫁给用户的具体代码之中。
每一处含有 $invokedynamic$ 指令的位置都被称为动态调用点 ( $Dynamically-Computed\ \ Call\ \ Site$ ),该指令的第一个参数不再是 $CONSTANT_-Methodref_-info$ ,而是 $CONSTANT_-InvokeDynamic_-info$ 。该常量包含三项信息:引导方法 ( $Bootstrap\ \ Method$ )、方法类型 ( $MethodType$ ) 和名称。引导方法具有固定参数,并且返回值必定是 $java.lang.invoke.CallSite$ 对象,表示真正要执行的目标方法调用。
public class InvokeDynamicTest {
public static void main(String[] args) throws Throwable {
INDY_BootstrapMethod().invokeExact("icyfenix"); //
}
public static void testMethod(String s) {
System.out.println("hello String:" + s);
}
public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt)
throws NoSuchMethodException, IllegalAccessException {
return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class, name, mt));
}
private static MethodType MT_BootstrapMethod() {
return MethodType.fromMethodDescriptorString(
"(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;",
null);
}
private static MethodHandle MH_BootstrapMethod()
throws NoSuchMethodException, IllegalAccessException {
return MethodHandles.lookup()
.findStatic(InvokeDynamicTest.class, "BootstrapMethod", MT_BootstrapMethod());
}
private static MethodHandle INDY_BootstrapMethod() throws Throwable {
CallSite cs =
(CallSite)
MH_BootstrapMethod()
.invokeWithArguments(
MethodHandles.lookup(),
"testMethod",
MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));
return cs.dynamicInvoker();
}
}
4. 基于栈的字节码解释引擎
许多JVM
的执行引擎在执行Java
代码的时候都有解释执行和编译执行两种选择。在最初的版本,虚拟机大多通过解释执行,但在如今主流的虚拟机中包含了即时编译器后,如何执行Class
文件中的代码就变成了因虚拟机决定了。
4.1 基于栈的指令集与基于寄存器的指令集
Javac
编译器输出的字节码指令流基本上是一种基于栈的指令集架构 ( $Instruction\ \ Set\ \ Architecture$ ,$ISA$ ),里面的指令大都是零地址指令,依赖操作数栈进行工作。与之对应的是基于寄存器的指令集,也就是如今主流PC
中物理硬件直接支持的指令集架构,依赖于寄存器进行工作。基于栈的指令集的优点是可移植,因为寄存器由硬件提供,容易受到硬件约束。而且基于栈的指令集具有更加紧凑的代码,实现更加简单。但是相应的,缺点就是速度会慢一些,这也是如今主流物理机都是寄存器架构的原因。
4.2 基于栈的解释器执行过程
以如下代码为例:
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
使用javap
进行反编译后的结果如下:
public int calc();
descriptor: ()I
flags: (0x0001) ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: bipush 100
2: istore_1
3: sipush 200
6: istore_2
7: sipush 300
10: istore_3
11: iload_1
12: iload_2
13: iadd
14: iload_3
15: imul
16: ireturn
LineNumberTable:
line 5: 0
line 6: 3
line 7: 7
line 8: 11
LocalVariableTable:
Start Length Slot Name Signature
0 17 0 this Lcom/jvm/execution/Calc;
3 14 1 a I
7 10 2 b I
11 6 3 c I
上述代码表明需要深度为 $2$ 的栈,$4$ 个变量槽,参数数量为 $1$ ( $this$ )。程序计数器从 $0$ 开始,按照偏移量依次执行指令。对上述字节码指令的解释如下:
- $bipush\ \ 100$ : 将 $100$ 压入栈内;
- $istore_-1$ :取出栈顶整型值 ( $100$ ) 并存放到第一个变量槽中;
- $sipush\ \ 200$ :将 $200$ 压入栈内;
- $istore_-2$ :取出栈顶整型值并存放到第二个变量槽中;
- $sipush\ \ 300$ :将 $300$ 压入栈内;
- $istore_-3$ :取出栈顶整型值并存放到第三个变量槽中;
- $iload_-1$ :将第一个变量槽中的整型值复制到操作数栈顶;
- $iload_-2$ :将第二个变量槽中的整型值复制到操作数栈顶;
- $iadd$ :取出操作数栈顶的两个元素,将他们相加的结果压入栈内;
- $iload_-3$ :将第三个变量槽中的整型值复制到操作数栈顶;
- $imul$ :取出操作数栈顶的两个元素,将他们相乘的结果压入栈内;
- $ireturn$ :结束方法并将操作数栈顶的整型值返回给该方法的调用者。
当然上述的执行过程只是一种概念模型,JVM
可能会对执行过程进行优化,从而提高性能。实际执行的情况会与上述的情况差距很大,根本原因是虚拟机中解析器和即时编译器都会对字节码进行优化。例如HotSpot
虚拟机中存在很多以 $fast_-$ 开头的非标准字节码,用于合并、替换输入的字节码,从而提高性能。