运行时帧栈结构与方法调用
运行时栈帧结构
栈帧是虚拟机用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。每一个方法从调入开始到执行完成的过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性中,因此,一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现,
一个线程中的方法调用链可能会很长,很多方法同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。栈帧的概念结构如图一所示
局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot)为最小单位,虚拟机规范并没有明确指明一个 Slot 应占用的内存空间大小,只是很有导向性地说到每个 Slot 都应该能存放一个 boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据,这 8 种数据类型都可以使用 32 位或更小的物理内存来存放。对于 64 位的 long 和 double 类型,虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间,虽然被分割存储了,但由于局部变量表是建立在线程的堆栈上,是线程私有的,因此不会有数据安全问题。
在方法执行时,如果是实例方法(非 static 的方法),那么局部变量表中第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过 this 来访问这个隐含的参数。其余参数按照参数表顺序排列,依次占用从 1 开始的 Slot,参数表分配完毕后,再根据方法内部定义的变量顺序和作用域分配其余的 Slot。
为了节省栈帧空间,局部变量表中的 Slot 是可以重用的。方法中定义的变量,其作用域不一定会覆盖整个方法,当超出某个变量的作用域时,整个变量对应的 Slot 就可以交给其它变量使用。这种设计虽然会省一部分空间,但也有额外的副作用,会影响到垃圾收集行为,见以下代码:
1 | public static void main(String[] args) { |
以上代码从逻辑上讲,在执行 System.gc();
的时候,placeholder
已经不可能再被访问了,但会发现内存并没有被回收。我们修改代码如下:
1 | public static void main(String[] args) { |
修改后的代码虽然看起来很莫名其妙,但测试会发现内存真的被回收了。这是因为第一段代码中,虽然已经离开了 placeholder
的作用域,但此之后,没有任何局部变量表的读写操作,placeholder
原本所占用的 Slot 还没有被其它变量所复用,所以作为 GC Roots 一部分的局部变量表仍然保持着对它关联。
因此,对于定义的占用大量内存,实际上已经不会再使用的变量,手动将其设置为 null 值(用来代替上面那句 int a = 0,把变量对于的局部变量表 Slot 清空),并不是一个绝对无意义的操作,有时也有奇效,这种操作一般在这种极其特殊的情况下使用:对象内存占用大、此方法的栈帧长时间不能被回收(即方法还有很多步)、方法调用次数还达不到 JIT 的编译条件。
操作数栈
操作数栈也常称为操作栈,同局部变量表一样,操作数栈的最大深度也在编译的时候写入到 Code 属性里的 max_stacks 数据项中。操作数栈的每一个元素可以是任意的 Java 数据类型, 32 位数据类型所占的栈容量为 1, 64 位所占容量为 2。
当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和读取内容,即出栈/入栈操作。举个例子,整数加法的字节码指令 iadd 在运行的时候将操作数栈最接近栈顶的两个元素出栈并相加,然后将相加的结果入栈。
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法:一是遇到 return 命令,二是遇到了未处理的异常。无论采用何种方式退出,在方法退出之后都需要返回到方法被调用的位置,程序才能继续执行。一般来说,方法正常退出时候,调用者的 PC 计数器的值可以作为返回地址,栈帧中可能会保存这个值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此可能的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈,调整 PC 计数器的值以指向方法调用指令后面的一条指令等。
方法调用的过程
方法调用不等同于方法执行,方法调用的阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),还不涉及方法内部的具体运行过程。
解析
所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析。符合“编译期可知,运行期不可变”这个要求的方法有静态方法、私有方法、构造方法、父类方法、final 修饰的方法。解析调用一定是个静态的过程,在编译期间就完全确定。
类的解析过程可以看之前的文章: 类加载过程——解析
静态分派
先演示一段代码,思考一下将会输出什么
1 | public class StaticDispatch { |
这段代码实际上是考验对重载的理解程度,其输出结果是:
1 | 动物在叫 |
有两个非常重要的概念,以以下代码为例:
1 | Animal dog = new Dog(); |
Animal
称为变量的静态类型(Static Type),或者叫外观类型(Apparent Type),后面的 Dog
称为变量的实际类型(Actual Type)。这两种类型都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会改变,并且最终的静态类型是在编译期可知的;而实际类型的变化结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
1 | // 实际类型变化 |
编译器在重载时时通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是在编译期可知的,因此,在编译阶段,Javac 编译期会根据参数的静态类型决定使用哪个版本。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机执行的。
动态分派
动态分派和多态性的另外一个重要体现——重写(Override)有很密切的关联。
思考下面一代代码,会输出什么。
1 | public class DynamicDispatch { |
毫不意外地输出:
1 | 汪汪汪 |
但这很明显没有规矩静态类型来决定调用哪一个方法,因为静态类型都是 Animal
类型,显而易见,这里用了变量的实际类型来分派方法的执行版本。使用 javap 查看 main 方法的字节码
1 | public static void main(java.lang.String[]); |
可以看到第 9 行和第 21 行是调用了对象的方法,但注释都显示是 Animal.shout:()V 的符号引用,但是这两句指令最终执行的目标方法并不相同。原因需要从 invokevirtual
指令的多态查找过程开始说起,**invokevirtual
指令的运行时解析过程大致分为以下几个步骤:**
- 找到操作数栈顶的第一个元素所指向的实际类型,记作 C。
- 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则通过访问权限校验,如果通过,则返回这个方法的直接引用,查找结束;如果不通过,则返回
java.lang.IllegalAccessError
异常。 - 否则,按照继承关系从下往上依次对 C 的各个父类进行第 2 步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出
java.lang.AbstractMethodError
。
这个过程就是 Java 语言中方法重写的本质,这种在运行期根据实际类型确定方法版本的分派过程称为动态分派。
虚拟机动态分派的实现——虚方法表
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”的手段就是虚方法表(Virtual Method Table,与此对应还有个接口方法表)
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了这个方法,子类方发表中的地址将会替换为子类实现版本的入口地址。
方发表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。