JVM读书笔记之第八章

虚拟机字节码执行引擎

概述

执行引擎是Java虚拟机最核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。

局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存放。

在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那局部变量表中第0位索引的slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。为了尽可能节省栈帧空间,局部变量表中的slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的slot就可以交给其他变量使用。但是会有副作用。例如:

1
2
3
4
5
6
7
public static void main(String[] args) {
{
byte[] placeHolder = new byte[64*1024*1024];
}
int a = 0; // 注释掉这行代码,gc不会回收上面的64mb内存
System.gc()
}

placeHolder能否被回收的根本原因是:局部变量表中的slot是否还存有关于该数据对象的引用。如果注释了那行代码,即使代码已经离开了placeholder的作用域,但placeholder原本所占用的slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联,所以不会被回收。但一般来说通过JIT编译后,System.gc()执行时就可以正确地回收内存,这是因为对GC Roots的枚举方式也有了巨大的差别。

局部变量不会像类变量那样,在“准备”阶段会被赋予系统初始值,在初始化阶段,赋予程序员定义的初始值。因此在初始化阶段程序没有为类变量赋值也没有关系,类变量仍有一个确定的初始值。但局部变量不一样,如果局部变量定义了但没有赋初始值的话,该变量是不能使用的,编译不会通过。

操作数栈

操作数栈的每一个元素可以是任意的java数据类型

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)

方法返回地址

当一个方法执行的时候,只有两种方式可以退出这个方法。

  1. 正常完成出口:执行引擎遇到任意一个方法返回的字节码指令,将返回值传递给上层的方法调用者。
  2. 异常完成出口:在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。

无论采用何种退出方式,在方法退出后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的pc计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上就等同于把当前帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整pc计数器的值以指向方法调用指令后面的一条指令等。

附加信息

方法调用

方法调用并不等同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。

解析

所有方法调用中的目标方法在class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

在Java语言中符合“编译器克制,运行期不可变”的方法主要包括:静态方法和私有方法两大类。前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

静态方法、私有方法、实例构造器init方法、父类方法和被final修饰的方法都是非虚方法,在类加载的解析阶段就会把设计的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。

分派

静态分派

在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

动态分派

典型应用是方法重写(override)

动态类型语言支持

基于栈的字节码解释执行引擎