文章目录
JVM字节码执行引擎
JVM字节码执行引擎概述
所谓的「虚拟机字节码执行引擎」其实就是 JVM 根据 Class 文件中给出的字节码指令,基于栈解释器的一种执行机制。通俗点来说,也就是 JVM 解析字节码指令,输出运行结果的一个过程。接下来我们详细看看这部分内容。
栈帧
栈帧的基本概念
在 Java 中,一个栈帧对应一个方法调用,方法中需涉及到的局部变量、操作数,返回地址等都存放在栈帧中的。每个方法对应的栈帧大小在编译后基本已经确定了,方法中需要多大的局部变量表,多深的操作数栈等信息早以被写入方法的 Code 属性中了。所以运行期,方法的栈帧大小早已固定,直接计算并分配内存即可。
- 栈帧:用于支持虚拟机进行方法调用和方法执行的数据结构,是虚拟机栈的栈元素
- 存储内容:方法的局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息
- 每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程
- 一个栈帧需要分配多少内存在程序编译期就已确定,而不会受到程序运行期变量数据的影响
- 对于执行引擎来说,只有位于栈顶的栈帧(当前栈帧)才是有效的,即所有字节码指令只对当前栈帧进行操作,与当前栈帧相关联的方法称为当前方法
局部变量表
局部变量表用来存放方法运行时用到的各种变量,以及方法参数。虚拟机规范中指明,局部变量表的容量用变量槽(slot)为最小单位,却没有指明一个 slot 的实际空间大小,只是说,每个 slot 应当能够存放任意一个 boolean,byte,char,short,int,float,reference 等。
- 定义:局部变量表是一组变量值存储空间
- 作用:存放方法参数和方法内部定义的局部变量
- 分配时期:
Java
程序编译为Class
文件时,会在方法的Code
属性的max_locals
数据项中确定了该方法所需要分配的局部变量表的最大容量 - 最小单位:变量槽
- 大小:虚拟机规范中没有明确指明一个变量槽占用的内存空间大小,允许变量槽长度随着处理器、操作系统或虚拟机的不同而发生变化
- 对于
32
位以内的数据类型(boolean
、byte
、char
、short
、int
、float
、reference
、returnAddress
),虚拟机会为其分配一个变量槽空间- 对于
64
位的数据类型(long
、double
),虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间- 特点:可重用。为了尽可能节省栈帧空间,若当前字节码
PC
计数器的值已超出了某个变量的作用域,则该变量对应的变量槽可交给其他变量使用
- 访问方式:通过索引定位。索引值的范围是从 0 开始至局部变量表最大的变量槽数量
- 局部变量表第一项是名为
this
的一个当前类引用,它指向堆中当前对象的引用(由反编译得到的局部变量表可知)
操作数栈
操作数栈也称作操作栈,它不像局部变量表采用的索引机制访问其中元素,而是标准的栈操作,入栈出栈,先入后出。操作数栈在方法执行之初为空,随着方法的一步一步运行,操作数栈中将不停的发生入栈出栈操作,直至方法执行结束。
操作数栈是方法执行过程中很重要的一个部分,方法执行过程中各个中间结果都需要借助操作数栈进行存储。
- 操作数栈是一个后入先出栈
- 作用:在方法执行过程中,写入(进栈)和提取(出栈)各种字节码指令
- 分配时期:同上,在编译时会在方法的
Code
属性的max_stacks
数据项中确定操作数栈的最大深度 - 栈容量:操作数栈的每一个元素可以是任意的
Java
数据类型 ——32
位数据类型所占的栈容量为1
,64
位数据类型所占的栈容量为2
- 注意:操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译时编译器需要验证一次、在类校验阶段的数据流分析中还要再次验证
动态连接
一个方法在调用另一个方法结束之后,需要返回调用处继续执行后续的方法体。那么调用其他方法的位置点就叫做「返回地址」,我们需要通过一定的手段保证,CPU 执行其他方法之后还能返回原来调用处,进而继续调用者的方法体。
正如我们一开始介绍的汇编代码一样,这个返回地址往往会被提前压入调用者的栈帧中,当方法调用结束时,取出栈顶元素即可得到后续方法体执行入口。
- 定义:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
- 静态解析和动态连接区别:
Class
文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用:
- 一部分会在类加载阶段或者第一次使用的时候就转化为直接引用(静态解析)
- 另一部分会在每一次运行期间转化为直接引用(动态连接)
方法返回地址
-
方法退出的两种方式:
-
正常退出:执行中遇到任意一个方法返回的字节码指令
-
异常退出:执行中遇到异常、且在本方法的异常表中没有搜索到匹配的异常处理器区处理
-
作用:在方法返回时都可能在栈帧中保存一些信息,用于恢复上层方法调用者的执行状态
-
正常退出时,调用者的
PC
计数器的值可以作为返回地址 -
异常退出时,通过异常处理器表来确定返回地址
-
方法退出的执行操作:
-
恢复上层方法的局部变量表和操作数栈
-
若有返回值把它压入调用者栈帧的操作数栈中
-
调整
PC
计数器的值以指向方法调用指令后面的一条指令等
在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部一起称为栈帧信息
方法调用
方法调用是本篇的核心内容,它解决了虚拟机对目标调用方法的确定问题,因为往往一条虚拟机指令要求调用某个方法,但是该方法可能会有重载,重写等问题,那么虚拟机又该如何确定调用哪个方法呢?这就是方法调用要处理的任务。
真实机器如何实现方法调用
- 参数入栈。有几个参数就把几个参数入栈,此时入的是调用者自己的栈
- 代码指针(eip)入栈。以便物理机器执行完调用函数之后返回继续执行原指令
- 调用函数的栈基址入栈,为物理机器从被调用者函数返回做准备
- 为调用方法分配栈空间,每个函数都有自己的栈空间。
JVM实现方法调用
HotSpot 虚拟机基于操作数栈进行方法的解释执行,所有运算的中间结果以及方法参数等等,基本都伴随着出入栈的操作取出或存储。这种机制最大的优势在于,可移植性强。不同于基于寄存器的方法执行机制,对底层硬件依赖过度,无法很轻易的跨平台,但是劣势也很明显,就是同样的操作需要相对更多的指令才能完成。
在Java虚拟机中提供了5条方法调用的相关指令:
- invokestatic: 调用静态方法
- invokespecial: 调用实例构造器方法, 私有方法, 父类方法
- invokevirtual: 调用所有的虚方法
- invokeinterface: 调用所有的接口方法
- invokedynamic: 先在运行时动态解析出调用点限定符所引用的方法, 然后再执行该方法.
虚方法是非虚方法的补集, 什么是非虚方法呢? 能够在编译器就确定将要调用的究竟是哪个方法, 进而将该方法的符号引用 转换为 相应的直接引用的 方法就被称作非虚方法.
我们知道在类加载时, 在相应的类信息中, 存有对应方法的相关信息, 常量池中存有相关直接引用. 在类加载的解析阶段, 即会将这部分的符号引用转换为直接引用.
那么什么方法才满足这种条件呢?
能够被invokespecial 和 invokestatic指令调用的方法, 都是可以在编译器确定的方法, 即静态方法, 私有方法, 父类方法(super.), 实例构造器.
在final方法是个特殊点, 虽然final方法的执行为 invokevirtual, 但它依然属于非虚方法, 不难理解, final方法不能够被重写.
解析调用
静态解析
当一个类初次加载的时候,会在解析阶段完成常量池中符号引用到直接引用的替换。这其中就包括方法的符号引用翻译到直接引用的过程,但这只针对部分方法,有些方法只有在运行时才能确定的,就不会被解析。我们称在类加载阶段的解析过程为「静态解析」。
比如下面这段代码:
Object obj = new String("hello");
obj.equals("world");
复制代码
Object 类中有一个 equals 方法,String 类中也有一个 equals 方法,上述程序显然调用的是 String 的 equals 方法。那么如果我们加载 Object 类的时候将 equals 符号引用直接指向了本身的 equals 方法的直接引用,那么上述的 obj 永远调用的都是 Object 的 equals 方法。那我们的多态就永远实现不了。
只有那些,「编译期可知,运行时不变」的方法才可以在类加载的时候将其进行静态解析,这些方法主要有:private 修饰的私有方法,类静态方法,类实例构造器,父类方法。
其余的所有方法统称为「虚方法」,类加载的解析阶段不会被解析。这些方法的调用不存在问题,虚拟机直接根据直接引用即可找到方法的入口,但是「非虚方法」就不同了,虚拟机需要用一定的策略才能定位到实际的方法,下面我们一起来看看。
特点:
- 是静态过程
- 在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,而不会延迟到运行期再去完成,即编译期可知、运行期不变
适用对象:
private
修饰的私有方法,类静态方法,类实例构造器,父类方法
动态解析
动态类型语言的一个关键特征就是,类型检查发生在运行时。也就是说,编译期间编译器是不会管你这个变量是什么类型,调用的方法是否存在的。例如:
Object obj = new String("hello-world");
obj.split("-");
复制代码
Java 中,两行代码是不能通过编译器的,原因就是,编译器检查变量 obj 的静态类型是 Object,而 Object 类中并没有 subString 这个方法,故而报错。
而如果是动态类型语言的话,这段代码就是没问题的。
静态语言会在编译期检查变量类型,并提供严格的检查,而动态语言在运行期检查变量实际类型,给了程序更大的灵活性。各有优劣,静态语言的优势在于安全,缺点在于缺乏灵活性,动态语言则是相反的。
JDK1.7 提供了两种方式来支持 Java 的动态特性,invokedynamic 指令和 java.lang.invoke 包。这两者的实现方式是类似的,我们只介绍后者的基本内容。
//该方法是我自定义的,并非 invoke 包中的
public static MethodHandle getSubStringMethod(Object obj) throws NoSuchMethodException, IllegalAccessException {
//定义了一个方法模板,规定了待搜索的方法的返回值和参数类型
MethodType methodType = MethodType.methodType(String[].class,String.class);
//查找符合指定方法简单名称和模板信息的方法
return lookup().findVirtual(obj.getClass(),"split",methodType).bindTo(obj);
}
复制代码
public static void main(String[] args){
Object obj = new String("hello-world");
//定位方法,并传入参数执行方法
String[] strs = (String[]) getSubStringMethod(obj).invokeExact("-");
System.out.println(strs[0]);
}
复制代码
输出结果:
hello
你看,虽然我们 obj 的静态类型是 Object,但是通过这种方式,我就是能够越过编译器的类型检查,直接在运行期执行我指定的方法。
具体如何实现的我就不带大家看了,比较复杂,以后有机会单独写一篇文章学习一下。反正通过这种方式,我们可以不用管一个变量的静态类型是什么,只要它有我想要调的方法,我们就可以在运行期直接调用。
分派调用
Q1:什么是静态类型?什么是实际类型?
A1:这个用代码来说比较简便, Talk is cheap ! Show me the code !
Copy//父类
public class Human {
}
Copy//子类
public class Man extends Human {
}
Copypublic class Main {
public static void main(String[] args) {
//这里的 Human 是静态类型,Man 是实际类型
Human man=new Man();
}
}
静态分派
- 依赖静态类型来定位方法的执行版本
- 典型应用是方法重载
- 发生在编译阶段,不由
JVM
来执行
单纯说未免有些许抽象,所以特地用下面的 DEMO
来帮助了解
Copypublic class Father {
}
public class Son extends Father {
}
public class Daughter extends Father {
}
Copypublic class Hello {
public void sayHello(Father father){
System.out.println("hello , i am the father");
}
public void sayHello(Daughter daughter){
System.out.println("hello i am the daughter");
}
public void sayHello(Son son){
System.out.println("hello i am the son");
}
}
Copypublic static void main(String[] args){
Father son = new Son();
Father daughter = new Daughter();
Hello hello = new Hello();
hello.sayHello(son);
hello.sayHello(daughter);
}
输出结果如下:
hello , i am the father
hello , i am the father
动态分派
依赖动态类型来定位方法的执行版本
典型应用是方法重写
发生在运行阶段,由
JVM
来执行单纯说未免有些许抽象,所以特地用下面的
DEMO
来帮助了解
Copypublic class Father {
public void sayHello(){
System.out.println("hello world ---- father");
}
}
//继承 + 方法重写
public class Son extends Father {
@Override
public void sayHello(){
System.out.println("hello world ---- son");
}
}
Copypublic static void main(String[] args){
Father son = new Son();
son.sayHello();
}
输出结果如下:
hello world ---- son
疑惑来了,我们可以看到,JVM
选择调用的是静态类型的对应方法,但是为什么最终的结果却调用了是实际类型的对应方法呢?
当我们将要调用某个类型实例的具体方法时,会首先将当前实例压入操作数栈,然后我们的 invokevirtual
指令需要完成以下几个步骤才能实现对一个方法的调用:
单分派
- 含义:根据一个宗量对目标方法进行选择(方法的接受者与方法的参数统称为方法的宗量)
多分派
- 含义:根据多于一个宗量对目标方法进行选择