JVM虚拟机解析——方法重载与重写

版权声明:本文为博主原创文章,转载请注明出处 https://blog.csdn.net/qq_34939549/article/details/84976930

1.什么是方法的重载

在Java中,同一个类下同名方法如果参数类型相同,是无法通过编译的。因此,我们在写同名方法时会使用不同类型(类型、数量、顺序)的参数来定义,这种定义方式,我们称做方法的重载。

在一般情况下,我们会认为同名方法如果有相同的参数类型是不被允许的。而实际上,在字节码文件中,同名且同参数类型而方法返回值不同的方法是可以的,JVM虚拟机会直接选取第一个方法名以及参数类型匹配的方法。并且,它会根据所选取方法的返回类型来决定可不可以通过编译,以及需不需要进行值转换。我们可以通过字节码工具绕过编译器限制,在编译后的字节码文件上添加这样的方法。

2.方法重载在编译器中的逻辑

之所以我们可以做到方法的重载,是因为在编译的过程中,除了名称以外,编译器还会根据被调用方法的声明参数的类型进行匹配。

方法重载在Java虚拟机中的判定的两个参数是拆型装箱和可变长参数。

  1. 不考虑拆装箱和变长参数的情况下进行匹配,如果匹配不到,执行下个阶段
  2. 拆装箱后再重新进行匹配,如果还不行,执行下个阶段
  3. 在考虑是否有可变长参数影响再次进行匹配。

在可变长参数的干扰下,有时不能确定具体调用的是哪个方法时,参数是子类的方法会被优先选取。如
void damo(Object obj, Object... args) { ... }
void damo(String s, Object obj, Object... args) { ... }
同时在一个类中时,如果我们调用damo(null, 1, 2);就会调用第二个方法,因为String是Object的子类。
需要注意的是,重载是一个编译器的逻辑。

3.方法重写在Java虚拟机中的逻辑

Java虚拟机是通过类名、方法名和方法描述符进行方法的加载的。其中方法描述符是由方法的参数类型和返回值构成的,和刚才我们说的方法重载不同,方法描述符中包含有返回值,这个时候如果同一个类中出现方法名和方法描述符都一样的方法,java虚拟机会在验证阶段就报错。

上文说过,同名同参数类型的方法在编译时不会通过,但只要返回值不同,它在Java虚拟机上就可以,就是因为方法描述符中还包含有返回值。
Java虚拟机在方法重写的判定上也是通过这样,通过方法描述符来进行判定的。如果子类中定义了与父类中同名的方法,而且这个方法既非静态也非私有,且方法的参数类型也与父类中一致,那么在Java语言中就会被判定成重写,当然,和方法重载一样,在Java虚拟机中,这个判定还需要有一个条件,就是他们的返回值也必须一样。也就是说,

当子类和父类中方法名和方法描述符都一致时,Java虚拟机就会判定子类重写了父类的方法

对于在Java语言中属于重写而在Java虚拟机中不符合重写的情况,编译器会通过桥接方法来在虚拟机中实现Java语言对应的语义。

4.静态绑定和动态绑定

由于重载在编译器编译的阶段就已经完成,所以我们可以认为在Java虚拟机中是不存在重载这一定义的。因此,有的时候人们会将方法重载成为静态绑定,而方法的重写被称为动态绑定。

当然,这个说法并不是那么严谨,毕竟一个类重载的方法还有可能会被它的子类重写,在这种情况下,它还是会被编译器判定为一个需要动态绑定的类型。

静态绑定是在解析的时候就能够确定对应方法的情况,而动态绑定则是需要在程序运行时根据实际调用情况来确定实际调用的方法的情况。

具体来说,Java字节码文件中调用方法的命令分为5种

  1. invokestatic:调用静态方法
  2. invokespecial:调用构造器和私有的实例方法,以及使用super调用的父类中对应的方法,和所实现接口的默认方法。
  3. invokevirtual:调用非私有的实例方法
  4. invokeinterface:调用接口方法
  5. invokedynamic:动态调用方法

对于前两个,invokestatic和invokespecial命令而言,由于静态方法和构造器都是不能被子类重写的,所以Java虚拟机可以直接识别对应的方法。

而对于invokevirtual和invokeinterface命令,首先invokeinterface实现的是接口方法,而接口方法在不加defalut关键字(有default关键字的是invokespecial命令)的情况下,是没有方法体的,它必然不会是在这里进行定义的,而对于invokevirtual命令,上文也说明过,除非被标记为final,这样的方法是有可能被子类重写的,我们需要知道调用者调用的究竟是不是这个类中的方法还需要动态的判断,所以这个两个方法都需要在运行时进行动态的判断。这两种调用,均属于Java虚拟机中的虚方法调用。

最后一个invokedynamic则涉及到了它所依赖的方法句柄,我们会在以后的文章中再详细的说明。

5.调用方法时的符号引用

在方法编译的过程中,我们是无法知道方法在虚拟机中的具体地址的,所以编译器会使用一个符号引用来表示该目标方法,它里面包括方法所在的类(或接口)的名称、方法名和方法描述符,存储在常量池中。

