运行时栈帧结构

概述

  • 栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,他是虚拟机运行时数据区中的虚拟机栈的栈元素,栈帧存储了方法的局部变量,操作数栈,动态连接和方法返回值等信息,每个方法从调用开始到执行完成的过程都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
  • 一个线程中的方法调用链会很长,只有位于栈顶的栈帧才有效,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行所有字节码指令都只针对当前栈帧进行操作。
image-20210615205601356

局部变量表

  • 局部变量表是一组变量存储空间,用于存放方法参数和方法内部定义的局部变量。
  • 在Java程序编译为class文件时就在方法的code属性的max_locals数据项中确定该方法所需要分配的局部变量表的最大容量。局部变量表的容量以变量槽为最小单位。
  • 虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大Slot数量。
  • 在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果是实例方法(非static的方法),那么局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
  • 局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。这样的设计不仅仅是为了节省栈空间,在某些情况下Slot的复用会直接影响到系统的垃圾收集行为
  • 局部变量没有默认值,需要初始化,否则这段代码在java中不能运行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* -verbose:gc
*/
public class SlotTest {

public static void main(String[] args) {
//placeholder的作用域被限制在花括号之内
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
//如果不增加这行,即没有任何对局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。
int a = 0 ;
System.gc();
}
}

运行结果:

1
2
[GC (System.gc())  68864K->66256K(125952K), 0.0020403 secs]
[Full GC (System.gc()) 66256K->664K(125952K), 0.0095304 secs]

操作数栈

  • 也称为操作栈,他是一个后入先出栈的栈,同局部变量一样,操作数栈的最大深度也在编译的时候写入到了code属性的max_stacks数据中,在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
  • 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是入栈出栈操作。
  • Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈

动态连接

  • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
  • 字节码中的方法调用指令就以常量池中指向方法的符号引用为参数,这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接

方法返回地址

  • 第一种退出方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
  • 另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
  • 方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息
  • 方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

附加信息

  • 虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息。
  • 一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

方法调用

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

在程序运行时,进行方法调用是最普遍、最频繁的操作之一。

解析调用

  • 解析就是将方法的符号引用转化成直接引用,解析的前提是方法须在方法运行前就确定一个可调用的版本,并且这个版本在运行阶段是不可改变的(编译期可知,运行期不可变)。
  • 只有用invokestaticinvokespecial指令调用的方法,都可以在解析阶段确定调用版本,符合此条件的有静态方法,私有方法,实例构造器和父类方法四类,再加上被final修饰的方法(尽管它使用invokevirtual指令调用)。它们在类加载时即把符号引用解析为该方法的直接引用,这些方法可以称为非虚方法,其他防备被称为虚方法。
  • 解析调用是一个静态过程,编译期间就可以确定,分派调用可能是静态的也可能是动态的,是实现多态性的体现。

静态分派

为了解释静态分派和和重载(Overload),请看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class StaticDispatch {

static abstract class Human {
}

static class Man extends Human {
}

static class Woman extends Human {
}

public static void sayHello(Human guy) {
System.out.println("hello guy");
}

public static void sayHello(Man guy) {
System.out.println("hello gentleman");
}

public static void sayHello(Woman guy) {
System.out.println("hello lady");
}

public static void main(String[] args) {
Human man = new Man();

Human woman = new Woman();

sayHello(man);

sayHello(woman);
}
}

运行结果:

1
2
hello guy
hello guy
  • 对代码Human man = new Man()来说,“Human”称为变量的静态类型,后面的“Man”称为变量的实际类型

  • 虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。

  • 所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派

  • 静态分派的最典型应用表现就是方法重载

  • 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

  • 解析与分派这两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。例如,前面说过,静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的

编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本,看以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class Overload {
public static void sayHello(Object arg) {
System.out.println("hello Object");
}

public static void sayHello(int arg) {
System.out.println("hello int");
}

public static void sayHello(long arg) {
System.out.println("hello long");
}

public static void sayHello(Character arg) {
System.out.println("hello Character");
}

public static void sayHello(char arg) {
System.out.println("hello char");
}

public static void sayHello(char... arg) {
System.out.println("hello char……");
}

public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
}

public static void main(String[] args) {
sayHello('a');
}
}

运行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
hello char

这很好理解,'a'是一个char类型的数据,自然会寻找参数类型为char的重载方法,如果注释掉sayHello(char arg)方法,那输出会变为:

hello int

这时发生了一次自动类型转换,'a'除了可以代表一个字符串,还可以代表数字97(字符'a'的Unicode数值为十进制数字97),因此参数类型为int的重载也是合适的。我们继续注释掉sayHello(int arg)方法,那输出会变为:

hello long

这时发生了两次自动类型转换,'a'转型为整数97之后,进一步转型为长整数97L,匹配了参数类型为long的重载。笔者在代码中没有写其他的类型如float、double等的重载,不过实际上自动转型还能继续发生多次,按照char->int->long->float->double的顺序转型进行匹配。但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。我们继续注释掉sayHello(long arg)方法,那输出会变为:

hello Character

这时发生了一次自动装箱,'a'被包装为它的封装类型java.lang.Character,所以匹配到了参数类型为Character的重载,继续注释掉sayHello(Character arg)方法,那输出会变为:

hello Serializable

这个输出可能会让人感觉摸不着头脑,一个字符或数字与序列化有什么关系?出现hello Serializable,是因为java.lang.Serializable是java.lang.Character类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生一次自动转型。char可以转型成int,但是Character是绝对不会转型为Integer的,它只能安全地转型为它实现的接口或父类。Character还实现了另外一个接口java.lang.Comparable<Character>,如果同时出现两个参数分别为Serializable和Comparable<Character>的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显式地指定字面量的静态类型,如:sayHello((Comparable<Character>)'a'),才能编译通过。下面继续注释掉sayHello(Serializable arg)方法,输出会变为:

hello Object

这时是char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用传入的参数值为null时,这个规则仍然适用。我们把sayHello(Object arg)也注释掉,输出将会变为:

hello char……
可变长参数的重载优先级是最低的

动态分派

它与Java语言多态性的另一个重要体现–重写(Override)有着很密切的关联。用一个例子来讲解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}

static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}

static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}

public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}

运行结果:

1
2
3
man say hello
woman say hello
woman say hello

动态分派的关键是 invokevirtual指令, 它的运行时解析过程大致分为以下几个步骤:

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

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

单分派和多分派

  • 方法的接收者与方法的参数统称为方法的宗量
  • 根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

用一个例子来讲解:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class Dispatch {
static class QQ {
}

static class _360 {
}

public static class Father {
public void hardChoice(QQ arg) {
System.out.println("father choose qq");
}

public void hardChoice(_360 arg) {
System.out.println("father choose 360");
}
}

public static class Son extends Father {
public void hardChoice(QQ arg) {
System.out.println("son choose qq");
}

public void hardChoice(_360 arg) {
System.out.println("son choose 360");
}
}

public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}

运行结果:

1
2
father choose 360
son choose qq

我们来看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(new QQ())”这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型

虚拟机动态分派的实现

  • 由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Vritual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Inteface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。
  • 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。
img

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

请看P329的例子