[短文速读-2] 重载/重写,动/静态分派?(重新修订)

1,109 阅读8分钟

正题

为了避免不必要的浪费时间,文章主要是围绕俩点进行展开:

1、重载为什么根据静态类型,而非动态类型?

2、通过动/静态分派决定调用对应方法的符号引用。

如果对这俩个问题理解的比较深刻的话,这篇文章不看也罢,哈哈~

文章后半部分,会从字节码层面,聊一聊符号引用。如果Class文件结构不是很了解的小伙伴,可以选择性观看~或者看看这篇文章,[动态代理三部曲:中] - 从动态代理,看Class文件结构定义

爱因斯坦:“如果你不能简单地解释一样东西,说明你没真正理解它。”

[短文速读-1] a=a+b和a+=b的区别

[短文速读-2] 重载/重写,动/静态分派?(重新修订)

[短文速读-3] 内部匿名类使用外部变量为什么要加final

[短文速度-4] new子类是否会实例化父类

[短文速读 -5] 多线程编程引子:进程、线程、线程安全

引子

小A:MDove,我最近遇到一个问题百思不得其解。

MDove:正常,毕竟你这智商1+1都不知道为什么等于2。

小A:那1+1为啥等于2呢?

MDove:......说你遇到的问题。

重载的疑惑

小A:是这样的,我在学习多态的时候,重载和重写,有点蒙圈了。我自己写了一个重载重写的demo...

public class MethodMain {
    public static void main(String[] args) {
        MethodMain main = new MethodMain();
        Language language = new Language();
        Language java = new Java();
        //重载
        main.sayHi(language);
        main.sayHi(java);
        //重写
        language.sayHi();
        java.sayHi();
    }

    private void sayHi(Java java) {
        System.out.println("Hi Java");
    }
    private void sayHi(Language language) {
        System.out.println("Im Language");
    }
}

public class Java extends Language {
    @Override
    public void sayHi() {
        System.out.println("Hi,Im Java");
   }
}
public class Language {
    public void sayHi() {
        System.out.println("Hi,Im Language");
    }
}

小A重写的结果这个毫无疑问。但是为什么重载的demo运行结果是这个呀?我觉得它应该一个是Im Language一个是Hi Java呀。毕竟我在调用方法时,参数一个传的实例化的类型一个Java,一个是Languae,为啥不一个匹配参数是Java类型,一个匹配参数Language类型啊?

重载方法版本选择是根据:静态变量

MDove:原来是这个疑惑呀。其实我最初也有这个疑惑。这里借用 R大的一个回答,看回答之前,我们先明确一个概念:A a = new B()。这个A a中的A被称之为静态类型,B称为动态类型/实际类型

MDove:明确这个概念之后,让我们看一下R大的回答。(这里我抽取了和这个问题相关的内容,如果想了解更多内容,可以去R大的回答里膜拜。)

为何判定调用哪个版本的重载只通过传入参数的静态类型来判定,而不使用其动态类型(运行时传入的实际参数的实际类型)来判定?其实根源并不复杂:因为这是当时的常规做法,C++也是这么设计的,于是Java就继承了下来。这样做的好处是设计与实现都简单,而且方法重载在运行时没有任何额外开销...(省略部分内容)...而这么做的缺点也很明显:牺牲了灵活性。如果程序确实需要根据多个参数的实际类型来做动态分派,那就得让码农们自己撸实现了。

小A:没想到是出于这么一种考虑。那我们又是重载,又是重写。这么多很类似的方法。JVM是怎么选择和调用具体的方法的呢?

MDove:这个过程算是方法的调用,提到这个过程,我们不得不聊一聊分派这个概念。分派又分两种方式:静态分派动态分派。这两种方式决定了一个方法的最终执行。

方法的定位

静态分派

MDove:小A,你觉不觉得这两个demo在写法上有明显的不同么?或者再上升一个高度。重载和重写是不是在业务场景上是有不同之处的?

小A:你这么一说好像真是!重载是在一个类里边折腾;而重写子类折腾父类

MDove:没错,正是如此!我们总结一下:

  • 重载是更倾向于对方法的选择
  • 重写则更倾向于是究竟是谁在对方法进行调用