在具体执行时,Java虚拟机会解析这个符号引用,并替换为实际引用。在上文中我们已经提到过虚拟机是怎么在找到类后匹配方法的,所以这里我们在说一说它是怎么找到指向的类的。
我们假设有一个符号引用指向A类,那么它会先在A类中查找对应的方法,如果其中没有,则会在他的父类中进行查找,直到Object。如果仍然没有,就会在A类直接或间接实现的接口中进行查找,当然,这样找到的必须是一个非私有非静态的方法。
在查找接口的时候,(比如是I),会先查找I中有没有满足条件的对应方法,如果没有,则会去查看该方法是否是Object类的公有实例方法。如果不是,则会去I的超接口中进行寻找。
经过这一过程,静态绑定会被替换为只想具体方法的指针,而动态绑定这会变成一个方法表的索引。这里我们就遇到了一个新的名词,方法表。

6.方法表

在上文我们提到invokevirtual和invokeinterface命令时,我们说过,这两者都是动态绑定,都属于虚方法的调用,在绝大多数的情况下,都需要虚拟机通过调用者调用的实际类型来进行绑定,这个过程相对于不需要进行判定的静态绑定,就会花费更多的时间。所以很多人会认为虚方法调用会牺牲时间从而降低代码的性能。
而实际上,Java虚拟机使用了一种“用空间换时间”的策略,大大减少了虚方法调用所耗费的时间,让它实际运行时并不想我们想的那么的耗时。这个策略就是方法表。

方法表的本质是一个数组,每个数组元素都指向一个类和其父类中的非私有实例方法。
之所以说方法表中的元素指向的是类和其父类的方法,是因为子类方法表中包含父类方法表中的所有的方法,如果子类方法重写了父类方法,那么他的索引值和父类中被重写的方法的索引值一致。
这个索引值在就是调用方法时符号引用中保存的那个索引值。在实际执行的过程中,调用方法时会根据这个索引值在对应的类的方法表中查找这个索引值对应的方法(这个过程就是动态绑定)。

具体来说,当一个类中的一个方法被调用时,虚拟机会先查看这个被调用的方法是那个类中的方法,然后查找对应类的方法表,根据索引值查找具体是哪一个方法被调用了。

使用方法表的动态绑定实际上只比静态绑定多了几个虚拟机的内存解引用操作,就是将索引值替换为实际方法的操作。相对于初始化Java栈帧来说小的几乎可以忽略不计。
但是这种情况我们是否可以说是没有影响呢?事实上,不能。上面的情况只是在解释执行中,或即时编译中的最坏的情况。我们还有更好的优化方法。就是内联缓存和方法内联。

7.内联缓存

内联缓存是一种提高动态绑定效率的优化技术。它能够缓存虚方法调用中调用的实际类型和对应的目标方法,在之后的过程中如果再次遇到已经缓存的,会直接调用缓存中类型的对应的方法,如果没有缓存才会执行上文中说明的基于方法表的动态绑定。

我们可以将内联缓存分为单态缓存、多态缓存和超多态缓存。

单态,即只有一种状态的情况,所以单态内联缓存就是说内联缓存只缓存了一种动态类型以及它对应的目标方法,它的实现很容易,与缓存的动态类型进行比较,如果一致,则直接调用对应的目标方法。

多态,则是指有限数量种状态的情况,它与超多态的则是根据一个数值进行区分。而多态混村则缓存了多个动态类型和目标方法,我们需要进行逐个的对比才能知道具体去调用哪个对应方法。

为了达到最优,我们会将调用越多的方法放到越前面,这时,大部分的虚方法调用时单态的,也就是只有一种动态类型,所以为了节省内存空间,一般Java虚拟机只是用单态内联缓存。

因此,当调用方法与单态内联缓存中的方法不一致时,我们就会使用方法表动态绑定机制。

对于这种情况,我们有两种选择,一种是替换单态内联缓存中缓存的记录,另一种则是裂化为超多太,这也是Java虚拟机所才有的方法,在这种状态下的内联缓存与替换记录的方法相比牺牲了优化的机会,但节省了重复写缓存的开销。

可能有的朋友不是很清楚这个“写缓存”的开销具体有多大。这么说吧,如果我们使用单态内联缓存,第一次我们调用了方法A,这个时候我们缓存中的就是A,之后我们又调用B,将内存中的A替换为B,而此时如果我们又调用了A,那么我们还需要把A再替换回去。如此重复多次还不如一开始就不缓存,直接分别调用A和B。

需要明确的是,内联缓存虽然有名字里有内联,但并没有实际的新建栈帧和压栈弹栈。这表示,你在实际的调用中,还是需要进行这些,而不是使用缓存中的栈帧,这些操作的开销仍然存在,因此我们还可以进行进一步的优化。内联缓存消除的仅仅是方法调用的开销,而不是栈帧结构和站操作的开销。

猜你喜欢

转载自blog.csdn.net/qq_34939549/article/details/84976930