这里我们解释关心不是重写和重载的语法实现,而是虚拟机如何正确定位到目标方法
重载?静态分派?
重载的实现原理就是静态分派。
什么是静态分派呢?
所有依赖静态类型来决定方法执行版本的分派动作就是静态分派。
静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,所以在有的资料中也把静态分派叫做解析。
补充解析的概念:
调用目标在程序代码写好、编译器进行编译的那一刻就确定下来。这类方法的调用被称之为解析。
静态分派案例
这里引用《深入理解Java虚拟机》中的案例,演示静态分派
public class StaticDispatch {
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) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
运行结果是什么呢?
为什么会是这样的结果呢?
在main方法中调用了两次sayHello()
方法。在方法接收者已经确定是对象sr的前提下,使用哪一个重载版本,完全取决于两点:
- 传入参数的数量
- 传入参数的数据类型
在示例代码中定义了两个静态类型相同,而实际类型不同的变量,但是虚拟机(准确的说是编译器)在重载时是通过参数的静态类型而不是运行时类型作为判定依据的。
由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了sayHell(Human)
作为调用目标,并把这个方法的符号引用写到main方法里的两条invokevirtual指令参数中。
重载确定哪个版本?
值得注意的是Javac编译器虽然能够确定出方法的重载版本,但是很多情况下这个重载版本并不是“唯一”的,往往只能确定一个“相对更合适的”版本
什么意思呢?
看个例子,我们就明白了
public class Overload {
public static void sayHello(char arg) {
System.out.println("hello, char");
}
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 main(String[] args) {
sayHello('a');
}
}
毫无疑问,上面的执行结果为
但是如果我们注释掉了sayHello(char arg)
方法之后,输出会变成什么呢?
public class Overload {
// public static void sayHello(char arg) {
// System.out.println("hello, char");
// }
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 main(String[] args) {
sayHello('a');
}
}
这时发生了依次自动类型转换,'a'
除了可以代表一个字符,还可以嗲表数字97(字符’a’的Unicode数字为十进制数字97),因此参数类型为int的重载也是合适的。
如果继续注释掉sayHello(int)
方法就会选择sayHello(long)
的重载版本,这时也是发生了自动类型转换int转型为了long,感兴趣的读者可以自己尝试,这里就不演示了
重写?动态分派?
动态分派的过程与重写有着密切的关系
什么是动态分派
我们把在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
动态分派案例
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();
}
}
运行结果是什么呢?
这里选择调用方法版本时显然就不是根据静态类型来决定的了,因为静态类型同样都是Humman的两个变量man和woman在调用sayHello()方法时产生了不同的行为,甚至man在两次调用中海之星了两个不同的方法。导致这个现象的原因很明显,因为两个变量的实际类型不相同。
那Java虚拟机是如何根据实际类型来分派方法的执行版本的呢?
我们来尝试从字节码中寻找答案:
0 new #2 <leetcode/editor/cn/test/DynamicDispatch$Man>
3 dup
4 invokespecial #3 <leetcode/editor/cn/test/DynamicDispatch$Man.<init> : ()V>
7 astore_1
8 new #4 <leetcode/editor/cn/test/DynamicDispatch$Woman>
11 dup
12 invokespecial #5 <leetcode/editor/cn/test/DynamicDispatch$Woman.<init> : ()V>
15 astore_2
16 aload_1
17 invokevirtual #6 <leetcode/editor/cn/test/DynamicDispatch$Human.sayHello : ()V>
20 aload_2
21 invokevirtual #6 <leetcode/editor/cn/test/DynamicDispatch$Human.sayHello : ()V>
24 new #4 <leetcode/editor/cn/test/DynamicDispatch$Woman>
27 dup
28 invokespecial #5 <leetcode/editor/cn/test/DynamicDispatch$Woman.<init> : ()V>
31 astore_1
32 aload_1
33 invokevirtual #6 <leetcode/editor/cn/test/DynamicDispatch$Human.sayHello : ()V>
36 return
0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中,这些动作对应了java源码中的这两部分
Human man = new Man();
Human woman = new Woman();
接下来的16~21行是关键部分,16和20行的aload指令分别把刚刚创建的两个对象的引用压到了操作数栈的栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者;17和21行是方法调用指令,这两条调用指令从字节码的角度来看,无论是指令还是参数都完全一样,如下:
17 invokevirtual #6 <leetcode/editor/cn/test/DynamicDispatch$Human.sayHello : ()V>
21 invokevirtual #6 <leetcode/editor/cn/test/DynamicDispatch$Human.sayHello : ()V>
但是指令最终执行的目标方法却不相同。那看来导致这个现象的原因就是invokevirtual指令的解析过程导致的了,invokevirtual指令的解析过程大致可以分为以下几步:
- 找到操作数栈顶的第一个元素所指向的对象的时机类型,记作C
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行权限访问校验,如果通过则返回这个方法的直接引用,查找结束;不通过则返回java.lang.IllegalAccessError异常
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
正因为invokevirtual指令的第一步就是在运行期间确定接收者的实际类型,所以两次调用中的invokevirtural指令并不是把常量池中的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。