方法识别

JVM 识别方法是通过 类名、方法名、方法描述符 来识别的:

  • 方法描述符是由 方法的参数类型、返回类型 构成;

  • 注意和 Java 语法的区别, Java语法规定方法返回值无法作为区分方法的依据;

如果 JVM 在类加载的验证阶段检测到同一个类中,同时出现多个方法名相同且描述符也相同的方法,那么会抛出异常;

 public class InvokeTest {
     static String staticTest() {
         return "123";
     }
     void nonStaticTest(String s, int a) {}
 }
 ​
 // 字节码
 cn/li/InvokeTest.staticTest ()Ljava/lang/String;
 cn/li/InvokeTest.nonStaticTest (Ljava/lang/String;I)V
 cn/li/InvokeTest.<init> ()V

重写方法 基于方法描述符:如果子类定义了与父类 非私有非静态 方法同名的方法,且方法描述符相同时, JVM 才会判定为重写;

  • 对于 Java 语法来说,重写的返回类型不一定相同,可以是其子类;此时 JVM 会通过生成 桥接方法 来实现该语义;

方法调用

JVM 调用相关指令

字节码和调用相关的指令有:

  • invokestatic:用于调用静态方法;

  • invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法;

  • invokevirtual:用于调用非私有实例方法;

  • invokeinterface:用于调用接口方法

  • invokedynamic:用于动态方法

对于 invokestaticinvokespecial 而言,JVM可以直接识别具体的目标方法;

对于 invokevirtualinvokeinterface 而言,大多数情况下 JVM 都需要根据调用者的具体类型来决定具体的目标方法;

调用指令的符号引用

在上面的调用指令后面,会跟有被调用方法的符号引用

  • 符号引用存在于类 class 文件的常量池中;

  • 方法的符号引用分为:

    • 接口符号引用

    • 非接口符号引用

非接口符号引用:假设该符号引用所指向的类是 C,JVM 查找方式:

  1. 在 C 中查找符合方法名和描述符的方法;

  2. 如果没有找到,在 C 的父类中继续搜索,直到 Object 类;

  3. 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,

根据该过程,子类可以调用父类的静态方法;

而子类的静态方法会隐藏父类中的同名、同描述符的静态方法;

接口符号引用:假设该符号引用所指向的接口是 I,JVM查找方法:

  1. 在 I 中查找符合名字及描述符的方法;

  2. 如果没有找到,在 Object 类中的共有实例方法中搜索;

  3. 如果没有找到,在 I 的父类接口中搜索;

经过上面的查找后,符号引用可以解析为直接引用:

  • 对于可以静态绑定的方法来说,直接引用的是一个指向方法的指针;

  • 对于需要动态绑定的方法来说,直接引用是一个方法表的索引;

静态绑定&动态绑定

上面所说的字节码指令,决定着方法绑定的方式;

invokestaticinvokespecial 对应着 静态绑定,字节码指令执行时,直接从方法区找到对应方法的引用即可;

invokevirtualinvokeinterface 对应着 动态绑定,字节码指令执行时,需要根据当前调用者的具体类型来查找匹配的方法;

因为 invokestaticinvokespecial 的指令语义,所以说在编译阶段,静态绑定就已经完成;

invokevirtualinvokeinterface 还需要在运行时确定具体方法,因此也称为 晚期绑定

虚方法调用

Java 中的所有非私有实例方法调用都会编译成 invokevirtual 指令,而接口方法调用会被编译成 invokeinterface 指令(JDK17实测似乎没有该指令的出现)

这两种指令都属于 JVM 中的 虚方法调用

在大多数情况下 JVM 都需要根据调用者的动态类型,来确定虚方法调用的目标方法,这个过程称为 动态绑定

需要根据当前调用者的类型去查找对应方法,相较于静态绑定的直接引用方法地址,动态绑定更加耗时;

方法表

JVM 采用了一种空间换时间的策略来实现动态绑定,为每个类都生成了一张方法表,用于快速定位目标方法;

在类加载的 准备阶段 中,除了为静态变量分配内存外,还会构造该类相关联的方法表;

  • invokevirtual 所使用的虚方法表 (virtual method table, vtable)

  • invokeinterface 所使用的接口方法表 (interface method table, itable)

本质上是一个数组,每个数组元素都指向一个当前类及其父类中非私有的实例方法(如果是重写方法)

  • 子类方法表中包含父类方法表中的所有方法

  • 子类方法在方法表中的索引值,和它父类所重写的方法的索引值 相同

因此类加载的 解析阶段,将符号引用解析为直接引用,实际上是方法表中的索引值;

在执行过程中,JVM会获取当前调用者的 实际类型,并在该类型的虚方法表中,根据索引值获取到目标方法

因此动态的绑定过程为:

  1. 访问当前调用者(访问栈上调用者)

  2. 获取调用者的实际类型(访问堆上对象,获取其对应类型)

  3. 读取该类型的方法表(方法区中维护的方法信息)

  4. 读取方法表中某个索引值对应的目标方法(如果当前子类查找不到还会向上沿着继承链查找)

上面的几个步骤开销相较于栈帧开辟的开销可以忽略不计;

其他优化手段

内联缓存

用于加快动态绑定;

能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法;

如果在后续的执行过程中,遇到已经缓存的类型,内联缓存变回直接调用该类型所对应的目标方法;

如果没有命中,那么回退到正常的动态绑定流程;

针对多态的情况,有:

  • 单态:仅有一种状态的情况;

  • 多态:指的是有限数量种状态的情况;

  • 超多态:指的是更多种情况的情况(多态和超多态根据一个阈值来区分)

对应内联缓存,对于上面的三种情况,分别有单态内联缓存、多态内联缓存、超多态内联缓存;

  • 单态内联缓存:仅缓存一种动态类型以及其对应的目标方法,只需要对比类型是否匹配就能调用;

  • 多态内联缓存:缓存多种动态类型及其对应的目标方法,需要逐一对比类型是否匹配;

大部分情况下,虚方法调用都是 单态 的,也就是只有一种动态类型,为了节省内存空间,JVM 仅采用 单态内联缓存

当内联缓存没有命中时,需要重新进行动态绑定,对于缓存已有的内容:

  • 要么替换已有的内容(如果交替调用方法,就会导致一直写缓存,没有缓存上的优化)

  • 要么劣化成超多态,直接访问方法表进行胴体绑定,并不写入

方法内联

将方法调用体移动到方法调用位置,以减少因方法调用产生的开销:

方法的开销有:

  • 创建栈帧(大头)

  • 压入栈

  • 弹出栈

比如大量的 getter/setter 方法,这部分的调用开销可能超过了方法本身执行的开销,因此 JIT 编译时,可以将其内联到调用处,减少方法调用开销;