在进行文章论点分析之前,我们先要回顾一下方法调用过程,阅读本文需要对jvm
有一定了解
方法调用
方法调用一般发生在一个方法调用另一个方法中,所有方法的执行过程都是通过栈
来完成,栈帧的出栈/入栈
对应着方法的调用和卸载,在程序运行时,方法调用是最普遍,最频繁的的操作之一,下图揭示了jvm
中方法调用的模型图
方法调用分类
jvm
在运行中会把符号引用转化为方法的直接引用
- 对于在编译期就确定,且在运行过程中不会改变的目标方法成为静态链接
- 被调用的方法在编译期无法确定下来,只能在程序运行期间才能将符号引用转化为对应的方法引用,我们成为动态链接,或者动态分派
废话不多说,有了这部分知识,我们就可以探讨本文的问题了
重载的本质
java面向对象的三大特性:封装,继承,多态,多态性的一个体现就是方法的重载
,通过一个例子来揭示重载的本质,也是一道面试题:
public class Test1{
static abstract class Human{
}
static class Man extends Human{
}
static class Woman extends Human{
}
public void sayHello(Human guy){
System.out.println("I'am human guy");
}
public void sayHello(Man guy){
System.out.println("I'am man guy");
}
public void sayHello(Woman guy){
System.out.println("I'am woman guy");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
Test1 t = new Test1();
t.sayHello(man);
t.sayHello(woman);
}
}
程序运行结果:
I'am human guy
I'am human guy
我们分析这段代码:Human man = new Man();
其中Human
称为静态类型,new man()
称为实际类型,静态类型和实际类型在使用时都可能发生变化,我们改变一下代码:
Human human = (new Random()).nextBoolean ? new Man : new Woman()
;实际类型发生变化
t.sayHello((Man) human)
;静态类型发生变化
这样就可以理解静态类型和实际类型在使用时都可能发生变化这句话了,但需要注意的是: 对于human
这个变量来说,它的静态类型不管在那个地方改变,都是在编译期能够确定的,这也是我们为什么有时候类型转换失败的原因,而实际类型在编译期无法确定,只有在程序运行期才能够确定,程序在编译器进行重载方法的选择是通过静态类型和参数数量决定的,所以才会有刚才程序运行的结果
重写的本质
在编译期无法确定而在程序运行期才能确定的方法也体现了java的多态性
,与重载需要根据静态类型来判断参数从而选择方法不同,重写则是根据实际类型来选择方法,同样通过代码来举例:
public class Test1{
static abstract class Human{
abstract void sayHello();
}
static class Man extends Human{
@Override
void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human{
@Override
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
对于这个结果我们很难解释,反编译看具体的字节码文件
Code:
stack=2, locals=3, args_size=1
0: new #2 // class Test1$Man
3: dup
4: invokespecial #3 // Method Test1$Man."<init>":()V
7: astore_1
8: new #4 // class Test1$Woman
11: dup
12: invokespecial #5 // Method Test1$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method Test1$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method Test1$Human.sayHello:()V
24: new #4 // class Test1$Woman
27: dup
28: invokespecial #5 // Method Test1$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method Test1$Human.sayHello:()V
36: return
下面来具体解释这些指令:
- 0-15行就是创建对象的过程,将创建好的两个实例变量存储到局部变量表的索引为1.2的地方
- 16-21行就是引用具体的方法了,通过
aload
指令将局部变量表里的对象引用压入操作数栈栈顶,invokevirtual #6 // Method Test1$Human.sayHello:()V
这两个对象的引用同时执行相同方法名的方法,执行的字节码指令完全相同,那么问题来了,同样的字节码指令为什么会调用不同的方法?
要解决这个疑问需要看懂invokevirtual
的作用(这些指令的具体作用会在文章末尾显示):这个指令在运行时的过程大概分为以下部分:
- 找到操作数栈顶的第一个元素(也就是我们之前分析的
aload
指令,从局部变量表返回的)所指向的对象的实际类型,记作C
- 在类型
C
的虚拟方法表中找是否存在和被调用方法相同的方法,进行访问权限校验,通过就返回这个方法的直接引用,否则就返回IllegalAccessError
异常 - 如果没有找到,就去
C
的各个父类中寻找 - 如果还是没找到,就抛出
java.lang.AbstractMethodError
异常,很好理解:抽象方法未被实现
这里需要多提一嘴,一个类被加载了,那么它的父类同样要被加载,而且是在子类加载之前被加载,并且jvm
会给每个加载的类维护一个常量池,记录了类的各种信息,包括字面量,属性,方法描述等,在子类调用方法的时候会去该类的常量池的虚拟表中查找是否有相同的方法,没有就继续去父类的常量池中寻找,而常量池中的方法,字段等都维护在各自的常量表中,虚拟方法表会在文章后面解释
就像这种,具体的类型有具体的表来存储,到这里就可以做一个结论了,方法重写的本质:
invokevirtual
指令执行第一步就是在运行期确定方法接收者的实际类型来选择对应的方法版本
静态方法为何不能被重写
回顾类加载阶段的解析阶段,我们都知道一个类被加载到jvm
中时,这个类的所有信息都会被存放在常量池中,并且在验证,准备的下一步解析中,会将代码中已经编译完成且确定不变的方法确定下来,具体怎么做的我们进行分析:
这是用来测试的代码:
public class overrideTest {
public static void main(String[] args) {
Father father = new Father();
Father f = new son();
son s = new son();
f.fun();
s.fun();
s.fun1();
System.out.println(s.count);
}
}
class Father{
int count = 9;
static void fun(){
System.out.println("father的静态方法");
}
void fun1(){
}
}
class son extends Father{
static void fun(){
System.out.println("son的静态方法");
}
}
我们反编译这篇代码,由于代码太多,我截取了其中一部分:
Code:
stack=2, locals=4, args_size=1
0: new #2 // class Father
3: dup
4: invokespecial #3 // Method Father."<init>":()V
7: astore_1
8: new #4 // class son
11: dup
12: invokespecial #5 // Method son."<init>":()V
15: astore_2
16: new #4 // class son
19: dup
20: invokespecial #5 // Method son."<init>":()V
23: astore_3
24: aload_2
25: pop
26: invokestatic #6 // Method Father.fun:()V
29: aload_3
30: pop
31: invokestatic #7 // Method son.fun:()V
34: aload_3
35: invokevirtual #8 // Method son.fun1:()V
38: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
41: aload_3
42: getfield #10 // Field son.count:I
45: invokevirtual #11 // Method java/io/PrintStream.println:(I)V
48: return
我们看看程序运行的结果:
father的静态方法
son的静态方法
9
在执行new
关键字创建对象时,会使用invokespecial
来调用此类的<init>
构造方法,我们从字节码指令就可以看出,我们还可以看出:当代码执行到f.fun();
的时候,对应的字节码文件是invokestatic #6
,而invokestatic
的作用是调用静态方法(这些指令的具体作用会在文章末尾显示), 不会和invokevritual
那样去访问虚拟表,这也就解释了静态方法的执行只看静态类型,而与实际类型无关,又因为重写的方法调用看的是实际类型,所以静态方法不能被重写
虚方法和非虚方法的指令
- invokespecial 调用实例构造器方法< init>(),私有方法,和父类中的方法
- invokevirtual 调用虚方法
- invokestatic 调用静态方法
- invokeinterface 调用接口方法,会在运行时再确定一个实现该接口的对象
- invokedynamic 动态解析方法
能被invokespecial
和invokestatic
调用的方法都叫非虚方法,比如静态方法,私有方法,实例构造器,父类方法,再加上final
修饰的方法,那么其他的方法就叫虚方法
虚拟方法表
在上文已经提及,这里说明一下:
在编程的时候会涉及很多方法的分配和调用,就拿方法重写来说,会在方法版本的选择时去实际类型的元数据搜索合适的方法,也就是常量池中寻找,影响效率,为了优化,jvm
采用在方法区建立为每一个类建立他们的虚拟方法表,使用该表可以提高性能,如图所示
简单阐述一下:
白色的部分是重写过的方法,在调用方法的时候会去虚拟表中调用自己的该方法,蓝色的部分是没有重写的,就表示要去父类中调用该方法
思考
在多态中,我们知道子类里除了重写父类的方法外,其他方法都不可调用,比如Father f = new Son()
,现在用我们今天学的知识来解释一下:
- 首先
son
被加载到内存中,其父类也被加载并初始 - 调用
son
独有的方法确抱错,按照顺序,应该是jvm
用invokevirtual
指令,去son
的虚拟方法表寻找该类的方法,并且该类也确实有这个方法,因为我们在代码中写了,是不是jvm
搞错了?其实并不是
解释:
在文章前部分我们就解释了:一个变量的类型是有静态类型决定的,这里的f
,虽然它指向堆空间上的son
对象,其实是一个father
类型,father
就不会有子类特有的方法,那么Java虚拟机
在加载的时候就按照它是father
类加载的!为其创建的虚拟方法表当然也是father
类中的方法了,我们接着上面的步骤往下走
- 虚拟机调用
invokevirtual
指令,去子类的虚方法表找是否有这个方法 - 找到了,并且通过了权限校验,就返回该方法的引用,
jvm
正式调用该方法