JVM栈帧以及方法调用

230 阅读14分钟

Java虚拟机在执行字节码指令时通常由解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择

从外观上看,所有Java虚拟机的执行引擎输入、输出都是一致的:输入字节码二进制流,处理字节码解析过程,输出执行结果

运行时栈帧结构

栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息,以及一些附加信息(部分虚拟机实现中包含)

栈帧的概念结构如下:

栈帧概念图

位于栈顶的方法是运行中的方法,所属的栈帧陈伟当前栈帧。

执行引擎所执行的所有字节码指令都只针对于当前栈帧进行操作

局部变量表

局部变量表用于存放方法参数和方法内部定义的局部变量

在编译阶段,局部变量表的大小就已经确定,就在方法的Code属性的max_locals数据项。该数据项确定了该方法所需分配的局部变量表的大小

局部变量表的容量以变量槽为最小单位。《Java虚拟机规范》中并未明确指出一个变量槽应占用的内存大小,只是导向性的说每个变量槽都应该能够存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的8种数据类型。这8种数据类型都可以使用32位或更小的物理内存来存放。在此,可以简单的理解为:一个插槽占用的内存为32位,即使在64位机上,使用64位内存去实现一个变量槽,虚拟机也需要使用对齐和补白的手段让变量槽在外观上和32位机中一致

在上面提到的8种数据类型中,reference类型表示为对一个对象的实例引用。虚拟机可以通过这个引用查找到对象在Java堆中的数据存放的起始地址或索引;可以根据引用直接或间接地查找到对象所属数据类型在方法区中存储的类型信息。

对于64位数据类型(long、double),Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽。对于定义在局部变量表中的64位数据类型的操作并不会引起数据竞争和线程安全问题。

Java虚拟机通过索引的方式使用局部变量表,索引值从0开始。对于32位类型,索引N则代表使用第N个局部变量,对于64位类型,访问第N个变量会同时使用N和N+1个变量槽。《Java虚拟机规范》明确要求:对于64位数据类型的两个变量槽,不能单独访问其中的某一个

当一个方法被调用时,Java虚拟机会使用局部变量表来完成实参到形参的传递。如果执行的是实例方法,那局部变量表的0号索引则为 this对象的索引值。

为了节省栈帧所用空间,局部变量表中的变量可以重用。在一个方法中,如果PC计数器超出了某个变量的作用域,则变量的变量槽可交给其他变量重用。

操作数栈

操作数栈(Operand Stack)可以用于存放各种计算的中间变量。栈的最大深度在Code属性的max_stacks数据项中定义。操作数栈的每个元素都可以是Java中的任意数据类型。32位数据类型所占栈容量为1,64位数据类型所占栈容量为2。

当一个方法开始执行时,操作数栈是空的,执行过程中,由字节码指令对操作数栈进行入栈和出栈操作。Java虚拟机的解释执行引擎被称为“基于栈的执行引擎”,这里的栈也即是操作数栈

字节码指令对操作数栈的操作必须与栈中元素的数据类型严格匹配,在编译和校验阶段需要去进行验证。

在概念模型中,两个不同的栈帧作为不同方法的虚拟机栈元素,是完全相互独立的。但是在大多的虚拟机实现中会进行一些优化处理,令两个栈帧出现一部分重叠。如下图

栈帧数据共享

动态连接

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

image-20221002164412202

字节码中方法调用就以常亮时中指向方法的符号引用作为参数。这些符号运动一部分会在类加载阶段或第一次使用时就被转化为直接引用,这种转化方式被称为静态解析。另一部分将在每一次运行期间都转化为直接引用,这部分就称为动态链接。

方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法

第一种是执行引擎遇到任意一个方法返回的字节码指令,这时候若有返回值将把返回值传递给上层调用者,这种退出方式称为“正常调用完成”

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

方法返回地址存放了调用该方法的PC寄存器的值

  • 当方法正常退出时,上层方法的PC计数器的值即可作为返回地址,指示下一条要执行的指令

  • 当方法异常退出时,返回地址需要通过异常处理器表确定

