JVM 栈帧,静态、动态分派以及虚方法表

1,536 阅读8分钟

前言

  • 学了类加载和字节码文件,是时候来学习JVM是如何执行代码的了。

栈帧(stack frame)

  • 栈帧是一种用于帮助虚拟机执行方法调用与方法执行的数据结构
  • 一个线程一个栈帧,不存在并发问题。
  • 栈帧本身是一种数据结构,封装了方法的局部变量表动态链接信息方法的返回地址以及操作数栈等信息。
  • 有些符号引用是在类加载阶段或是第一次使用时就会转换为直接引用,这种转换叫做静态解析;另外一些符号引用则是在每次运行期转换为直接引用,这种转换叫做动态链接,这体现为Java的多态性。
    • 局部变量表在字节码层面已经做出解读了。
    • 方法的返回地址简单说就是方法执行完要回到调用的地方。
    • 操作数栈简单说就是进行计算的临时数据存放区。
    • 符号引用以一组符号来描述所引用的目标。
    • 直接引用直接指向目标的指针或相对偏移量或一个能间接定位到目标的句柄。
  • 顺便一提,JVM变量槽Slot的有关概念。

基于栈和寄存器的指令集

代码的执行分解释执行和编译执行

  • 现代JVM在执行Java代码的时候,通常都会将解释执行与编译执行二者结合起来进行。
  • 所谓解释执行,就是通过解释器来读取字节码,遇到相应的指令就去执行该指令。
  • 所谓编译执行,就是通过即时编译器―(Just In Time,JIT)将字节码转换为本地机器码来执行。
  • 现代JVM会根据代码热点来生成相应的本地机器码。
    • 热点代码对于程序来说,通常只有一部分代码会被经常执行,而应用的性能主要取决于这些代码执行得有多快。这些关键代码段被称为应用的热点代码,代码执行得越多就被认为是越热。

基于栈的指令集与基于寄存器的指令集之间的关系

  • JVM执行指令时所采取的方式是基于栈的指令集。
  • 基于栈的指令集主要的操作有入栈与出栈两种。
  • 基于栈的指令集的优势在于它可以在不同平台之间移植,而基于寄存器的指令集是与硬件架构紧密关联的,无法做到可移植。
  • 基于栈的指令集的缺点在于完成相同的操作,指令数量通常要基于寄存器的指令集数量要多;
  • 基于栈的指令集是在内存中完成操作的,而基于寄存器的指令集是直接由CPU来执行的,它是在高速缓冲区中进行执行的,速度要快很多。虽然虚拟机可以采用一些优化手段,但总体来说,基于栈的指令集的执行速度要慢一些。

基于栈的指令执行方式

  • 首先,肯定有出栈入栈操作。
  • 将需要操作的数据先入栈,在需要使用的数据的时候出栈,比如变量赋值操作,先将数入栈再赋值,出栈的数会被放到对应的变量槽Slot。
  • 加减乘除,也类似。会有两个数入栈,在计算时会将两个数出栈,进行运算。
  • 字节码中方法属性中的stack就是指的种stack; image.png

方法调用

方法调用的指令

  • invokeinterface:调用接口中的方法,实际上是在运行期决定的,决定到底调用实现该接口的哪个对象的特定方法。
  • invokestatic:调用静态方法。
  • invokespecial:调用自己的私有方法、构造方法()以及父类的方法。
  • invokevirtual:调用虚方法,运行期动态查找的过程。
  • invokedynamic:动态调用方法。

静态解析的4种情形:

静态解析就是在编译或者类加载阶段就能确定调用关系,且是具体准确的调用关系。

  • 静态方法
  • 父类方法
  • 构造方法
  • 私有方法(无法被重写)

以上4类方法称作非虚方法,它们是在类加载阶段就可以将符号引用转换为直接引用的
也就是说可以被 invokestaticinvokespecial 调用的方法可以被静态解析。


  • invokestatic 为例 image.png

方法的静态分派

还是先从代码说起,与方法重载有关

public class StaticDispatchTest {
     //方法重载,是一种静态行为,编译期就可以确定
     public void test(Animal animal){
         System.out.println("Animal");
     }
     public void test(Dog dog){
         System.out.println("Dog");
     }
     public void test(Cat cat){
         System.out.println("Cat");
     }

    public static void main(String[] args) {
        StaticDispatchTest staticDispatch = new StaticDispatchTest();
        Animal animalDog = new Dog();//变量声明为Animal,引用指向Dog实例
        Animal animalCat = new Cat();//变量声明为Animal,引用指向Cat实例
        staticDispatch.test(animalDog);//调用重载的方法
        staticDispatch.test(animalCat);//调用重载的方法
     }
}
class Animal{

}
class Dog extends Animal{

}
class Cat extends Animal{

}
  • 结果

