虚拟机字节码执行引擎
一、运行时栈帧结构
概述
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,位于虚拟机栈中。
栈帧包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。
局部变量表
局部变量表是一组变量的存储空间,用于存放方法参数和方法内部的局部变量,在程序被编译为Class文件时就被方法的Code属性中的max_locals数据项确定(详情不了解参看之前博客Class类文件结构)
变量槽
局部变量表的容量以变量槽(Slot)为最小单位分配。虚拟机规范并未明确指明Slot大小,只是说明一个Slot应该能存放boolean、byte、char、short、int、float、reference(对象的引用)或returnAddress(指向字节码的指针)类型数据。对于64位的long和double两种(reference类型可能是32位也可能64位),需要分割为两份按照高位在前的方式分配两个连续的Slot空间。
如果方法是实例方法,那么变量表的0号槽位默认是用于传递方法所属对象实例的引用(this),之后为参数分配槽位,最后按照局部变量定义顺序和作用域分配其余Slot
如果方法是静态方法,没有this对象实例引用,直接从0号开始分配参数
Slot可以复用
如果当前PC计数器的值超出一个局部变量的作用范围,那么这个Slot可以被其他变量复用。
Slot复用对垃圾回收的影响
- 使用"-verbose:gc",可以在控制台看到垃圾回收信息,经历一次GC和Full GC可以看出64M的空间并没有被回收。根据局部变量保存在局部变量表中,可以知道当前并未脱离bytes的作用范围,所以不会被回收
- 可修改了变量作用域后仍未被回收
- 可以看出这次bytes所占用的空间被回收了
导致bytes是否被回收的最根本原因就是Slot中是否仍然存在这bytes的引用,第二次虽然已经不再bytes的作用范围,但并没有其他变量复用bytes占用的Slot,又因为局部变量表属于GC Roots,所以虚拟机认为bytes仍然可达,就不会将其回收。第三次断开了bytes与GC Roots的连接,所以就被回收。
变量初始值
对于类变量,有两次赋初始值的机会
- 准备阶段:赋系统初始值或static final类型自定义的常量值
- 初始化阶段:执行<clinit>和<init>方法赋我们声明的初始值
对于局部变量,必须程序员声明初始值,JVM在调用到变量时才会进行初始化
操作数栈
与数据结构中的栈类似,也是后进先出的结构。同局部变量表一样,操作数栈深度也在编译时候就被写入Code属性的max_stacks数据项。32位的数据类型占用一个栈容量,64位的数据类型占用两个栈容量。
在概念模型中,两个栈帧应当是完全独立的,但是在虚拟机的实现中,会让两个栈帧出现一部分重叠,这样可以减少额外的参数复制传递
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用。这些符号引用一部分会在类加载阶段或第一次使用时转换为直接引用,这称为静态解析。另一部分在运行期间转化为直接引用,这部分称为动态连接。
方法调用
方法调用不等同与方法执行,方法调用只是选择使用哪个方法执行,具体执行则为其他阶段。(主要表现:方法重载、方法重写)
解析
简而言之,在编译时期就可以确定的方法调用就称之为解析
在Java中符合上述要求的方法主要有两类:
- 静态方法
- 私有方法
Java虚拟机提供了四条方法调用字节码指令:
- invokestatic:调用静态方法
- invokespecial:调用<init>方法、私有方法和父类方法
- invokevirtual:调用所有的虚方法(不在类加载期间将符号引用转换为直接引用)和final方法
- invokeinterface:调用接口方法
解析调用演示
public class Test {
public static void sayHello(){
System.out.println("Hello World");
}
public static void main(String[] args) {
Test.sayHello();
}
}
分派
Java多态的具体体现(“重写”和“重载”)在JVM层面就是由分派实现的
静态分派
由一个方法重载的例子开头,大家想想结果是什么
public class Test {
public static void main(String[] args) {
Test test = new Test();
Father father = new Son(); //静态分派
test.print(father);
}
void print(Father father) {
System.out.println("this is father");
}
void print(Son son) {
System.out.println("this is son");
}
}
class Father{
}
class Son extends Father{
}
结果:this is father
对于Father father = new Son();来说
- Father称为变量的静态类型或外观类型
- Son称为变量的实际类型
静态类型是编译期可知的,实际类型只有运行时才可知。而虚拟机在重载时是通过参数的静态类型作为判断依据的,在编译时期就已确定调用方法的版本。依赖静态类型来进行方法调用的都成为静态分配
动态分派
看一个方法重写的例子
public class Test {
public static void main(String[] args) {
Father father = new Son(); //静态分派
father.print();
}
}
class Father{
void print() {
System.out.println("this is father");
}
}
class Son extends Father{
void print() {
System.out.println("this is son");
}
}
运行结果:this is son
与静态分派相对应的就是动态分派,是按照实际类型进行方法调用
单分派与多分派
方法的接收者与方法的参数统称为方法的宗量。
- 根据一个宗量对目标方法进行选择叫做单分派
- 根据多个宗量对目标方法进行选择叫做多分派
静态分派属于多分派:1、需要考虑静态类型是Father还是Son,2、需要考虑方法的参数是father还是son
动态分派属于单分派:因为动态分派参数类型在编译期间已经确定,只需要考虑实际类型即可
二、基于栈的字节码解释执行引擎
解释执行:将代码一句句解释为机器码来执行,解释一句,执行一句
编译执行:先将代码全部编译为机器码,然后进行执行
对于Java来说是编译为字节码,之后由字节码解释为机器码执行
基于栈的指令集
Java编译器输出的指令流基本上是一种基于栈的指令集结构。指令大部分都是零地址指令,依赖操作数进栈进行工作
基于寄存器的指令集
依赖寄存器进行工作,与物理机具有较大关系
二者关系
基于栈的指令集具有较好的移植性,而寄存器由硬件提供受到硬件的约束
栈指令集执行速度稍慢一些,寄存器指令集则速度较快
栈指令集代码相对紧凑,编译器实现简单,不需要考虑空间分配。但是也会多很多入栈出栈的操作指令