方法调用

方法调用阶段的唯一任务就是确定被调用方法的版本(即确定调用哪个方法)

解析

在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序运行之前就有一个可确定的调用版本,且这个方法的调用版本在运行期间不可改变。这类方法的调用呗称为解析

Java中符合“编译期可知,运行期不可变”的主要有静态方法私有方法

在JVM中支持以下5种方法调用字节码指令,分别是:

  • invokestatic:用于调用静态方法
  • invokespcial:用于调用实例构造器<init>()方法、私有方法和父类中的方法
  • invokevirtual:用于调用所有的虚方法以及被final修饰的方法
  • invokereference:用于调用接口方法,会在运行时在确定一个实现该接口的对象
  • invokedynamic:该指令的分派逻辑由用户设定的引导方式决定,主要用于lambda表达式以及stream,用于支持动态语言如JRuby

只要能被invokestaticinvokespecial调用的方法以及被final修饰的方法在类加载阶段就可转为直接引用。这些方法统称为“非虚方法”

方法调用演示代码如下:

public class MethodDispatch {
    public static void sayHello() {

    }
    abstract static class Animal {
        public abstract void walk();
    }
    static class Dog extends Animal{
        @Override
        public void walk() {
            System.out.println("Dog walk");
        }
    }
    static class Cat extends Animal {
        @Override
        public void walk() {
            System.out.println("Cat walk");
        }
    }

    public static void main(String[] args) {
        MethodDispatch.sayHello(); // invokestatic
        Animal animal = new Dog();
        animal.walk(); // invokevirtual
    }
}

对应的main方法的code属性如下:

 0 invokestatic #2 <MethodDispatch.sayHello : ()V>
 3 new #3 <MethodDispatch$Dog>
 6 dup
 7 invokespecial #4 <MethodDispatch$Dog.<init> : ()V>
10 astore_1
11 aload_1
12 invokevirtual #5 <MethodDispatch$Animal.walk : ()V>
15 return

分派

解析调用是一个静态过程,在编译期间就可完全确定。 另一种主要的方法调用形式就是:“分派”,分派可能是静态的,也可能是动态的,按照分派依据的宗量可划分为单分派和多分派。这两种分派方式两两组合即可构成四种分派组合情况。

静态分派

静态分派最典型的应用表现就是方法重载。静态分派指的是在编译期间进行的方法选择,通常以方法名称,方法接收者和方法参数的静态类型来作为方法选择的依据。这些可以静态分派的方法一般都具有“签名唯一性”的特点(签名只考虑参数的静态类型而不管参数的实际类型),即不会出现相同签名的方法,因此可以在编译期就实现方法确定。主要出现在Java中的虚方法中

下面代码有助于对静态分派的理解

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

对上面代码反编译之后可以看到

    24: aload_3
    25: aload_1
    26: invokevirtual #13                 // Method sayHello:(LStaticDispatch$Human;)V
    29: aload_3
    30: aload_2
    31: invokevirtual #13                 // Method sayHello:(LStaticDispatch$Human;)V
    34: return

在编译期,就已经确定了代码的版本。方法重载版本的参数的选择也就是依据静态类型

对于代码Human man = new Man(),Human就是静态类型或外观类型,而Man则是实际类型或运行时类型。静态类型在编译期可知,而实际类型在编译期不可知。如

Human human = (new Random()).nextBoolean() ? new Man() : new Woman();

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派

动态分派

Java语言中的动态分派与方法重写有着密切联系。动态分派指方法运行时版本在运行时才能最终确定。如下代码

public class DynamicDispatch{
    static abstract class Human{
        protected abstract void sayHello();
    }
    static class Man extends Human{
        @Override
        protected void sayHello()){
        System.out.println("man say hello");
        }
    }
    static class Woman extends Human{
        @Override
        protected void sayHello(){
        System.out.println("woman say hello");
        }
    }
    public static void main(String[]args){
        Human man=new Man();
        Human woman=new Woman();
        man.sayHello();
        woman.sayHello();
        man=new Woman();
        man.sayHello();
    }
}

