重载/重写,我们的方法是如何被执行的

来源:IT面试填坑小分队 2018-09-08 15:34:12

正题

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

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

2、我们重载/重写了这么多方法,是怎么被准确的定位到的?

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

文章后半部分,会从字节码层面,聊一聊符号引用和动态链接。如果Class文件结构不是很了解的小伙伴,可以选择性观看~或者看看我历史文章中关于Class文件结构的部分。

引子

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

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

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

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

重载的疑惑

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

//重载

publicclassMethodMain{publicstaticvoidmain(String[] args){MethodMain main = new MethodMain(); Language language = new Language(); Language java = new Java(); main.sayHi(language); main.sayHi(java); }privatevoidsayHi(Java java){ System.out.println("Hi Java"); }privatevoidsayHi(Language language){ System.out.println("Im Language"); }}publicclassJavaextendsLanguage{}publicabstractclassLanguage{}

//重写

publicclassMethodMain{publicstaticvoidmain(String[] args){Language language = new Java();new MethodMain().sayHi(language); }}publicclassJavaextendsLanguage{@OverridepublicvoidsayHi(){ System.out.println("Hi,Im Java"); }}publicclassLanguage{publicvoidsayHi(){ System.out.println("Hi,Im Language"); }}

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

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

MDove:原来是这个疑惑呀。其实我最初也有这个疑惑。由于并没有找到官方的解释,所以自行推测了一下,你仔细看这俩种写法,再思考一下重载重写写法上的不同,是不是可以总结出来:重载倾向于方法的选择;而重写更在意是谁在调用方法。既然是选择那么就要有明确的标准,而这个标准根据参数的静态类型,这样方便且直接。也就是现在重载的这种实现方式。

PS:A a = new A() 这个A a中的A就是静态类型

MDove:。后来看到R大的一个回答,补充了很多设计上的思路,也侧面证实了我的这种想法:

为何判定调用哪个版本的重载只通过传入参数的静态类型来判定,而不使用其动态类型(运行时传入的实际参数的实际类型)来判定?其实根源并不复杂:因为这是当时的常规做法,C++也是这么设计的,于是Java就继承了下来。这样做的好处是设计与实现都简单,而且方法重载在运行时没有任何额外开销——不同重载版本间在运行时就像是没有任何联系的独立方法一样,因为运行时所关心的“方法名”不只是语言层面的方法名,而也包括了带有参数列表及返回类型信息的方法签名(signature)在内。例如说,JVM并不知道foo方法是什么,而只认foo:()V、foo:(II)Z这样的方法名+方法签名的符号引用。而这么做的缺点也很明显:牺牲了灵活性。如果程序确实需要根据多个参数的实际类型来做动态分派,那就得让码农们自己撸实现了。

方法的定位

静态分派

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

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

MDove:没错,正是如此!我们再深入的思考一下,上文提到的:

重载是更倾向于对方法的选择重写则更倾向于是谁在调用方法的调用

MDove:首先,让我们看一段代码。

A a = new B();

MDove:对于A和B来说,他们有不同的学术名词。A称之为静态类型,B称之为实际类型。对于Language language = new MethodMain().new Java();也是如此:Language是静态类型,Java是实际类型

MDove:从你写的demo里,我们可以看出来:main.sayHi(language); main.sayHi(java);最终都是调用了private void sayHi(Language language)。我们是可以得出一个结论:方法的调用,在选择重载哪个版本的时候,是根据静态类型去匹配的。

就像你的那个demo一样,language和java的静态类型都是Language所以就匹配了private void sayHi(Language language)这个方法。

MDove:在调用之前,我们再回到上文提到的静态类型上。对于JVM来说,在编译期变量的静态类型是确定的,同样重载的方法也就能够确定,同样也间接的提高了性能。这很好理解,因为二者都是确定无误的。所以对于这种方法的调用,就叫静态分派。(因为这类不涉及任何需要动态决定类型的地方)

MDove:Java最初的设计就是如此,对于方法的选择(比如说重载)来说,在编译期根据静态类型决定。这叫做:静态分派。而重写因为其特殊性,它只有在运行时,才能确定实例对象是什么,因此它的这种方法调用就被称之为:动态分派

小A:原来如此,那可不可以再多讲一讲动态分派呢?

MDove:如果要聊动态分派,那就必须要引出一些概念。先用一句话总结这个过程:

我们的java文件被编译成class文件后,class文件中的常量池部分就拥有了和这个类相关的符号引用。在类加载时通过静态分派决定某些方法的调用,在运行期间通过动态分派决定某些方法的调用。当定位到对应方法的符号引用后,字节码指令会执行对应的符号引用,然后将这些符号引用真正映射到对应对象的内存地址上。这样就可以完成真正方法的调用。

动态分派

MDove:刚才提到的静态分派的过程。

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

小A:照这个逻辑来说,重写就是动态分派,需要JVM在运行期间确定对象的实际类型,然后再决定调用哪个方法。

MDove:没错,毕竟重写涉及到你是调用子类的方法还是调用父类。也就是说调用者的类型对于重写是有影响的,因此这种情况下静态分派就行不通了,需要在运行期间去决定。

MDove:当然我们用嘴说是很轻巧的,实际JVM去执行时是很复杂的过程。根据你上边写的重写的代码,咱们来从字节码层面看一看方法的调用过程。这个是你的那个demo,javap的内容:

MDove:我用三种颜色标注的地方,就是我们需要关注的点。我们的方法调用,也就是通过常量池里#X的符号引用进行关联的(这些内容都是在编译期生成的)。那么对于我们重写来说,我们需要运行期决定类型,也就是说在运行期间决定到底调用哪个方法。

MDove:注意一下黄色圈起来的字节码指令:invokespecial。invokespecial以及invokestatic指令所执行的符号引用,在类被加载的时候就直接替换成了直接引用,并不会等到运行期。因此它的执行简单明了(就比如:执行我们的重载,静态分派决定方法,找到对应符号引用,拿到直接引用执行即可)。

MDove:而其他的指令,例如紫色圈起来的invokevirtual则不同。

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

小A:那它的内存地址是怎么动态链接上的?

MDove:那这个问题就比较的复杂了,如果很感兴趣的话,可以看一下R大的回答。

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

总结

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

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

剧终

点击查看原文

相关链接