从JAVA虚拟机的角度看JAVA重载与重写

1. 重载

最近在看JAVA虚拟机相关的内容,从JAVA虚拟机的角度上了解到了重载OverLoad和重写Override在JAVA虚拟机的底层(指令集)层面是如何实现的,所以写下此文留作记录

首先还是了解一些重载和重写的概念,重载和重写并不只是JAVA的专属概念,在很多编程语言中都有重载和重写的体现。

  • 重载 Overload:一般是用于在同一个类内实现若干个方法名称完全相同,而方法的参数在类型上或者在个数上又或者是在参数顺序上有所不同。

tips:在Java语言中,要重载一个方法,除了要与原方法具有相同的简单名称之外,还必须拥有一个与原方法不同的特征签名,特征签名是指一个方法中各个参数在常量池中的字段符号引用的集合。注意,返回值是不会包含在特征签名之中的,所以Java语言里面是无法仅仅依靠返回值的不同来对一个已有的方法进行重载的,因为他们的特征签名还是相同的。但是在Class 文件格式当中,特征签名的范围就要更大一些了,因为Class文件区分方法靠的是方法的描述符(方法的参数和返回值都会影响到描述符),所以只要描述符不是完全一致的两个方法就可以共存。所以在JVM层面,仅仅返回值不同,仍然可以重载成功,因为方法的描述符不同了,两个方法可以共存

我们看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
package com.Test;

public class Solution{
static abstract class Human{

}

static class Man extends Human{

}

static class Woman extends Human{

}
public void sayHello(Human guy){
System.out.println("hello,guy");
}
public void sayHello(Man guy){
System.out.println("hello,gentleMan");
}
public void sayHello(Woman guy){
System.out.println("hello,lady");
}

public static void main(String[] args) {
Solution solution = new Solution();
Human man = new Man();
Human woman = new Woman();
solution.sayHello(man);
solution.sayHello(woman);
}
}

可以看到,Solution类中的三个方法就是重载的

1
2
3
4
5
6
7
8
9
public void sayHello(Human guy){
System.out.println("hello,guy");
}
public void sayHello(Man guy){
System.out.println("hello,gentleMan");
}
public void sayHello(Woman guy){
System.out.println("hello,lady");
}

那我们关心的是虚拟机在这些相同名称的方法中确定所要调用的真正的目标方法呢?

我们首先运行上面的main函数,结果为:

hello,guy
hello,guy

为什么都执行了 sayHello(Human guy)这个方法呢?

我们首先需要定义两个概念:静态类型实际类型

1
Human man = new Man();

上面这一条new对象的语句,我们称Human为变量的静态类型(Static Type)或者又称之为外观类型,而后面的Man则被称为变量的实际类型(Actual Type)或者称之为运行时类型(Runtime Type)

静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅是在使用时发生变化的,变量本身的静态类型是不会被改变的。怎么理解呢?

如我们将main函数修改为:

1
2
3
4
5
6
7
public static void main(String[] args) {
Solution solution = new Solution();
Human man = new Man();
Human woman = new Woman();
solution.sayHello((Man)man);
solution.sayHello(woman);
}

此时,(Man) man语句将man的静态类型从Human改为了man,注意这里的修改仅仅是使用时发生变化,过了这条语句之后,man的静态类型还是Human。尽管变量的静态类型可能在使用期间发生变化,但是最终的静态类型在编译器是可知的,如上面的(Man) man强制类型转换,编译器是可以知道的。

而实际类型变化的结果在运行期才可确定,编译器在编译程序尔等时候并不知道一个对象的实际类型是什么,比如man变量,编译器在编译的时候是不知道你是new Man()来的,还是new Woman()来的。

我们可以通过下面的例子来解释

1
2
3
4
5
Human human = (new Random()).nextBoolean()?new Man() : new Woman(); //实际类型变化

//静态类型变化
solution.sayHello((Man) human);
solution.sayHello((Woman) human);