代码反编译main()方法的字节码如下

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #2                  // class DynamicDispatch$Man
         3: dup
         4: invokespecial #3                  // Method DynamicDispatch$Man."<init>":()V
         7: astore_1
         8: new           #4                  // class DynamicDispatch$Woman
        11: dup
        12: invokespecial #5                  // Method DynamicDispatch$Woman."<init>":()V
        15: astore_2
-------------------------------下面是执行部分
        16: aload_1
        17: invokevirtual #6                  // Method DynamicDispatch$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #6                  // Method DynamicDispatch$Human.sayHello:()V
        24: new           #4                  // class DynamicDispatch$Woman
        27: dup
        28: invokespecial #5                  // Method DynamicDispatch$Woman."<init>":()V
        31: astore_1
        32: aload_1
        33: invokevirtual #6                  // Method DynamicDispatch$Human.sayHello:()V
        36: return

在上面字节码的17,28,33行的方法调用都是采用的invokevirtual指令,《Java虚拟机规范》中对此指令的解析过程大致分为以下几步:

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

单分派与多分派

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

由于java的静态分派需要同时考虑方法接收者和方法参数的静态类型,某种层度上而言是考虑了两种宗量,尽管没有涉及任何实际类型,依然可以从行为上勉强理解为”多分派“。

对于动态分派,在编译器已经可以确定方法参数的静态类型,所以只需要考虑方法的接收者。

因此,如今的Java语言是一门静态多分派,动态单分派的语言

虚拟机动态多分派的实现

动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时,在接收者类型的方法原数据中搜索合适的目标方法。因此,Java虚拟机真正运行时一般不会频繁的去反复搜索类型元数据,常见的优化手段是为类型在方法区中建立一个虚方法表

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类继承的方法和父类的方法的地址是一致的,都指向父类的实现入口。反之,如果方法被重写,则指向子类的实现入口。

image-20221003170732098

动态类型语言支持

在了解JVM对动态类型语言的支持前需要了解以下动态类型语言与静态类型语言的区别

动态类型语言

动态类型语言的关键特征是它的类型检查的主题过程是在运行期间而不是编译期进行的。

动态类型的另一个核心特征就是“变量无类型而变量值才有类型”

Invokedynamic指令

在JDK7时加入了java.lang.invoke包,主要目的是在之前单纯依靠符号引用来确定调用方法的这条路外,提供一种新的动态确定目标方法的机制,称为“方法句柄”

从 Java 1.0 到现在,invokedynamic 是第一个新加入的 Java 字节码,它与已有的字节码 invokevirtual、invokestatic、invokeinterfaceinvokespecial 组合在了一起。已有的这四个操作码实现了 Java 开发人员所熟知的所有形式的方法分派(dispatch):

  • invokevirtual——对实例方法的标准分派
  • invokestatic——用于分派静态方法
  • invokeinterface——用于通过接口进行方法调用的分派
  • invokespecial——当需要进行非虚(也就是“精确”)分派时会用到

新增的invokedynamic指令用于处理新型的方法分派——它的本质是允许应用级别的代码来确定执行哪一个方法调用,只有在调用要执行的时候,才会进行这种判断。

它的目的在于由用户代码通过方法句柄API在运行时确定方法的调用版本,同时避免反射带来的性能惩罚和安全问题。invokedynamic 所宣称的目标就是一旦该特性足够成熟,它的速度要像常规的方法分派(invokevirtual)一样快。

方法句柄简介

要让 invokedynamic 正常运行,一个核心的概念就是方法句柄(method handle),它代表可以从调用点(Call Site)进行调用的方法。每一处含有invokedynamic指令的位置都被称作“动态调用点”,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是JDK7新加入的CONSTANT_InvokeDynamic_info常量,从这个常量中可以得到3项信息:引导方法(Bootstrap Method,BSM,该方法放在新增的BootstrapMethods属性中)、方法类型(MethodType)和方法名称。