MDove:上述你写的那个重载demo里,对于Language language = new Java();来说:Language是静态类型,Java是实际类型。同样MethodMain main = new MethodMain();也是如此,MethodMain main这个MethodMain是静态类型new MethodMain()这是MethodMain是实际类型

MDove:所以,对于JVM来说,在编译期参数/及调用者的静态类型是确定的,因此这一步方法的符号引用是确定的。(这个过程就是:静态分派)

小A:哦,原来在编译期就已经确定了符号引用...不对,等等!!如果确定符号引用也会用过调用者的静态类型,那重写不也是调用静态类型里边的方法了?!!!

MDove:哎呦,你小子反应的还挺快!先回答你的问题,在这个过程中,重写的确和重载所确定的符号引用是一样的!我们看一下你demo中字节码的内容,这里只截取一部分:

红色是重载,紫色是重写

MDove:看到了吧,你说的没错,紫色所标注的内容就是重写的俩个方法在编译期决定的符号引用。因为静态分派的原因,它们俩的确是相同的!而重载的方法选择就是在这个过程确定的,但是重写比较特殊,它需要依赖运行时对象的实际类型,因此对于重写来说它还需要动态分派

MDove:对于静态分派。说白了就是,在编译期就决定好,调用哪个版本的方法。因此对于在运行期间生成的实际类型JVM是不关心的。只要你的静态类型是郭德纲,就算你new一个吴亦凡出来。这行代码也不能又长又宽...

MDove:这里总结一下,所有依赖静态类型来定位方法执行版本的方式都称之为:静态分派

小A:静态分派我明白了,快讲讲动态分派吧!我想知道为什么同样的符号引用,重写会执行不同的方法!

动态分派

MDove:我们都知道重写涉及到你是调用子类的方法还是调用父的。也就是说调用者的实际类型对于重写是有影响的,因此这种情况下仅靠静态分派就行不通了,需要在运行期间的动态分派去进一步确定。

MDove:这次我们来看一下这个main方法里边的字节码情况:

MDove:这里简单的解释一下这些字节码指令:

第一个紫色的圈:执行常量池#5的符号应用,并将其压倒操作数栈第2个位置。

第一个紫色的线:就是常量池#5对应的符号引用。我们可以看出它是一个Language的类型。

第一个红色的圈:执行常量池#6的符号应用,并将其压倒操作数栈第3个位置。

第一个红色的线:就是常量池#6对应的符号引用。我们可以看出它是一个Java的类型

第二个紫色的圈:取出操作数栈2的变量,执行invokevirtual执行也就是执行#9。

第二个紫色的线:就是常量池#9对应的符号引用。我们可以看出它Language.sayHi()方法。

第二个红色的圈:取出操作数栈3的变量,执行invokevirtual执行也就是执行#9。

第二个红色的线:就是常量池#9对应的符号引用。我们可以看出它Language.sayHi()方法。

MDove:通过字节码指令我们可以看出,除了操作数栈中变量的类型不同,其他的都是相同的。因此动态分派的特性,就在invokevirtual这个执行中。

MDove:简单来说,虚拟机在执行invokevirtual时,会先找到操作数栈顶第一个元素,去找它的实际类型,然后找到它对应的符号引用,如果没有就一步步往上找,直到找到。紧接着动态链接到它的真正内存地址,完成方法调用。

MDove:对应到我们的demo,上就是:执行java.sayHi()时,先是aload_3出栈,确定它的实际类型,一看是Java类型,所以就会在Java.class中找对应的符号引用,有的话,返回对应的符号引用,执行。也就完成了我们的重写调用。

小A:Java真好玩,我想回家送外卖...

总结

对于重载来说,在编译期,就已经通过静态类型决定要选择那个版本的方法了(决定调用哪个符号引用)。而这种通过静态类型来定位方法执行版本的过程叫做:静态分派

对于重写来说,通过静态类型显然是行不通的,因此需要动态类型来判断。那么这种通过动态类型来定位方法执行版本的过程叫做:动态分派

剧终

我是一个应届生,最近和朋友们维护了一个公众号,内容是我们在从应届生过渡到开发这一路所踩过的坑,以及我们一步步学习的记录,如果感兴趣的朋友可以关注一下,一同加油~

个人公众号:IT面试填坑小分队