对象human的实际类型是可变的,知道运行时我们才知道它具体是什么类型,在编译期间它就像是薛定谔的猫,到底是Man还是Woman只要等到程序运行到这行时才能确定。

而human的静态类型是Human,我们也可以在使用的时候临时改变这个类型,但这个改变编译器仍然是可知的,在编译器就可以明确知道转型的是Man还是Woman。

这就是静态类型与实际类型的区别。我们再将话题话题回到Java虚拟机如何选择重载方法上来

虚拟机或者准确的说是编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译器是可知的,所以在编译阶段,Javac编译器就根据参数的静态类型选择了对应的重载版本。

代码为:

1
2
3
4
5
6
7
public static void main(String[] args) {
Solution solution = new Solution();
Human man = new Man();
Human woman = new Woman();
solution.sayHello(man);
solution.sayHello(woman);
}

man和woman的静态类型都为Human,所以都会选择sayHello(Human)作为调用目标,并且把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。(我们知道java中实例方法的调用在底层都是通过invokevirtual指令来实现的)。

我们再做测试,将main()方法修改为:

1
2
3
4
5
6
7
public static void main(String[] args) {
Solution solution = new Solution();
Human man = new Man();
Human woman = new Woman();
solution.sayHello((Man) man);
solution.sayHello((Woman )woman);
}

此时的返回结果为:

hello,gentleMan 调用了sayHello(Man)
hello,lady 调用了sayHello(Woman)

所有依赖静态类型来决定方法执行版本的分派动作,也称之为静态分派。静态分派最典型的代表就是方法重载了。静态分派实际上发生在编译阶段,实际上在编译阶段就已经知道要选择哪个版本的重载方法了。

需要注意的是Javac编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个相对更合适的版本。如下面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
package org.fenixsoft.polymorphic;
public class Overload {
public static void sayHello(Object arg) {
System.out.println("hello Object");
}
public static void sayHello(int arg) {
System.out.println("hello int");
}
public static void sayHello(long arg) {
System.out.println("hello long");
}
public static void sayHello(Character arg) {
System.out.println("hello Character");
}
public static void sayHello(char arg) {
System.out.println("hello char");
}
public static void sayHello(char... arg) {
System.out.println("hello char ...");
}
public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
}
public static void main(String[] args) {
sayHello('a');
}
}

上面的代码运行后会输出

hello char

这很好理解,'a’是一个char类型的数据,自然会寻找参数类型为char的重载方法,如果注释掉
sayHello(char arg)方法,那输出会变为:

hello int

更多详情的解释见《深入理解Java虚拟机第3版》8.3节

2 重写

重载与静态分派相关,而重写就与动态分配相关了

我们看下面的重写的例子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}

运行结果

man say hello
woman say hello
woman say hello

这个结果对应已经习惯了JAVA编程的当然觉得是理所当然的结果,而JAVA虚拟机去如何去判断应该调用哪个方法的呢?

实例方法的调用底层仍然是通过invokevirtual指令来实现的,那我们看一下invokevirtual指令的运行时解析过程,大致分为下面几步:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果
    通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

可以看到,invokevirtual指令在执行的第一步就找到了操作数栈顶的第一个元素,也就是this(调用该方法的对象)的实际类型,然后在实际类型C中找到方法名称和方法描述符都匹配的方法,调用该版本的方法,这个过程就是Java语言中方法重写的本质,我们把这种在运行期根据实际类型确定方法执行版本的分配过程称为动态分派

正是英文这种多态性的根源在于虚方法调用指令invokevirtual的执行逻辑,那我们可以得出的结论就是重写只会对方法有效,对字段是无效的,因为字段不使用这条指令。事实上,在Java里面只有虚方法存在,字段永远不可能是虚的,换句话说,字段永远不参与多态,哪个类的方法访问某个名字的字段时,该名字指的就是这个类能看到的那个字段。当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。

文章作者: luo
文章链接: https://luo41.top/2022/06/18/从JAVA虚拟机的角度看JAVA重载与重写/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 luo's Blog