JAVA虚拟机(JVM)详细介绍(六)——字节码执行引擎
栈帧
定义:
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它位于虚拟机栈里面。
作用:
每个方法从调用开始到执行完成的过程中,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
特点:
(1)栈帧包括了局部变量表,操作数栈等,到底是需要多大的局部变量表,多深的操作数栈是在编译期确定的。因为一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响。
(2)两个栈帧之间的数据共享。在概念模型中,两个栈帧是完全独立的,但是在虚拟机的实现里会做一些优化处理,令两个栈帧出现一部分重叠。这样在进行方法调用时,就可以共用一部分数据,无须进行额外的参数复制传递。
(1)局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。
//方法参数 max(int a,int b)
int a;//全局变量 void say(){ int b=0;//局部变量 }
局部变量和类变量(用static修饰的变量)不同
类变量有两次赋初始值的过程:准备阶段(赋予系统初始值)和初始化阶段(赋予程序员定义的初始值)。所以即使在初始化阶段没有为类变量赋值也没关系,它仍然有一个确定的初始值。
但局部变量不一样,如果定义了,但没有赋初始值,是不能使用的。
(2)操作栈
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈、入栈操作。
例如,计算:
int a=2+3
操作数栈中最接近栈顶的两个元素是2和3,当执行iadd指令时,会将2和3出栈并相加,然后将相加的结果5入栈。
(3)动态链接
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用分为两部分:
静态解析:在类加载阶段或第一次使用的时候就转化为直接引用。动态链接:在每一次运行期间转化为直接引用。
(4)返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法:正常退出、异常退出。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。
当方法正常退出时
调用者的PC计数器作为返回地址。栈帧中一般会保存这个计数器值。
当方法异常退出时
返回地址是要通过异常处理器表来确定的。栈帧中一般不会保存这部分信息。
方法调用
方法调用是确定调用哪一个方法。
(1)解析
对“编译器可知,运行期不可变”的方法进行调用称为解析。符合这种要求的方法主要包括
静态方法,用static修饰的方法私有方法,用private修饰的方法
(2)分派
分派讲解了虚拟机如何确定正确的目标方法。分派分为静态分派和动态分派。讲解静动态分派之前,我们先看个多态的例子。
Human man=new Man();
在这段代码中,Human为静态类型,其在编译期是可知的。Man是实际类型,结果在运行期才可确定,编译期在编译程序的时候并不知道一个对象的实际类型是什么。
静态分派:
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。它的典型应用是重载。
public class StaticDispatch{ static abstract class Human{ } static class Man extends Human{ } static class Woman extends Human{ } public void say(Human hum){ System.out.println("I am human"); } public void say(Man hum){ System.out.println("I am man"); } public void say(Woman hum){ System.out.println("I am woman"); } public static void main(String[] args){ Human man = new Man(); Human woman = new Woman(); StaticDispatch sr = new StaticDispatch(); sr.say(man); sr.say(woman); } }
运行结果是:
I am human I am human
为什么会产生这个结果呢?
因为编译器在重载时,是通过参数的静态类型而不是实际类型作为判断依据的。在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本,所以两个对say()方法的调用实际为sr.say(Human)。
动态分派:
在运行期根据实际类型确定方法执行版本的分派过程。它的典型应用是重写。
public class DynamicDispatch{ static abstract class Human{ protected abstract void say(); } static class Man extends Human{ @Override protected abstract void say(){ System.out.println("I am man"); } } static class Woman extends Human{ @Override protected abstract void say(){ System.out.println("I am woman "); } } public static void main(String[] args){ Human man = new Man(); Human woman = new Woman(); man.say(); woman.say(); man=new Woman(); man.say(); } }
运行结果:
I am man I am woman I am woman
这似乎才是我们平时敲的java代码。对于方法重写,在运行时才确定调用哪个方法。由于Human的实际类型是man,因此调用的是man的name方法。其余的同理。
动态分派的实现依赖于方法区中的虚方法表,它里面存放着各个方法的实际入口地址。如果某个方法在子类中被重写了,那子类方法表中的地址将会替换为指向子类实现版本的入口地址,否则,指向父类的实现入口。
单分派和多分派:
方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,分为单分派和多分派。
在静态分派中,需要调用者的实际类型和方法参数的类型才能确定方法版本,所以其是多分派类型。在动态分派中,已经知道了参数的实际类型,所以此时只需知道方法调用者的实际类型就可以确定出方法版本,所以其是单分派类型。综上,java是一门静态多分派,动态单分派的语言。
字节码解释执行引擎
虚拟机中的字节码解释执行引擎是基于栈的。下面通过一段代码来仔细看一下其解释的执行过程。
public int calc(){ int a = 100; int b = 200; int c = 300; return (a + b) * c; }
第一步:将100入栈。
第二步:将操作栈中的100出栈并存放到局部变量中。后面的200,300同理。
第三步:将局部变量表中的100复制到操作数栈顶。
第四步:将局部变量表中的200复制到操作数栈顶。
第五步:将100和200出栈,做整型加法,最后将结果300重新入栈。
第六步:将第三个数300从局部变量表复制到栈顶。接下来就是将两个300出栈,进行整型乘法,将最后的结果90000入栈。
第七步:方法结束,将操作数栈顶的整型值返回给此方法的调用者。