JVM方法调用原理
方法重载
方法重载在编译过程就已经能够确定,具体到每个方法调用,Java编译器会根据所传入参数的声明类型来选取重载方法。可以分为三个步骤:
如果Java编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。
方法重载也可以作用于这个类所继承而来的方法。也就是说,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同,那么在子类中,这两个方法同样构成了重载。
方法重写
如果子类和父类的同名方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个同名方法都不是静态的,且都不是私有的,并且两个方法的参数类型以及返回类型一致,那么子类的方法重写了父类中的方法。
JVM静态绑定与动态绑定
JVM识别方法的关键在于类名、方法名以及方法描述符,方法描述符是由方法的参数类型以及返回类型所构成。JVM与Java语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此JVM能够准确地识别目标方法。
如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,JVM才会判定为重写。
对于Java语言中重写而JVM中非重写的情况,编译器会通过生成桥接方法来实现Java中的重写语义。
对方法重载在编译阶段已经完成,但是方法重写需要在运行时才能决定。重载也被称为静态绑定,重写也被称为动态绑定。这个说法在JVM语境下并非完全正确。这是因为某个类中的重载方法可能被它的子类所重写,因此Java编译器会将所有对非私有实例方法的调用编译为需要动态绑定的类型。
确切地说,JVM中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
Java字节码中与调用相关的指令共有五种:
对于invokestatic以及invokespecial而言,Java虚拟机能够直接识别具体的目标方法。
而对于invokevirtual以及invokeinterface而言,在绝大部分情况下,虚拟机需要在执行过程中,根据调用者的动态类型,来确定具体的目标方法。唯一的例外在于,如果虚拟机能够确定目标方法有且仅有一个,比如说目标方法被标记为final,那么它可以不通过动态类型,直接确定目标方法。
符号引用
在编译过程中,并不知道目标方法的具体内存地址。因此,Java编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。
符号引用存储在class文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用,可以使用javap -v 类名.class
来打印某个类的常量池。
对于非接口符号引用,假定该符号引用所指向的类为C,则JVM会按照如下步骤进行查找:
从这个解析算法可以看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。
对于接口符号引用,假定该符号引用所指向的接口为I,则JVM会按照如下步骤进行查找:
经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。
方法
类加载的准备阶段,它除了为静态字段分配内存之外,还会构造与该类相关联的方法表。方法表分为两种:
方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:
方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。执行过程中,JVM将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。
用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。
即时编译还拥有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)。
内联缓存
内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。
在针对多态的优化手段中有三种状态:
- 单态内联缓存指的是仅有一种状态的情况。它便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。
- 多态内联缓存指的是有限数量种状态的情况。它则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。一般来说,会将更加热门的动态类型放在前面。在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,JVM只采用单态内联缓存。
- 超多态内联缓存指的是更多种状态的情况。通常用一个具体数值来区分多态和超多态。在这个数值之下,我们称之为多态。否则,我们称之为超多态。
当内联缓存没有命中的情况下,JVM需要重新使用方法表进行动态绑定。对于内联缓存中的内容,有两种选择。一是替换单态内联缓存中的记录。这种做法就好比cpu中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。因此,在最坏情况下,用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。另外一种选择则是劣化为超多态状态。这也是JVM的具体实现方式。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存记录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。
虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。
对于极其简单的方法而言,比如说getter/setter,这部分固定开销占据的cpu时间甚至超过了方法本身。此外,在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性。
版权声明:本文内容由互联网用户自发贡献,该文观点与技术仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 [email protected] 举报,一经查实,本站将立刻删除。