通过字节码分析-java静态方法为何不能被重写,重写和重载的本质

17 篇文章 1 订阅
14 篇文章 0 订阅

在进行文章论点分析之前,我们先要回顾一下方法调用过程,阅读本文需要对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 动态解析方法

能被invokespecialinvokestatic调用的方法都叫非虚方法,比如静态方法,私有方法,实例构造器,父类方法,再加上final修饰的方法,那么其他的方法就叫虚方法

虚拟方法表

在上文已经提及,这里说明一下:
在编程的时候会涉及很多方法的分配和调用,就拿方法重写来说,会在方法版本的选择时去实际类型的元数据搜索合适的方法,也就是常量池中寻找,影响效率,为了优化,jvm采用在方法区建立为每一个类建立他们的虚拟方法表,使用该表可以提高性能,如图所示
在这里插入图片描述
简单阐述一下:
白色的部分是重写过的方法,在调用方法的时候会去虚拟表中调用自己的该方法,蓝色的部分是没有重写的,就表示要去父类中调用该方法

思考

多态中,我们知道子类里除了重写父类的方法外,其他方法都不可调用,比如Father f = new Son(),现在用我们今天学的知识来解释一下:

  • 首先son被加载到内存中,其父类也被加载并初始
  • 调用son独有的方法确抱错,按照顺序,应该是jvminvokevirtual指令,去son的虚拟方法表寻找该类的方法,并且该类也确实有这个方法,因为我们在代码中写了,是不是jvm搞错了?其实并不是

解释
在文章前部分我们就解释了:一个变量的类型是有静态类型决定的,这里的f,虽然它指向堆空间上的son对象,其实是一个father类型,father就不会有子类特有的方法,那么Java虚拟机在加载的时候就按照它是father类加载的!为其创建的虚拟方法表当然也是father类中的方法了,我们接着上面的步骤往下走

  • 虚拟机调用invokevirtual指令,去子类的虚方法表找是否有这个方法
  • 找到了,并且通过了权限校验,就返回该方法的引用,jvm正式调用该方法
  • 4
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值