第八章 虚拟机字节码执行引擎 《深入理解java虚拟机》

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/xuchuanliang11/article/details/102614347

运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。

局部变量表

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

在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程,如果执行的是实例方法(非static方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问这个隐含的参数。

类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另一次在初始化阶段,赋予程序员定义的初始值。但是局部变量定义了但是没有赋初始值是不能使用的。

操作数栈

操作数栈是一个后入先出的栈。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。

java虚拟机的解释执行引擎称为基于栈的执行引擎,其中所指的栈就是操作数栈。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

方法返回地址

当一个方法开始执行后,有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值类型将根据遇到何种方法返回指令来决定,这种退出称为正常完成出口

第二种方式是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,称为异常完成出口,异常完成出口退出的方法是不会给上层调用者产生任何返回值。

方法调用

方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用的版本,不涉及到方法的执行过程。

解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载解析阶段、会将其中一部分符号引用转化成直接引用,这种解析成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。实际上就是调用的目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用被称为解析。

主要包括静态方法和私有方法两大类,前者与类型关联,后者外部不可访问,都适合在类加载阶段进行解析,与之对应的java中提供5条方法调用字节码指令:

1.invokestatic:调用静态方法

2.invokespecial:调用实例构造器<init>方法、私有方法和父类方法

3.invokevitual:调用所有的虚方法

4.invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象

5.invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面四条指令的分派逻辑是固话在java虚拟机内部的,而invokedynamic指令的分派逻辑是用户所设定的引导方法决定的。

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造方法、父类方法4类,他们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法称为非虚方法,与之相反的其他方法被称为虚方法。备注:被final修饰的方法虽然被invokevitual指令调用,但是也是非虚方法。

解析调用是一个静态过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及到的符号引用完全转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用(dispatch)调用则可能是静态的也可能是动态的,根据分派的宗数量可分为单分派和多分派。这两类方式两两组合成静态单分派、动态单分派、静态多分派、动态多分派。

分派

静态分派(典型场景是方法重载)

package capter08;

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 man");
    }

    public void sayHello(Woman guy){
        System.out.println("hello woman");
    }

    /**
     * 打印结果是:
     * hello guy
     * hello guy
     * @param args
     */
    public static void main(String[] args){
        StaticDispatch staticDispatch = new StaticDispatch();
        Human man = new Man();
        Human woman = new Woman();
        staticDispatch.sayHello(man);
        staticDispatch.sayHello(woman);
    }
}

上方代码中Human man = new Man();Human称为变量的静态类型,Man是变量的实际类型。静态类型的变化只在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果只有在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

虚拟机在重载时是通过参数的静态类型而不是实际类型作为判断依据。并且静态类型编译期可知,因此,在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human guy)作为调用目标,并把这个方法的符号引用写到man()方法里的两条invokevirtual指令的参数中。

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

动态分派(重写)

package capter08;

/**
 * 动态分派
 */
public class DymicDispatch {
    static abstract class Human{
        protected abstract void say();
    }
    static class Man extends Human{

        @Override
        protected void say() {
            System.out.println("man");
        }
    }

    static class Woman extends Human{

        @Override
        protected void say() {
            System.out.println("woman");
        }
    }

    /**
     * 输出结果
     * man
     * woman
     * woman
     * @param args
     */
    public static void main(String[] args){
        Human man = new Man();
        Human woman = new Woman();
        man.say();
        woman.say();
        man = new Woman();
        man.say();
    }
}

这里不再根据静态类型来决定,而是由于动态类型的不同,导致产生了不同的结果。要从invokevirtual指令的多态查找过程分析。

invokevirtual指令的运行时解析过程大致分为以下几个步骤:

1.找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C

2.如果在类型C中找到与常量中的描述符和简答名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。

3.否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。

4.如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以上方代码中两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程是java语言中重写的本质。

方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

package capter08;

public class Dispatch {
    static class QQ{}
    static class _360{}
    public static class Father{
        public void hardChoice(QQ arg){
            System.out.println("father qq");
        }
        public void hardChoice(_360 arg){
            System.out.println("father 360");
        }
    }
    public static class Son extends Father{
        @Override
        public void hardChoice(QQ arg) {
            System.out.println("son qq");
        }

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

    /**
     * 输出结果
     * father 360
     * son qq
     * @param args
     */
    public static void main(String[] args){
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}

详解上方代码:

编译阶段的选择过程,也就是静态分配过程。这时目标方法上的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果最终产物是产生两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)和Father.hardChoice(QQ)方法的符号引用。因为这是两个宗量进行选择,所以java语言的静态分派属于多分派类型。

再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行Father.hardChoice(QQ)这句代码时,也就是在执行这句代码对应的invokevirtual指令时,由于编译器已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数QQ是腾讯QQ还是奇瑞QQ,因为此时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son,因为只有一个宗量作为选择依据,所以java语言的动态分派属于单分派类型。

静态多分配和动态但分派

基于栈的字节码解释执行引擎

javac编译器完成程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线程的字节码指令流的过程。因为一部分动作是在java虚拟机之外进行的,而解释器在虚拟机的内部,所以java程序的编译就是半独立的实现。

java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,他们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是X86的二地址指令集,我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。

基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要收到硬件的约束。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问频繁的数据放到寄存器中以获取尽量好的性能,这样实现起来也更加简单。主要缺点是执行速度会相对于基于寄存器的指令集要慢一些。

猜你喜欢

转载自blog.csdn.net/xuchuanliang11/article/details/102614347
今日推荐