JVM如何执行方法调用

  从内存模型角度来说,每执行一个方法都会生成一个栈帧。栈帧通过操作数栈和局部变量表来完成计算。这部分的内容不是今天的重点。

一. JVM如何识别目标方法?

  首先确定一下需求,识别目标方法。目前方法就是JVM内存模型中的一个内存地址,它是一个函数。识别就是我们的字节码定位到这个地址的过程。

  怎么识别呢?JVM内存地址可不是固定的,需要一个固定的唯一标识来标记这块地址,也就是计算机中常见映射表。

  这个唯一标识是什么?我们在编写Java类时,类的方法名可以是重复的,但是有一定的限制,这其中涉及了两个知识:重载和重写。

 

重载和重写

  在 Java 程序里,如果同一个类中出现多个名字相同,并且参数类型相同的方法,那么它无法通过编译。也就是说,在正常情况下,如果我们想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同。这些方法之间的关系,我们称之为重载。

小知识:这个限制可以通过字节码工具绕开。也就是说,在编译完成之后,我们可以再向class文件中添加方法名和参数类型相同,而返回类型不同的方法。当这种包括多个方法名相同、参数类型相同,而返回类型不同的方法的类,出现在Java编译器的用户类路径上时,它是怎么确定需要调用哪个方法的呢?当前版本的Java编译器会直接选取第一个方法名以及参数类型匹配的方法。并且,它会根据所选取方法的返回类型来决定可不可以通过编译,以及需不需要进行值转换等。

  重载的目标方法在编译时期就可以确定,在Idea中使用Ctrl+鼠标左键都可以定位到目标方法,更别说更加底层的虚拟机了。具体到每一个方法的调用,Java编译器会根据方法名和所传参数的声明类型进行判断:

  1.在不考虑装箱拆箱和变长参数的情况下,定位方法

  2.若1没找到,在考虑装箱拆箱的情况下,定位方法

  3.若1、2没找到,在考虑装箱拆箱和边长参数的情况下,定位方法

  若Java编译器找到了多个匹配的方法,则会选择最接近的那个,如下例: 

void invoke(Object obj, Object... args) { ... }
void invoke(String s, Object obj, Object... args) { ... }

invoke(null, 1);    // 调用第二个invoke方法,因为String是Object的子类,传入null时JVM认为子类是更解决的方法
invoke(null, 1, 2); // 调用第二个invoke方法
invoke(null, new Object[]{1}); // 只有手动绕开可变长参数的语法糖,
                               // 才能调用第一个invoke方法

  [注:官方文档建议避免重载可变长参数方法,程序员很可能在使用时误判断目标方法]

  除了同一个类中同名方法的重载外,继承自父类的同名方法也属于重载。那么如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,那么这两个方法之间又是什么关系呢?

  如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。

  Java 是一门面向对象的编程语言,它的一个重要特性便是多态。而方法重写,正是多态最重要的一种体现方式:它允许子类在继承父类部分功能的同时,拥有自己独特的行为。

 

虚拟机识别方法

  JVM识别方法和上边讲的Java语言不完全相同。Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符,方法描述符由方法的参数类型和返回值类型构成。可以看到,Java 虚拟机与 Java 语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此 Java 虚拟机能够准确地识别目标方法。

  Java 虚拟机中关于方法重写的判定同样基于方法描述符。也就是说,如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java 虚拟机才会判定为重写。对于Java中是重写但是JVM不是重写的方法,编译器会通过桥接方法来实现Java中的重写语义。

  由于重载的方法在编译器就可识别定位,所以Java虚拟机中并不存在重载的概念。所以它也被称为静态绑定,即在编译期就能确定的目标。而重写被称为动态绑定,必须到运行时才能确定。

二. 动态绑定和静态绑定

