Java的方法调用
方法调用
Java中方法的重载,在编译过程中可以完成识别,而具体调用哪一个方法,Java编译器会根据所传入参数的声明类型(注意与实际类型区分比如声明的是一个List,实际实例化传入的可能是一个ArrayList)来选取重载方法。选取过程分以下三个阶段:
- 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;(这里是只找普通方法)
- 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;(加入自动装箱拆箱的方法)
- 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况 下选取重载方法。(所有方法)
如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。示例如下:
1 | void invoke(Object obj, Object... args) { ... } |
为什么会调用第二个呢?因为String 类型 是Object 的子类,所以编译器会认为它更贴合(范围更小)。
而如果子类继承父类,而且有一个方法和父类中非私有的方法同名,而且参数相同,如果两个方法都是静态的,那么子类中会隐藏父类的方法,如果不是静态切都不是私有的,那么子类的方法相当于重写了父类的方法。
JVM 的静态绑定和动态绑定
Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。方法描述符,是由方法的参数类型以及返回类型所构成。在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证阶段报错。
因为描述符包含了返回类型,所以说并不会限制同一个类中有方法名和参数一样,但返回值不一样的方法。而在上述提到的子类有一个父类同名,同返回值,同参数的非私有,非静态方法,才会被虚拟机判定为重写。
因为重载方法在编译阶段已经区分,可以认为虚拟机层面没有重载的概念。因此,重载也被称为静态绑定(static binding),或者编译时多态 (compile-time polymorphism);而重写则被称为动态绑定(dynamic binding)。
这个说法在虚拟机语境下并不完全正确,因为某个类的重载方法可能会被它的子类所重写,因此,Java编译器会对所有非私有方法的调用,编译为需要动态绑定的类型(考虑子类继承父类,并重写父类的方法。此时通过子类调用重写的方法,并不能在编译期间确定)。
确切地说,Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
调用指令的符号引用
在编译过程中,并不知道目标方法的具体内存地址(编译阶段该对象并没有被实例化,似乎没实例化说法不太准确,gpt说是并不需要知到。有了符号引用后,在运行时,JVM会将符号引用解析为实际的地址引用)。
因此,Java 编译器会暂时用符号引用(这个符号引用应该就是暂时指代该方法)来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。
符号引用存储在 class 文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。
虚方法调用
Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指令, 而接口方法调用都会被编译成 invokeinterface 指令。这两种指令,均属于 Java 虚拟机中的 虚方法调用。
Java 虚拟机需要根据调用者的动态类型,来确定虚方法调用的目标方法。这个过程我们称之为动态绑定。(这里,调用者的动态类型,应该是受Java多态的影响,即List list = new ArrayList这种写法,List是一个接口,并不知道它的实例化是哪一个,需要根据具体的动态类型来决定调用的是它的哪一个实现)
1 | public class Test { |
方法表(用于优化动态绑定,即虚方法)
Java 虚拟机中采取了一种用空间换取时间的策略来实现动态绑定。它为每个类生成一张方法表,用以快速定位目标方法。
方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。这些方法可能是具体的,可执行的方法,也可能是抽象的方法。方法表有以下两个特征:
- 子类的方法表中包含了父类的方法表中的所有方法。
- 子类重写了父类方法时,该方法在子类中的索引值与父类中的一样。即通过索引只能找到子类的方法。
在符号引用解析为实际引用的时候,对于静态绑定的调用而言,实际引用是直接指向具体的目标方法,而动态绑定,则是实际引用则是指向了方法表的索引值(并不仅是索引值)。
在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据 索引值获得目标方法。这个过程便是动态绑定。
使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。
这种优化仅存在于解释执行中,或者即时编译代码的最坏情况中。这是因为即时编译还有另外两种更好的优化,内联缓存 (inlining cache)和方法内联(method inlining)。
内联缓存
它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接 调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定,即当内联缓存没有命中的情况下,Java 虚拟机需要重新使用方法表进行动态绑定。
Java虚拟机采用的是单态的,即只会缓存对应方法的一种实例,那么在一种极端的情况下,两个不同的类型频繁调同一个 方法,就会导致缓存被频繁的切换,而且每次都需要去重新绑定一下。情况如下:
1 | class Animal { |