JVM-5.字节码执行引擎

时间:2023-01-20 15:16:42
一、概述
二、栈帧结构
三、方法调用
四、方法执行
 
 
 
一、概述
虚拟机与物理机
虚拟机是一个相对于物理机的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
 
概念模型与实现(解释执行/编译执行)。
从模型(外观)看:输入字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
从实现看:可能解释执行,可能编译执行,可能都有,甚至可能有多个级别的编译器。
本文从模型角度讲解虚拟机的方法调用和字节码执行。
 
 
 
二、栈帧结构
帧栈是JVM运行时数据区域中Java虚拟机栈的元素(栈帧不是栈,而是栈的元素)。每一个方法从调用开始到执行结束,对应一个栈帧在虚拟机栈里入栈和出栈的过程。
每个栈帧都包括局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。
栈帧的大小在编译器已经确定(包括局部变量表、操作数栈的大小),并写入到方法表的Code属性中。
当前栈帧:在活动线程中:只有栈顶的栈帧有效,称为当前栈帧,对应的方法称为当前方法(但是调用链上的各个方法都处于执行状态)。执行引擎运行的所有字节码指令只针对当前栈帧。
 
局部变量表
1、作用:存放方法参数和方法内定义的局部变量
2、Slot(编译器确定容量大小)
容量以Slot为最小单位;VM规范没有指明Slot大小,只是说能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据;这个说明很有导向性,目前基本是32位(64位也有,如在一些64位虚拟机中,此时boolean到float要对齐补白),但是未来不好说。下面按照32位Slot大小进行说明。
(1)从boolean到float,可以按照Java中对应数据类型的概念去理解(仅仅是理解,Java语言与Java虚拟机中的基本数据类型是有本质区别的)。
(2)reference,VM没有说明其长度或结构,但是有两点要求:可以通过reference查找到对象的地址以及对象所属类型在方法区的地址;reference长度与虚拟机位数以及是否使用指针压缩优化有关。
(3)returnAddress为jsr、jsr_w和ret指令服务,指向一条字节码指令的地址,以前常用于异常处理,现在很少使用。
(4)Java中明确的64位数据类型,是long和double(reference可能32可能64);虚拟机以高位对齐方式分配2个Slot空间。注意,这里是线程私有数据,不存在线程安全问题。
3、定位:通过索引(从0开始);对于32位数据,索引n代表使用第n个Slot;对于64位数据,索引n使用第n和n+1个Slot。对于64位的两个Slot,决不能单独引用,否则会抛异常。
4、局部变量表赋值
(1)对于实例方法,索引0对应的是实例对象,即this。
(2)然后,按照参数表排列顺序分配Slot。
(3)最后,按照方法内部变量的定义顺序和作用域分配Slot。
5、Slot复用:随着局部变量作用域的结束,其Slot可以被后面定义的局部变量复用。
下面与之相关的例子很有意思;这是手动置null有效果的极少见的情况(对象很大、后面持续时间很长、调用次数不足以进行JIT等),但是不应对手动置null有所多依赖,还是以作用域控制进行变量回收控制比较优雅,一方面是因为这种情况极少,另一方面是因为如果进行了JIT会有一些优化。
public static void f(){
    byte[]placeholder=new byte[64*1024*1024];
    System.gc();//不会回收
}
public static void f(){
    {
        byte[]placeholder=new byte[64*1024*1024];
    }
    System.gc();//依然不会回收,因为那个Slot没有被覆盖,引用还在
}
public static void f(){
    {
        byte[]placeholder=new byte[64*1024*1024];
    }
    int a = 0;
    System.gc();//回收,因为那个Slot被a覆盖
}
6、初始化:局部变量没有准备阶段(类变量有),如果使用之前没有赋值,编译器会报错;如果手动修改字节码,在加载类的字节码校验阶段也会发现导致加载失败。
 
操作数栈
1、编译期确定大小;32位占1个64位占2个
2、操作数栈中元素的数据类型必须与字节码指令的序列严格匹配:编译时要严格保证;类加载时的字节码校验阶段也要校验。
3、优化:在模型中栈帧之间彼此独立;但是在实现中,经常让下面的栈帧的部分操作数栈与上面的栈帧的部*部变量表重叠在一起,在方法调用时可以共享,不用复制数据。
 
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
 
方法返回地址
方法开始执行后,有2种方法可以退出:
1、正常完成出口:遇到返回的字节码指令。
2、异常完成出口:遇到方法内部没有处理的异常(可能是虚拟机内部抛出,也可能是通过athrow指令抛出)。
无论哪种方法,退出后都需要返回到方法被调用的位置;方法返回地址存储的就是调用者的PC计数器的值(个人认为);如果是正常退出,则可以通过方法返回地址找到;如果是异常退出,返回地址通常通过异常处理表来确定。
方法退出相当于把当前栈帧出栈,可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
 
附加信息:虚拟机自定义,如调试相关的信息。
 
 
 
三、方法调用
1、概述
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本,暂时还不涉及方法内部的具体运行过程。
 
Class文件的编译过程不包括传统编译中的连接步骤,因此一切方法调用指令以Class文件常量池中指向方法的符号引用作为参数,而不是直接引用(内存地址)。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
 
