深入理解Java虚拟机(2)之十三-虚拟机字节码执行引擎

运行时栈帧结构

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

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

栈帧的概念结构

1、局部变量表

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

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

Java中占用32位以内的数据类型有booleanbytecharshortintfloatreferencereturnAddress8种类型。reference类型表示对一个对象实例的引用,虚拟机规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但一般来说,虚拟机实现至少都应当能通过这个引用做到两点:

  • 一是从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引;
  • 二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束约束。

returnAddress类型目前很少见了,它为字节码指令jsr、jsr_w和ret服务的,执行一条字节码指令的地址,很古老的Java虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。

Java语言中明确的64位数据类型只有longdouble两种,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表的最大的Slot数量。如果访问的是32位的数据类型的变量,索引n就代表了使用第n个Slot,如果是64位数据的两个Slot,则说明会同时使用n和n+1两个Slot,并且不允许采用任何方式单独访问其中某一个,Java虚拟机规范要求如果遇到这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。

在执行方法时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static 的方法),局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表的顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot

局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。副作用就是某些情况下,Slot复用会直接影响到系统的垃圾收集行为。

2、操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First OutLIFO)栈。操作数栈的最大深度也是在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素都可以是任意的Java数据类型,包括longdouble。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2.在方法执行的任何时候,操作数栈的深度都不会超过max_stacks数据项中设定的最大值。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。

3、动态连接

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

4、方法返回地址

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

  • 第一种是执行引擎遇到任意方法返回的字节码指令,这个时候会有返回值传递到上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方式成为正常完成出口(Normal Method Invocation Completion)。
  • 另一种退出方式是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

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

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

5、附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有的描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。

方法调用

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

1、解析

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

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

与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器方法、私有方法和父类方法。
  • invokevirtual:调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而该方法指令的分派逻辑是由用户所设定的引导方法决定的。

只要能被invokestaticinvokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,称为非虚方法,与之相反,其他方法称为虚方法(除去final方法)。

解析调用一定四个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dsipatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。

2、分派

2.1 静态分派
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
35
36
37
38
/**
* 方法静态分派演示
*/
public class StaticDispatch {
static abstract class Human {
}

static class Man extends Human {
}

static class Woman extends Human {
}

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

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

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

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


运行结果:
hello,guy!
hello,guy!

Human man = new Man();

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

1
2
3
4
5
6
// 实际类型变化
Human man = new Man();
man = new Woman();
// 静态类型变化
staticDispatch.sayHello((Man) man);
staticDispatch.sayHello((Woman) man);

上面演示案例中,manwoman是两个静态类型相同而实际类型不同的变量,但虚拟机(准确说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判断依据的。而且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本不是“唯一的”,往往只能确定一个“更加合适的”版本。产生模糊的主要原因是字面量不需要定义,所以字面量没有显式的静态变量,它的静态类型只能通过语言上的规则去理解和推断。如已下代码:

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
35
36
37
package jvm;

import java.io.Serializable;

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');
}
}

上面代码运行后输出:

hello char

‘a’是一个char类型的数据,自然寻找参数类型为char的重载方法,如果注释掉sayHello(char arg)方法,那么输出会变成:

hello int

这是发生一次自动类型转换,‘a’除了可以代表一个字符串,还可以代表数字97。继续注释掉sayHello(int arg)方法,输出变成:

hello long

这时发生两次自动类型转换,’a’转型为整数97之后,进一步转型为长整数97L,匹配long的重载。实际上自动转型还能继续发生多次,按照char->int->long->float->double的顺序转型进行匹配。但不会匹配到byteshort类型的重载,因为charbyteshort的转型是不安全的。注释掉sayHello(long arg),输出结果变为:

hello Character

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

hello Serializable

因为java.lang.Serializablejava.lang.Character类实现的一个接口,当自动装箱之后还找不到装箱类,但是找到装箱类实现了的接口类型,所以紧接着又发生一次自动转型。char可以转型成int,但是Character是绝对不会转型为Integer的,它只能安全地转型为它实现的接口类型或父类。继续注释掉sayHello(Serializable arg)方法,输出变为:

hello object

注释sayHello(Object arg),输出变为:

hello char…

2.2 动态分派

动态分派和多态性的另一个重要体现——重写(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
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
/**
* 方法动态分派演示
*/
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 void sayHello(Human guy) {
System.out.println("hello,guy!");
}

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

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

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

运行结果:
man say hello
woman say hello
woman say hello
2.3 单分派与多分派

根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对于目标方法进行选择。

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
35
36
37
38
39
40
41
42
43
44
45
46
package jvm;

/**
* 单分派、多分派演示
*/
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 {
@Override
public void hardChoice(QQ arg) {
System.out.println("son choose qq");
}

@Override
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());
}
}

运行结果:
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语言的动态分派属于单分派类型。

2.4 虚拟机动态分派的实现

虚拟机在类的方法区建立一个虚方法表(Virtual Method table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method table,也称为itable),使用虚方法表索引来代替元数据查找以提高性能。

虚方法表

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类相同的地址入口是一致的,都指向父类的实现入口。如果子类重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

如图,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是SonFather都没有重写来自object的方法,所以他们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

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

1、解释执行

编译过程

大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过上图的各个步骤。上图的下面那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,中间那条分支,就是解释执行的过程。

Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。

2、基于栈的指令集与基于寄存器的指令集

Java编译器输出的指令流,基本上是基于栈的指令集架构(Instruction Srt Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集,通俗点就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。那么它们的区别是什么呢。

举个简单的例子,分别用这两种指令集计算“1+1”的结果,基于栈的指令集会是这样:

1
2
3
4
iconst_1
iconst_1
iadd
istore_0

两条iconst_1指令连续把两个常量1压入栈后,iadd指令把栈顶的两个值出栈、相加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。

如果基于寄存器,那程序可能会是这样:

1
2
mov eax, 1
add eax, 1

mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就保存在EAX寄存器里面。

优缺点:

  • 基于栈的指令集的主要优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地收到硬件的约束。
  • 栈架构的指令集的主要缺点就是执行速度会相对慢一点。

3、基于栈的解释器执行过程

1
2
3
4
5
6
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}

javap查看字节码指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public int calc();
descriptor: ()I
flags: ACC_PUBLIC
Code:
stack=2, locals=4, args_size=1
0: bipush 100
2: istore_1
3: bipush 200
5: istore_2
6: sipush 300
9: istore_3
10: iload_1
11: iload_2
12: iadd
13: iload_3
14: imul
15: ireturn
LineNumberTable:
line 7: 0
line 8: 3
line 9: 6
line 10: 10

javap提示需要深度为2的操作数栈和4个Slot的局部变量空间。

解释器执行

解释器执行

解释器执行

解释器执行

解释器执行

解释器执行

解释器执行


该文章来源《深入理解Java虚拟机》


以上

LeoQin wechat
欢迎您扫一扫上面的微信公众号,订阅我的博客!
0%