JVM虚拟机之栈帧与以及方法调用

简介

执行引擎是Java虚拟机最核心的组成部分之一。虚拟机与物理机都具有执行代码的能力。区别在去物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是自己实现的,可以自行定制指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。

本章主要从虚拟机执行引擎的概念模型上来学习和讲解虚拟机的方法调用和字节码执行


栈帧

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

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

局部变量表

局部变量表是用来存放执行目标方法时方法中出现的一些局部变量,这些变量包括了方法入参、方法体内声明的局部变量。
该表的大小在java编译器编译时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。局部变量表的容量以Slot为最小单位,所有不超过32位的属性都只占据一个Slot,64位的属性则占据两个Slot。同时,除了一些显示定义的局部变量以外,还包括方法参数(包括隐藏参数this)、显式异常处理的参数。另外,并不是有多少局部变量就分配多少Slot,因为这个Slot是可以复用的,当某个局部变量超出其作用域时,它的Slot是可以被其他局部变量所使用的。编译器会根据变量的作用域来最终计算出max_locals的大小
注意,由于在局部变量表中以32位为一个单位,因此虚拟机将64位的long和double类型的操作进行的分割,分割成两个32位的读写操作。因此,由于是两次操作,对这两个类型的读写操作将不具备原子性。但是由于栈帧是线程独立的,因此可以不用担心线程切换引发的数据安全问题。

如果是一个普通的实例方法,也就是我们的非static方法,那局部变量表中第0个slot的位置默认是用于传递方法所属的对象实例的引用,在方法体内可以通过this关键字访问到这个隐含的参数,其余参数按照声明顺序排列,再往后就根据局部变量的定义顺序和作用域进行分配。

Slot的复用虽然会为我们带来栈空间的节省,但是也会带来一些副作用 :在某些情况下,Slot的复用会影响垃圾回收的行为。通常,GC只会对一些失去索引的堆中对象进行垃圾回收,但是由于我们的栈中的Slot会在执行过程中指向目标局部对象,因此,被指向的对象就因为存在索引而不会被GC回收,但是Slot的复用使得这件事情产生了一些变化,假设我们的方法内存在某个变量,它的作用域被限制在一个花括号内,那么按照我们的逻辑,一旦该花括号内的代码执行完毕,其中的所有变量应当会因为失去索引而可以被GC回收。但是事实上却不一定,因为我们的局部变量表中的Slot是可以复用的,也就是说我们花括号内声明的变量所占的Slot会被后续声明的变量所复用,但是如果后面并没有变量去复用这个Slot,那就意味着在方法执行完毕之前这个Slot原有的变量会一直占据这个Slot,从而导致明明超出作用域的变量对象无法被GC回收。

操作数栈

操作数栈是一个先入后出的栈,同局部变量表一样,操作数栈的最大深度也在编译时被写入到Code属性的max_stacks数据项中。
当一个方法刚开始执行时,这个方法的操作数栈是空的,会随着各种字节码指令往操作数栈中写入和提取内容,也就是对应我们的出入栈操作。所有的算术操作以及方法参数传递操作都是基于操作数栈来执行的

举个例子,整数加法的字节码指令iadd在运行时操作数栈中最接近栈顶的两个元素已经存入了两个int值,当指令执行时,会将这两个int值出栈然后结果相加入栈。

另外,在我们常见的虚拟机中,不同栈帧之间传递参数的方式就是让两个栈帧之间存在一个重叠区域,也就是将我们需要传递的参数共享给另一个栈帧。从而无需额外的参数复制。
重叠过程如下
在这里插入图片描述

动态连接

每一个栈帧都包含一个指向运行时常量池中栈帧所属方法的引用,持有该引用的目的是为了支持方法调用中的动态连接,看我我前面关于Class文件结构文章的朋友可以知道,Class文件的常量池中存在大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号会在类加载阶段或是第一次使用时转化为直接引用,这种称为静态解析。另一种是在每一次运行期间转化为直接引用,这部分我们在后面的方法调用中进行详细解说。

方法返回地址

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

另一种退出方式是在方法执行过程中遇到异常,并且这个异常没有在方法体内得到处理,这种方式也会导致突出,这种退出方式被称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

无论采取何种方式退出,在方法退出后都需要返回被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复上层方法的执行状态。方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧会保存这个数值。

方法退出的过程就是栈帧出栈的过程,因此战争的出栈是执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者操作数栈中,调整PC计数器的值以指向方法调用指令的后一条指令等。


方法调用

方法调用不等同于方法执行,方法调用阶段的唯一任务就是确定被调用方法的版本,也就是确定要调用哪个方法,暂时还不涉及方法内部的具体运行过程。Class文件的编译过程不包含传统编译中的连接步骤,一切方法调用在Class文件中存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址也就是方法体的直接引用。这个特性给java带来了强大的动态拓展能力,但也使得Java调用过程变得相对复杂起来,需要在类的加载期间,甚至到运行期间才能够确定目标方法的直接引用。

解析

所有的方法调用在Class文件中都是一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用。这种解析能够成立的前提是:方法在程序真正运行前就有一个可确定的调用版本,并且这个版本在运行期间也就是不可变的。也就是说,这种对于具体要调用的方法的确定是在编译期间就已经确定下来了。我们称这种为静态绑定。