2、解析
在类加载的解析阶段,将一部分符号引用转化为直接引用,这类方法的调用称为解析;要求方法在程序运行前有可确定的调用版本,且该版本在运行期间不可变。
 
在Java虚拟机里面提供了5条方法调用字节码指令,分别如下。
invokestatic:调用静态方法。
invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
invokevirtual:调用所有的虚方法。非私有的final的方法也用invokevirtual调用,但不是虚方法。
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
 
invokestatic和invokespecial调用的方法称为非虚方法,可以解析;其他方法称为虚方法(final除外)。
解析一定是静态的:虽然操作是在类加载时进行的,但是在编译期间已经能够确定了。【静态和动态:如果在编译期间能够完全确定,是静态;要在运行期间才能确定,是动态】
 
3、分派
分派可能是静态可能是动态,依据宗量数可能是单分派可能是多分派。
 
重载经典问题
public class Test {
    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();
        Test t = new Test();
        t.sayHello(man);//hello,guy!
        t.sayHello(woman);//hello,guy!
    }
}
abstract class Human {}
class Man extends Human {}
class Woman extends Human {}
 
 
静态/外观类型和实际类型
Human man = new Man();
//实际类型发生变化
man = new Woman();
//静态类型发生变化
o.sayHello((Man)man);
o.sayHello((Woman)woman);
Line1中,Human是静态类型,Man是实际类型;二者都可以发生变化。区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会改变,而且静态类型在编译期都是可知的;实际类型变化的结果在运行期才可以确定,编译时并不知道真正的类型是什么。
 
静态分派与重载
虚拟机(准确说是编译器)在重载时通过参数的静态类型而不是实际类型作为判定依据,而静态类型在编译期可知,因此,在编译阶段,编译器会根据参数的静态类型选择使用哪个重载版本,并将该方法的符号引用写到invokevirtual指令的参数中。
在选择重载版本时,如果没有完美契合的,会找最合适的重载版本;如参数为'a',则部分方法参数类型匹配的优先级如下:char>int>long>Character>Serializable/Comparable<Character>>Object>char...,永远不会匹配Integer。
 
invokevirtual指令的运行时解析过程大致分为以下几个步骤:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C(在invokevirtual之前一般都有入栈操作,如aload)。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
3)否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
 
动态分派与重写
在运行期根据实际类型确定方法执行版本的分派过程,称为动态分派。多态/重写方法的确定过程,是动态分派的过程。
 
动态分派的实现
调用虚方法(接口方法)时,一般不会真的搜索;稳定优化手段是,在类的方法区建立虚方法表(vtable)(接口方法表itable同理);vtable中存放着各方法的实际地址,如果子类中没有重写,则指向父类实现的该方法地址,如果重写了则指向重写版本。
vtable的初始化一般在类加载的连接阶段中的初始化子阶段进行,在准备了类的变量初始值后进行。
 
5、厘清解析、静态分派和动态分派
1)静态分派,编译时执行,是根据方法的接收者和参数确定方法的符号引用的过程;接收者相同参数不同则为重载;所有方法都会有静态分派的过程。
2)解析和动态分派,运行时执行,是根据方法的接收者将符号引用转化为直接引用的过程;接收者不同则为重写;非虚方法解析,虚方法动态分派。
3)静态分派和解析在编译期可以确定,因此是静态过程。
4)静态分派属于多分派类型(接收者和参数2个宗量),动态分派属于单分派类型(接收者1个宗量)。
5)Java并没有直接变为动态语言的迹象,而是通过内置动态语言(如JavaScript)执行引擎的方式来满足动态性的需求(反射也算?)。但是Java虚拟机层面上则不是如此,JDK 1.7中新增的invokedynamic指令也成为了最复杂的一条方法调用的字节码指令【这里到底是想说动态语言还是动态类型语言呢,不懂】。
 
6、动态类型语言支持
Java是静态类型语言,但是JVM可以跑动态类型语言,如Jython、JRuby等已可以运行;invokedynamic字节码指令、java.lang.invoke包等都可以在虚拟机层面上提供动态类型支持;略。
 
 
 
四、方法执行
本章只介绍解释执行时,执行引擎是如何工作的。
 
基于栈与基于寄存器的指令集
基于栈:指令是零地址指令,依赖操作数栈进行操作;优点:可移植(寄存器毕竟由硬件提供),代码相对紧凑(没有参数),编译器实现简单(不需要考虑空间分配问题,在栈上完成);缺点:执行速度慢(因此所有物理机指令集基本上都是基于寄存器的),一方面因为指令多(使得代码紧凑这个有点大打折扣),另一方面因为访问内存比访问寄存器慢,尽管有一些优化措施,比如说把访问最频繁操作映射到寄存器中,但是治标不治本。
基于寄存器:指令依赖寄存器进行工作。
 
举例:1+1
#栈
iconst_1
iconst_1
iadd
istore_0
#寄存器
mov eax,1
add eax,1
 
Java编译器输出的指令流,基本上是基于栈的(个别字节码指令会带有参数)。
 
基于栈的解释器执行过程:很简单,略。
 
 
 
参考:《JVM》第8章