当解释器遇到一条invokedynamic指令时,BSM会被调用,返回一个包含了方法句柄的CallSite对象,这个对象表明了调用点要实际执行哪个方法。

理解方法句柄的一种方式就是将其视为以安全、现代的方式来实现反射的核心功能,在这个过程会尽可能地保证类型的安全。invokedynamic 需要方法句柄,另外它们也可以单独使用。

方法类型

一个Java方法可以视作由四个基本部分组成

  • 方法名称
  • 方法签名(包含返回值)
  • 定义它的类
  • 实现方法的字节码

如果要引用一个方法,可以使用反射来完成,在JDK7之后,将使用方法句柄的方式来完成。方法句柄首先需要的一个构建块就是表达方法签名的方式,以便于查找。这个角色是由 java.lang.invoke.MethodType 类来完成的,它使用一个不可变的实例来代表签名。

要获取MethodType,可以使用MethodType中的工厂方法methodType()fromMethodDescriptorString()例如:

// methodType()方法第一个参数是返回值类型,其余参数为对应方法中参数的类型
//toString() 的签名
MethodType mtToString = MethodType.methodType(String.class);

// setter 方法的签名
MethodType mtSetter = MethodType.methodType(void.class, Object.class);

// Comparator 中 compare() 方法的签名
MethodType mtStringComparator = MethodType.methodType(int.class, String.class, String.class); 

public static void testMethod(String s) {
        System.out.println("hello string:" + s);
}
// 对于方法testMethod,使用fromMethodDescriptorString:第一个参数为方法描述符,第二个参数为查找类型的类加载器
MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V", null));

现在我们就可以使用 MethodType,再组合方法名称以及定义方法的类来查找方法句柄。要实现这一点,我们需要调用静态的 MethodHandles.lookup() 方法。这样的话,会给我们一个“查找上下文(lookup context)”,这个上下文基于当前正在执行的方法(也就是调用 lookup() 的方法)的访问权限。

查找上下文对象有一些以“find”开头的方法,例如,findVirtual()findConstructor()findStatic() 等。这些方法将会返回实际的方法句柄,需要注意的是,只有在创建查找上下文的方法能够访问(调用)被请求方法的情况下,才会返回句柄。这与反射不同,我们没有办法绕过访问控制。换句话说,方法句柄中并没有与 setAccessible() 对应的方法。

private static MethodHandle MH_BootstrapMethod() throws NoSuchMethodException, IllegalAccessException {
    return MethodHandles.lookup().findStatic(InvokeDynamicTest.class, "testMethod", MethodType);
}

对于访问控制的解释,可以参考以下代码:

class GrandFather {
    void thinking() {
        System.out.println("I am grandFather");
    }
}
class Father {
    void thinking() {
        System.out.println("I am Father");
    }
}
class Son extends Father {
    void thinking() {
        try {
            MethodType mt = MethodType.methodType(void.class);
            MethodHandle mh = lookup().findSpecial(GrandFather.class, "thinking", mt, getClass());
            mh.invoke(this);
        } catch (Throwable e) {
        }
    }
}

public static void main(String[] args) {
    new Son().thinking();
}

在JDK7 Update9之前的HotSpot虚拟机运行能够在Son中调用Grand Father的thinking()方法,但是在Update9之后为了保证访问权限的约束被修改了

MethodHandle 中有两个方法能够触发对方法句柄的调用,那就是 invoke()invokeExact()。这两个方法都是以接收者(receiver)和调用变量作为参数,区别在于InvokeExact()的参数需要进行严格的类型匹配,而invoke()能够稍微调整一下方法的变量。invoke() 会执行一个 asType() 转换,它会根据如下的这组规则来进行变量的转换:

  • 如果需要的话,原始类型会进行装箱操作
  • 如果需要的话,装箱后的原始类型会进行拆箱操作
  • 如果必要的话,原始类型会进行扩展
  • void 返回类型会转换为 0(对于返回原始类型的情况),而对于预期得到引用类型的返回值的地方,将会转换为 null
  • null 值会被视为正确的,不管静态类型是什么都可以进行传递