天天看点

深入理解Java虚拟机之虚拟机字节码执行引擎运行时栈帧结构方法调用

执行引擎是java虚拟机最核心的组成部分之一。

物理机的执行引擎是建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎是由自己实现的,可以自行制定指令集与执行引擎的结构体系,并且能够执行那些硬件不直接支持的指令集格式。

执行引擎在执行Java代码时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可以两者兼备,甚至可以包含几个不同级别的编译器执行引擎。

运行时栈帧结构

栈帧是用于支持虚拟机进行防腐调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。

深入理解Java虚拟机之虚拟机字节码执行引擎运行时栈帧结构方法调用

在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令只对当前栈帧进行操作。栈帧是随着方法调用而创建,随着方法结束而销毁。

栈帧是线程本地私有的,不可能在一个栈帧中引用另一个线程的栈帧。

局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在方法的Code属性的

max_locals

数据项中确定了该方法所需要分配的局部变量表的最大容量。

局部变量表的容量以变量槽(Slot)为最小单位。一个局部变量可保存一个boolean、byte、char、short、int、float、reference和returnAddress类型的数据。两个局部变量可以保存long、double的数据。

reference类型表示对一个对象实例的引用。一般来说通过这个引用做两点:一是从此引用中直接或间接地查找到对象在Java堆中的数据存放的起始地址索引。二是从此引用中直接或间接查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现Java语言规范中定义的语法约束。

returnAddress类型是为了字节码指令

jsr

jsr_w

ret

服务的,指向了一条字节码指令的地址。

虚拟机使用索引来进行定位访问,索引值从0到局部变量表最大的Slot数量。若访问32位数据类型,索引n就代表了第n个Slot,如果是64位数据类型,则会访问第n个和第n+1个,且不允许采用任何方式单独访问其中的某一个,如果遇到这个情况,虚拟机会在类加载的校验阶段抛出异常。

虚拟机使用局部变量表完成参数值到参数变量表的传递过程的,如果是执行的是实例方法,那局部变量表的第0位Slot默认用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数,其余参数按参数表顺序排列,分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

局部变量表的Slot是可以重用的,如果当前字节码PC计数器的值超过了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。

如果一个局部变量表定义了但是并未赋初始值是不能使用的。

操作数栈

也称为为操作栈,是一个后入先出的栈。最大深度在编译时写入到Code属性的

max_stacks

数据项中。操作数栈每一个元素可以是任意的java的数据类型,32位数据类型所占容量为1,64位的数据类型所占容量为2。

在方法的执行过程中,会有各种字节码指令往操作数栈写入和提取内容,就是出栈/入栈操作。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证。

在概念模型中,两个栈帧作为虚拟机栈的元素,是完全独立的,但是实际中,会令两个栈帧出现一部分重叠,让下面的栈帧部分操作数栈与上面栈帧的部分局部变量表重叠在一起,便于传递数据。

深入理解Java虚拟机之虚拟机字节码执行引擎运行时栈帧结构方法调用

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

符号引用一部分会在类加载阶段或者第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

方法返回地址

只有两种方法可以在方法运行时退出。

  • 第一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口。
  • 第二种方式是,在方法执行过程中遇到异常,并且这个异常没有在方法体内得到处理,无论是java虚拟机内部产生的异常,还是代码中使用

    athrow

    字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。

    以上两种方式,在退出之后都需要返回到方法被调用的位置。

方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器的值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧一般不会保存。

附加信息

虚拟机允许增加一些规范中没有描述的信息到栈帧之中,这部分信息完全取决于具体的虚拟机实现。

一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

方法调用

方法调用阶段的唯一任务是确定被调用方法的版本(调用哪一个方法)。一切方法调用在Class文件里面存储的都只是符号引用,而只有在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

方法在程序真正执行之前有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的,这类方法的调用称为解析。

符合上述“编译器可知,运行期不可变”得方法,主要包括静态方法和私有方法。前者与类型直接关联,后者在外部不可被访问。因此它们适合在类加载阶段进行解析。

java提供5种调用字节码指令方法:

  • invokestatic

    调用静态方法
  • invokespecial

    调用实例构造器方法、私有方法和父类方法
  • invokevirtual

    调用所有的虚方法
  • invokeinterface

    调用接口方法,会在运行时再确定一个实习此接口的对象
  • invokedynamic

    在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

只要能被

invokestatic

invokespecial

指令调用的方法,都可以在解析阶段中确定唯一的版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载时就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法。

虽然final方法是使用

invokevirtual

指令来调用。但是因为被final修饰的方法无法被重载,没有其他版本,所以在虚拟机规范中说明final修饰的方法为非虚方法。

解析调用是个静态过程,在编译器就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用。

分派

静态分派

public class StaticDispatch {

    static abstract class Human {
    }

    static class Man extends Human {
    }

    static class Woman extends Human {
    }

    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();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}//输出结果为:
//hello,guy!
//hello,guy!
           

Human man = new Man();

Human称为变量的静态类型,Man则称为变量的实际类型。

静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

虚拟机在重载时通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译器可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。

编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本只能确定一个“更加合适的”。因为字面量不需要定义,所以字面量没有显式的静态类型,而静态类型只有通过语言上规则去理解和推断。

动态分派

动态分派和重写有着密切的关联。

invokevirtual指令的运行时解析过程大致分为:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找结束;如果不通过,则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

以上为java语言中重写的本质,在运行期间根据实际类型确定方法执行版本的分派过程称为动态分派。

单分派和多分派

方法的接受者与方法的参数统称为方法的宗量。

根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是基于一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

静态分派中,影响虚拟机选择因素有方法名和参数,所以是多分派。

动态分派中,影响虚拟机选择因素只有接受者的实际类型,所以是单分派。

虚拟机动态分派的实现

动态分派是非常频繁的动作,且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此基于性能的考虑,最常用的“稳定优化”手段就是在为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。

public class Dispatch {

    static class QQ {}

    static class _360 {}

    public static class Father {
        public void hardChoice(QQ arg) {
            System.out.println("father choose qq");
        }

        public void hardChoice(_360 arg) {
            System.out.println("father choose 360");
        }
    }

    public static class Son extends Father {
        public void hardChoice(QQ arg) {
            System.out.println("son choose qq");
        }

        public void hardChoice(_360 arg) {
            System.out.println("son choose 360");
        }
    }

    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}
           

代码所对应的方法表

深入理解Java虚拟机之虚拟机字节码执行引擎运行时栈帧结构方法调用

虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口一致,指向父类入口。如果子类重写了方法,则指向子类的入口地址。

具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

虚拟机在条件允许下,还会使用内联缓存和基于“类型继承关系分析技”技术的守护内联两种非稳定的“激进优化”手段来获得更高的性能。

参考来自《深入理解Java虚拟机》