title: 虚拟机字节码执行引擎
date: 2019-03-29 14:51:14
categories:
- Java虚拟机
tags: - 虚拟机字节码执行引擎
概述
将Java
源文件编译成字节码之后,就可以通过Java
虚拟机的核心组件——执行引擎来进行执行。现代JVM
在执行Java
代码的时候,通常都会将解释执行与编译执行二者结合起来进行。执行过程一般都是输入字节码,解析字节码,输出执行结果。
解释执行:由
Java
解释器执行,将字节码从头开始进行读取,读取到相应的指令就去执行该指令。编译执行:不要与源代码的编译混为一谈。就是将字节码由即时编译器(
Just In Time ,JIT
)产生本地代码,就是相应的机器码,然后去执行。现代JVM
会根据热点代码(经常调用的代码)来生成相应的本地机器码。转换为机器码,就不具有可移植性了,各个平台的机器码各不相同。
栈帧
栈帧,简单来说就是——用于帮助虚拟机执行方法调用与方法执行的数据结构。它是jvm
运行时数据区中的虚拟机栈中的栈元素。栈帧本身是一种数据结构,封装了方法的局部变量表、动态连接、方法的返回地址以及操作数栈。在上文中Java字节码(一):深度分析Class类文件中,我们知道了在Class
文件中每个方法的操作数栈深度以及局部变量表都已经在编译期确定了,被写入到Code
属性中了。对于执行引擎来讲,活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。
局部变量表
局部变量表是用以存储方法参数、方法内部定义的局部变量以及代表当前对象的this
。Slot
是虚拟机为局部变量分配内存所使用的最小单位。不超过32位的数据类型占1个Slot
,64位的数据类型则使用两个Slot
,在编译期,就已经确定好局部变量表的所需的存储空间了。另外,并不是在方法中用到了多少了局部变量,就把这些Slot
之和作为max_locals
的值,原因是局部变量表中的Slot
可以重用,在局部变量表中通过这个变量的偏移量可以得到这个变量的作用域,当代码执行超出一个局部变量的作用域时,这个局部变量所占的Slot
就可以被其他局部变量所使用,编译器会根据变量的作用域来分配Slot
给各个变量使用,然后计算出max_loacals
的大小。
操作数栈
操作数栈,即是一个先入后出的栈。同局部变量表一样,在编译期,Javac
编译器也将所需的操作数栈的深度计算出来放到Code
属性中的max_stacks
中。当一个方法刚刚执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指向操作数栈中写入和提取值,也就是入栈与出栈操作。例如,在做算术运算的时候就是通过操作数栈来进行的,又或者调用其它方法的时候是通过操作数栈来行参数传递的。另外,在概念模型中,两个栈帧作为虚拟机栈的元素,相互之间是完全独立的,但是大多数虚拟机的实现里都会作一些优化处理,令两个栈帧出现一部分重叠。让下栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用返回时就可以共用一部分数据,而无须进行额外的参数复制传递了。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class
文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。
方法的返回地址
方法的返回地址就是在栈帧中保存的一些信息,用以在当方法调用完成后,就可以会返回到调用方法处,继续执行代码,有了这个地址,就可以回到这个调用处,继续执行下面的代码。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用PC
计数器的值以指向方法调用指令后面的一条指令等。
方法的调用
方法的调用指的是确定被调用方法的版本,即确定调用哪一个方法。
解析
在程序运行之前,就确定好执行哪个方法,即在在编译期间,就可以确定下来,然后在类加载的解析阶段,就将常量池中,与此方法有关的符号引用转换为直接引用。因此,想要在类加载阶段就完成解析工作,首先这个方法是可以确定唯一的版本的,比如静态方法、私有方法、构造方法、父类方法,称为非虚方法,与之相对应的字节码助记符:
invokestatic
:调用静态方法invokespecial
:调用构造方法、私有方法和父类方法
解析一定是一个静态过程,在编译期间就可以确定其要调用哪个方法,在类加载的解析阶段,就可以将涉及的符号引用,即invokestatic
和invokespecial
指令的参数在常量池中相对应的符号引用转换为直接引用。
分派
静态分派
静态分派,简单来说,可以理解为在编译期间根据静态类型确定方法执行版本的分派过程。静态分派与方法的重载是密切相关的。
静态类型:
Grandpa g1 = new Father();
以上代码,g1
的静态类型是Grandpa
,而g1
的实际类型(真正指向的类型)是Father
。
public class MyTest6 {
public void test(Grandpa grandpa){
System.out.println("grandpa");
}
public void test(Father father){
System.out.println("father");
//方法的重载,是一种静态行为,在编译期就可以确定
}
public void test(Son son){
System.out.println("son");
}
public void test(int a){
System.out.println("son");
}
public static void main(String[] args) {
MyTest6 myTest6 = new MyTest6();
Grandpa g1 = new Father();
Grandpa g2 = new Son();
Father father = new Father();
myTest6.test(father);
myTest6.test(g1);
myTest6.test(g2);
}
}
class Grandpa{}
class Father extends Grandpa{}
class Son extends Father{}
输出结果:
father
grandpa
grandpa
在方法接收者已经确定的前提下,使用哪个重载版本,取决于传入的参数的数量和数据类型。编译器在重载时通过参数的静态类型
来确定重载方法的版本。并且静态类型是在编译期
可知的,意思是对于重载来说,在编译阶段,就已经确定了要执行的重载方法的版本。
- 常量池中生成对应重载方法的符号引用(没用到的重载方法版本是不会在常量池中生成符号引用的)。
- 对于没有显示静态类型的字面量来说,它的静态类型只能通过语言上的规则来理解和推断。
静态分派与解析的区别
解析与静态分派不是一个层面上的东西,它们是在不同的层次上去筛选、确定方法的过程。解析针对的是,在类加载的解析阶段,能够确定的唯一方法,针对的是invokestatic
和invokespecial
。而静态分派则是,在编译阶段方法的接收者确定的情况下,根据参数的静态类型确定重载方法的版本。
动态分派
动态分派,简单来说,可以理解为在运行期间根据实际类型确定方法执行版本的分派过程。针对的是方法的重写。
public class MyTest7 {
public static void main(String[] args) {
Fruit apple = new Apple();
Fruit orange = new Orange();
apple.test();
orange.test();
apple = new Orange();
apple.test();
}
}
class Fruit{
public void test(){
System.out.println("Fruit");
}
}
class Apple extends Fruit{
@Override
public void test() {
System.out.println("Apple");
}
}
class Orange extends Fruit{
@Override
public void test() {
System.out.println("Orange");
}
}
输出结果:
Apple
Orange
Orange
输出结果显示,方法的确定是通过变量的实际类型来确定的。
来看看这个方法的Code
属性。
可以看出,在编译阶段,生成的字节码文件中invokevirtual
指令的参数都是父类Fruit
的test
方法,但实际在运行时,这几个相同的指令最终执行的目标方法并不相同,这是依赖于invokevirtual
的多态查找过程。
动态分派的多态查找过程:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型。
- 如果在这个类型中找到与常量池中符号引用相同的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用。
- 否则,按照继承关系从下往上依次对这个对象类型的父类进行搜索和验证。
invokevirtual
指令执行的第一步就是在运行期确定方法接收者的实际类型,所以这几次调用invokevirtual
指令就将常量池中的类符号引用解析到了不同的直接引用上,这个过程就是Java
语言中方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程就叫动态分派。
示例分析
public class MyTest8 {
public static void main(String[] args) {
Animal animal = new Animal();
Animal cat = new Cat();
animal.test(1);
cat.test("1");
}
}
class Animal{
public void test(int a){
System.out.println("animal int");
}
public void test(String a){
System.out.println("animal str");
}
}
class Cat extends Animal{
@Override
public void test(int a){
System.out.println("cat int");
}
@Override
public void test(String a){
System.out.println("cat str");
}
}
输出结果:
animal int
cat str
结果不重要,看看Code
属性:
在编译阶段,编译器的选择过程,也就是静态分派的过程:
这时候的判断依据有两点:静态类型是
Cat
还是Animal
,以及传入的参数是int
还是String
,这次选择产生了两条invokevirtual
指令,这两条指令分别指向常量池中的Animal.test(int)
以及Animal.test(String)
。然后到了运行阶段,也就是动态分派过程:
执行到了
invokevitual
指令,由于在编译阶段,已经确定好方法的参数,此时就只需确定方法的接收者的实际类型。根据invokevirtual
的多态查找过程,找出正确的重写方法去执行。