在java语言中符合静态绑定的主要包括静态方法和私有方法两大类,前者与类型直接绑定,而后者是不可被外界访问。因此它们的特性决定了他们不会存在其他版本,因此可以在编译器即可确定调用版本。

解析调用是一个静态的过程。而后面所要讲述的分派调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式可以两两组合成静态单分派、静态多分派、动态单分派、动态多分派。

分派

Java的三大特性为封装、继承、多态。本部分的分派调用过程解释的就是我们多态特性的实现方式。

1.静态分派

先看一段代码。代码结构如下,很简单的继承关系,各个类也没有属性和方法。

public class Human {
    
    

}

public class Man extends Human {
    
    

}

public class Woman extends Human {
    
    

}

//主要的操作类
public class StaticCall {
    
    

  public void sayHello(Human person) {
    
    
    System.out.println("human hello!");
  }

  public void sayHello(Man person) {
    
    
    System.out.println("man hello!");
  }

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

  public static void main(String[] args) {
    
    
    StaticCall staticCall = new StaticCall();

    Human human = new Human();
    staticCall.sayHello(human);

    Human man = new Man();
    staticCall.sayHello(man);

    Human woman = new Woman();
    staticCall.sayHello(woman);
  }

}

执行结果
在这里插入图片描述
在上面代码中存在这样一个语句

Human man = new Man();

我们将上面代码中的“Human”称为变量的静态类型,后面的“Man”则称为变量的实际类型,这两者在程序中都可以发生一些变化。区别是静态变量的类型不会发生变化,并且最终的静态类型在编译器是可知的;而实际类型需要在运行期才能够确定,编译器在编译程序时并不知道一个对象的实际类型是什么。

我们看下面代码

//实际类型的变化
Human man = new Man();
man = new Woman();

//静态类型的变化
staticCall.sayHello((Man)man);
staticCall.sayHello((Woman)man);

通过上面的例子可以看出,静态类型的变化指的是变量类型的变化,而不是变量所指向的实例对象的实际类型的变化。而实际类型的变化则是建立在变量类型不变,而赋予不同类型的实例对象的变化。

我们的重载方法是通过静态类型来确定方法的调用版本。因此上面的例子中虽然我们传入的变量的实际类型不是Human,但是被调用的方法却是以Human类型为参数的版本。因此,可以获知我们的方法重载是属于静态分配。

2.动态分派

动态分派与我们的重写有着紧密的联系。我们看下面的代码。

class Human {
    
    
  public void sayHello() {
    
    
    System.out.println("hello,human!");
  }
}

class Man extends Human {
    
    
  @Override
  public void sayHello() {
    
    
    System.out.println("hello,Man!");
  }
}

class Woman extends Human {
    
    
  @Override
  public void sayHello() {
    
    
    System.out.println("hello,Woman!");
  }
}

//主要操作类
public class DynamicCall {
    
    

  public static void main(String[] args) {
    
    
    Human human = new Human();
    human.sayHello();

    Human man = new Man();
    man.sayHello();

    Human woman = new Woman();
    woman.sayHello();
  }

}

运行结果
在这里插入图片描述
通过上面例子我们可以发现,我们sayHello()方法的版本的确定并不依赖于我们的静态类型。而是根据我们的动态的对象实际类型确定了所要调用的版本。

那么虚拟机是如何根据实际类型来分派方法执行版本呢?我们使用javap来输出这段代码对应的字节码来寻找答案。
在这里插入图片描述
我们看到上面字节码中的第21行和33行,通过源代码可知,这两行代码分别对应我们的Man类型和Woman类型,但是在21和33行,调用的方法却指向了Human类型的sayHello方法。但是事实上这两句指令最终执行的却并不是目标方法。原因就需要从invokevirtual指令的多态查找过程开始说起。该指令的运行时解析过程大致分为以下几个步骤:

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

上述过程就是我们Java语言中方法重写的本质,我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

3.单分派与多分派

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

我们看下面代码

public class Demo4 {
    
    
    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());
        /**
         * father choose 360
         * son choose qq
         */
    }

}

上面代码中调用了两次hardChoice,调用的结果已经很明显了。
首先我们看看静态分派的结果,编译期选择方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是_360,这次选择的结果产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(_360)以及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以java的静态分派属于多分派类型。
再看看动态分派,在执行son.hardChoice(new QQ())这句代码时,由于编译器在编译时已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心调用者的静态类型以及任何参数类型,而是会在静态分派的基础上根据调用者的实际类型去进行方法的选择。因此java中的动态分派属于单分派类型。

4.虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机的实际实现中基于性能的考虑,会有其他的应对策略,其中最常用的会在类的方法区中建立一个虚方法表,使用虚方法表来代替元数据查找以提高性能。下面是上面代码实例对应的虚方法表结构

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表里的地址入口与父类相同方法的入口地址是一致的,都指向父类的实现入口。如果发生重写,那么在子类方法表中的地址将会替换为指向子类实现版本的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。

猜你喜欢

转载自blog.csdn.net/qq_33905217/article/details/111686361
今日推荐