image.png

说明

    Animal animalDog = new Dog();
  • 首先,animalDog 被声明为 Animal 类,但它指向的是 Dog 实例。也就是说 animalDog 静态类型就是 Animal 类。
  • 其本质还是 Animal 类,它是静态的不变的,就算进行类型转换 (Dog)animalDoganimalDog 还是不变的。类型转换是生成了新东西,并不是原地将 animalDog 类型给转换了。
  • 对于方法的重载,方法的参数是以变量的静态类型为基准,不管你指向的引用是什么类型。
    • 所以上述代码输出的是 Animal,虽然参数的引用分别指向 DogCat 实例。
  • 总之,我们可以得出这样一个结论:变量的静态类型是不会发生变化的,而变量的实际类型则是可以发生变化的(多态的一种体现),实际类型是在运行期才可以确定的。

  • 通过字节码也可以验证,看看调用的是哪个方法。

image.png

  • 如果上面的代码改成
staticDispatch.test((Dog) animalDog);
staticDispatch.test(animalCat);
  • 结果

image.png

  • 再看看字节码

image.png

方法的动态分派

还是先从代码说起,与方法重写有关

public class DynamicDispatchTest {
    public static void main(String[] args) {
        Fruit apple = new Apple();//声明为Fruit,引用指向子类
        Fruit orange = new Orange();
        apple.test();
        orange.test();
        apple = new Orange();//修改引用
        apple.test();
    }
}
class Fruit{
     public void test(){
         System.out.println("Fruit");
     }
}
class Apple extends Fruit{
    @Override//重写
    public void test(){
        System.out.println("Apple");
    }
}
class Orange extends Fruit{
    @Override//重写
    public void test() {
        System.out.println("Orange");
    }
}
  • 结果

image.png

  • 再来看看字节码

image.png

  • 方法的动态分派涉及到一个重要概念:方法接收者。
  • invokevirtual 字节码指令的多态查找流程 :
    • 先在操作数栈顶,找到对象的实际类型,不是静态类型。
    • 然后去常量池找匹配的名称和描述符,再检查访问权限(ACC_PUBLIC之类的),如果一切符合且正常,就会返回目标方法的直接引用
  • 上面的字节码中的符号引用Fruit.test,在运行期间通过 invokevirtual 指令的多态查找步骤就可以确定每个符号引用的直接引用。从而准确的运行实际类型的对应方法。
  • 总之,就是将符号引用转换为直接引用。

虚方法表

每个类都有虚方法表

  • 针对于方法调用动态分派的过程,虚拟机会在类的方法区建立一个虚方法表的数据结构(virtual method table,简称vtable)。
  • 针对于invokeinterface指令来说,虚拟机会建立一个叫做接口方法表的数据结构(interface method table,简称itable)。

虚方法表的作用

  • 在动态分派过程中,有一个步骤是查找对应实际类型的对应方法,为了快速查找的目的,而引入的虚方法表。
  • 在操作数栈中找到对应对象后回去虚方法表查找真正被调用的方法。
  • 在虚方法表中查找方法有些注意点。
    • 如果子类没有重写父类方法,就会去父类的虚方法表中查找,对应方法,节省了空间。
    • 对于顶层父类Object也是,如果没有重写,就会去Object中查找对应方法。
    • 子类的重写的方法和父类中的方法在字节码层面方法索引一般来说是一样的,如果在子类找到方法test(),其索引是5,发现不是要调用的方法,而是要调用父类的test(),就会直接去父类方法索引为5的地方查找。(提升了查找效率)
  • 虚方法表是在类加载阶段中的连接阶段进行的

image.png

说明一些问题

理解一下静态与动态

  • 一个有语法错误的代码
class Animal{
    public void test(){

    }
}
class Dog extends Animal{
     public  void test(){

     }
     public void testInDog(){

     }
}
class Test{
    public static void main(String[] args) {
        Animal animalDog = new Dog();
        animalDog.testInDog();
    }
}
  • 错误 image.png

你可能会认为Dog里面明明写了testInDog(),而且还是new Dog(),为什么说找不到?

从字节码指令中获取解释

image.png

小结

  • 方法调用有静态、动态分派。
  • 方法调用在静态阶段(编译)以声明的静态类型为准,不管你符号引用指向的是哪个实例对象。
  • 为了确定调用的到底是哪个类中的方法,在运行期间会进行动态查找对应方法。
  • 为了快速找到的对应方法,每个类都有虚方法表,以便提升查找效率。