方法调用指令

  上面我们讲了,重载被称为静态绑定,重写被称为动态绑定。这个说法在 Java 虚拟机语境下并非完全正确。这是因为某个类中的重载方法可能被它的子类所重写,因此 Java 编译器会将所有对非私有实例方法的调用编译为需要动态绑定的类型。确切地说,Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。

  Java字节码中与调用相关的指令有五种:

   1. invokestatic:调用静态方法

  2. invokespeical:调用构造器方法、私有实例方法、super指定的父类方法

  3. invokevirtual:调用实例方法

  4. invokeinterface:调用接口方法

  5. invokedynamic:调用动态方法

  其中invokedynamic指令是用于实现动态语法的,较为复杂。

调用指令的符号引用

  在编译阶段,我们并不知道目标类、目标方法的实际内存地址,所以需要一个代表符号来暂时标记目标方法。这个符号引用由目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符组成。

  在执行目标方法前,虚拟机会解析符号引用,并替换成实际引用。

  对于非接口的类方法,查找实际引用的过程如下:

  1. 在本类查找目标方法。查找的方式就是解析符号引用,对比方法名和参数类型

  2. 如果1没找到,在父类中查找,一直到Object类

  3. 如果1.2 都没有找到,在其直接或间接实现的接口中查找default默认接口方法。这一过程查找的方法必须是公开的、非静态的(java 9 提供了接口的私有方法和静态方法)。并且,如果目标方法在间接实现的接口中,则需满足其与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。

  对于接口方法,查找过程如下:

  1. 在本接口中查找目标方法

  2. 如果1没有找到,在Object类中查找目标方法

  3. 如果1、2没有找到,在直接或间接实现的接口中查找,这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致。

 

三. 虚方法和非虚方法

  invokevirtual和invokeinterface属于虚方法,在绝大多数情况,虚拟机需要根据动态类型来确定虚方法调用的目标方法,这个过程我称之为动态绑定。相对于静态绑定的非虚方法调用来说,虚方法调用更加耗时。

  invokestatic和invokespecial属于非虚方法,除此之外还包括标记了final的实例方法。

 

方法表

  在类加载的链接阶段,除了为静态字段分配内存外,还会构造与该类相关联的方法表。这个方法表就是虚拟机实现动态绑定的关键。

  方法表本质上就是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:其一,子类方法表中包含父类方法表中的所有方法;其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同(覆盖)。

  上边我们提到过,方法调用之前会将符号引用替换为实际引用,对于静态绑定来说 实际引用就是实际的内存地址,而动态绑定来说 实际引用则是数组的索引值(实际上并不仅是索引值)。Java虚拟机会先获取动态类型(class),然后找到对应类型的方法表,遍历方法表定位到索引。

 

  实际上,使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。相对于创建并初始化 Java 栈帧来说,这几个内存解引用操作的开销简直可以忽略不计。

  那么我们是否可以认为虚方法调用对性能没有太大影响呢?其实是不能的,上述优化的效果看上去十分美好,但实际上仅存在于解释执行中,或者即时编译代码的最坏情况中。这是因为即时编译还拥有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)。下面我便来介绍第一种内联缓存。

 

内联缓存

  内联缓存是一种加快动态绑定的优化技术,本质还是以空间换时间。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。

   对于内存缓存的方法来说,可分为三种:

  1. 单态:指只有一种状态

  2. 多态:指有限数量种状态

  3. 超多态:指超过指定阈值数量的状态

  对于内联换出来说,就可分为:单态内联缓存、多态内联缓存、超多态内联缓存

  单态内联缓存因为方法只有一个,所以实现起来很简单:比较缓存的动态类型,如果没有命中则去查方法表

  多态内联缓存方法有多个,需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。一般来说,虚拟机会将更热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,Java 虚拟机只采用单态内联缓存。

  前面提到,当内联缓存没有命中的情况下,Java 虚拟机需要重新使用方法表进行动态绑定。对于内联缓存中的内容,我们有两种选择。一是替换单态内联缓存中的纪录。这种做法就好比 CPU 中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。

  因此,在最坏情况下,我们用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。

  另外一种选择则是劣化为超多态状态。这也是 Java 虚拟机的具体实现方式。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存纪录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。

  虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。

posted @ 2020-09-16 12:06  Super-Yan  阅读(402)  评论(0编辑  收藏  举报