执行引擎是Java虚拟机最核心的组成部分之一。相对于物理机来说,它不依赖于处理器、指令集、硬件和操作系统。它的功能为输入字节码文件,处理过程就是字节码解析的等效过程,输出的即为执行结果。

运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时虚拟机栈中的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接以及方法返回地址等信息。每一个方法从调用开始至执行完成的过程,对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

stack-frame

局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。其最大容量在编译时就已经放入class文件中。

局部变量表的容量以变量槽(Variable Slot)为最小单位,并通过索引的方式进行定位,索引的范围从0开始到局部变量表的最大Slot数量为止。其中,局部变量表中第0位索引默认用于传递方法所属的对象实例引用,方法中可以直接通过this关键字访问到这个隐含参数。

在方法体的内部,变量就代表着一个Slot,当字节码PC计数器的值超过某个变量的作用域后,该变量所使用的Slot可以被其他变量使用。对于某些情况下的gc,这一特性非常重要。

//局部变量表Slot复用与垃圾收集
public static void main(String[] args) {
    byte[] placeholder = new byte[64 * 1024 * 1024];
    System.gc();
}

运行上面的代码,当程序运行到System.gc()时,placeholder的生命周期已经完成,此时应该被GC掉,但System.gc()之前没有任何对局部变量表的读写操作,placeholder所占用的Slot未被其他变量所抢去,其值仍然指向byte[]数组,所以该byte[]无法被及时gc。添加一个placeholder = null,将该Slot的值强行设为null,栈帧与byte[]的可达性路径被切断,此时的byte[]就可以被正确的回收掉了。

//局部变量表Slot复用与垃圾收集(优化版)
public static void main(String[] args) {
    byte[] placeholder = new byte[64 * 1024 * 1024];
    placeholder = null; //help gc
    System.gc();
}

当使用完一个大对象之后,后面的代码含有一些耗时很长的操作时,我们可以尝试使用XXX = null的方式帮助gc。但是如果没有遇到性能问题,请不要随意使用,它会让代码看起来很丑陋,而且不友好。

局部变量表与类变量不同,它不存在”准备阶段”,也就是说,它不会被赋初始值。所以,在我们在方法中使用一个未初始化的局部变量时,无论编译器或JVM都将会抛出错误,无法执行下去。

//局部变量未赋值将直接抛出错误,程序无法继续执行
public static void main(String[] args) {
    int a;
    System.out.println(a);
}

操作数栈

操作数栈的处理方法与数据结构中利用栈执行算数/逻辑运算类似

动态链接

见方法调用章节

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:

  1. 执行引擎遇到任意一个方法返回的字节码编码,即return语句
  2. 在方法的执行过程中遇到了异常,且该异常没有在方法体内得到处理

附加信息

除了以上几点外,JVM规范运行JVM在实现时增加一些规范里没有描述的信息到栈帧之中。

方法调用

方法调用阶段唯一的任务就是确定被调用方法的版本,并不涉及到方法内部的具体运行过程。Class文件在编译的过程中不包含传统编译中的连接步骤,一切方法调用在Class文件中存储的都仅是符号引用。确定方法所调用的过程一般发生在类的加载期间或者运行期间。

解析

在类的加载阶段,会将其中的一部分符号引用转化为直接引用。这种解析能够成功的前提是:方法在程序真正运行之前就有一个可确定的调用版本,且该方法的调用版本在程序运行期间是不可变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用被称为解析。

编译期可知,运行期不变的方法主要包括两大类:

  • 静态方法:与类型直接关联
  • final方法:无法被继承重载(所有private方法默认就是final的)

这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

解析调用一定是一个静态的过程,在编译期间就能完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。

分派

首先以一段代码来定义两个重要概念

Human man = new Man();

上面代码中的”Human”被称为变量的静态类型,或者叫做外观类型,后面的”Man”则是实际类型。静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用的时候发生,变量本身的静态类型不会被改变(自动转型或其他转型),并且最终编译的静态类型是在编译期可知的;而对象的实际类型变化的结果在运行期才可确定,编译器在编译程序时并不知道一个对象的实际类型是什么。

虚拟机(准确来说是编译器)在重载时通过参数的静态类型而不是实际类型作为依据。在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。所有依赖静态类型来定位方法执行版本的分派就被称为静态分派,其典型应用是方法重载。静态分派发生在编译阶段,由编译器来确定。由于字面量没不用定义,所以涉及到字面量的静态分派逻辑往往会变得非常奇怪,这里就不展开讨论了。

对于重写时方法的分派,虚拟机采用的是动态分派的方式。对于语言多态中的动态分派,通过javap输出字节码,可以看出这里所使用的指令都是invokevirtual,而类型基本都是静态类型。

完全一样的字节码却执行了不同的目标方法,原因在于invokevirtual指令的查找过程:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
  2. 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证。
  4. 如果最终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常

上面即为Java中的动态分派