Java虚拟机(十二)------方法调用和多态实现机制

本文探讨了Java中方法调用的过程,包括静态解析与动态连接的概念,详细解释了静态分派与动态分派的区别及应用场景,特别是针对重载与覆写的不同行为。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

方法调用

方法调用并不等同于方法执行。

方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。

在程序运行时,进行方法调用是最普遍、 最频繁的操作。
 

1. 静态解析

Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址,这个特性给Java带来了更强大的动态扩展能力。

  • 静态解析:一部分方法的符号引用在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析;
  • 动态连接:在类运行期间才能确定某些目标方法的直接引用;

静态解析成立的前提是:方法在程序真正执行前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在编译器进行编译时就必须确定下来,这类方法的调用称为解析。在Java语言中,符合“编译器可知,运行期不可变”这个要求的方法主要有静态方法私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法都不可能通过继承或别的方式重写出其他的版本,因此它们都适合在类加载阶段进行解析。

方法调用字节指令

Java虚拟机里共提供了四条方法调用字节指令,分别是:

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
  • invokevirtual:调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。

虚方法和非虚方法

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法四类,它们在类加载时就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法(还包括final方法),与之相反,其他方法就称为虚方法(final方法除外)。

  • 这里要特别说明下final方法,虽然调用final方法使用的是invokevirtual指令,但是由于它无法覆盖,没有其他版本,所以也无需对方发接收者进行多态选择。Java语言规范中明确说明了final方法是一种非虚方法

下面代码演示了一个最常见的解析调用的例子,静态方法sayHello()只可能属于类型StaticResolution,没有任何手段可以覆盖或隐藏这个方法。

/**
*方法静态解析演示
**
*/
public class StaticResolution{
    public static void sayHello(){
        System.out.println("hello world");
    }
    public static void main(String[]args){
        StaticResolution.sayHello();
    }
}

使用javap命令查看这段程序的字节码,会发现的确是通过invokestatic命令来调用sayHello()方法的。

public static void main(java.lang.String[]);
Code:
Stack=0,Locals=1,Args_size=1
0:invokestatic#31;//Method sayHello:()V
3:return
LineNumberTable:
line 15:0
line 16:3


 

2. 分派

分派调用则可能是静态的也可能是动态的,根据分派依据的宗量数(方法的调用者和方法的参数统称为方法的宗量)又可分为单分派和多分派。两类分派方式两两组合便构成了静态单分派、静态多分派、动态单分派、动态多分派四种分派情况。

静态分派   

所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派发生在编译阶段,因此确定静态分配的动作实际上不是由虚拟机来执行的。

静态分派的最典型应用就是多态性中的方法重载。下面就通过一段方法重载的示例程序来更清晰地说明这种分派机制:

class Human{
}  
class Man extends Human{
}
class Woman extends Human{
}
 
public class StaticPai{
 
	public void say(Human hum){
		System.out.println("I am human");
	}
	public void say(Man hum){
		System.out.println("I am man");
	}
	public void say(Woman hum){
		System.out.println("I am woman");
	}
 
	public static void main(String[] args){
		Human man = new Man();
		Human woman = new Woman();
		StaticPai sp = new StaticPai();
		sp.say(man);
		sp.say(woman);
	}
}

    上面代码的执行结果如下:

    I am human
    I am human

以上结果的得出应该不难分析。在分析为什么会选择参数类型为Human的重载方法去执行之前,先看如下代码:

Human man = new Man();

我们把上面代码中的“Human”称为变量的静态类型,后面的“Man”称为变量的实际类型。静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的,而实际类型变化的结果在运行期才可确定。例如下面的代码:

//实际类型变化
Human man=new Man();
man=new Woman();
//静态类型变化
sp.say((Man)man)
sp.say((Woman)man)

回到上面的代码分析中,在调用say()方法时,方法的调用者(回忆上面关于宗量的定义,方法的调用者属于宗量)都为sp的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型(方法的参数也是数据宗量)。代码中刻意定义了两个静态类型相同、实际类型不同的变量,可见虚拟机(准确的说是编译器,因为如果是根据静态类型做出的判断,那么在编译期就确定了)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,所以在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本。这就是静态分派最典型的应用。

编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。下面代码清单演示了何为“更加合适的”版本。

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

这时发生了一次自动类型转换,'a'除了可以代表一个字符串,还可以代表数字97(字符'a'的Unicode数值为十进制数字97),因此参数类型为int的重载也是合适的。 我们继续注释掉sayHello(int arg)方法,那输出会变为:

hello long

这时发生了两次自动类型转换,'a'转型为整数97之后,进一步转型为长整数97L,匹配了参数类型为long的重载。 笔者在代码中没有写其他的类型如float、 double等的重载,不过实际上自动转型还能继续发生多次,按照char->int->long->float->double的顺序转型进行匹配。 但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。 我们继续注释掉sayHello(long arg)方法,那输出会变为:

hello Character

这时发生了一次自动装箱,'a'被包装为它的封装类型java.lang.Character,所以匹配到了参数类型为Character的重载,继续注释掉sayHello(Character arg)方法,那输出会变为:

hello Serializable

这个输出可能会让人感觉摸不着头脑,一个字符或数字与序列化有什么关系?出现hello Serializable,是因为java.lang.Serializable是java.lang.Character类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生一次自动转型。 char可以转型成int,但是Character是绝对不会转型为Integer的,它只能安全地转型为它实现的接口或父类。 Character还实现了另外一个接口java.lang.Comparable<Character>,如果同时出现两个参数分别为Serializable和Comparable<Character>的重载方法,那它们在此时的优先级是一样的。 编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。 程序必须在调用时显式地指定字面量的静态类型,如sayHello((Comparable<Character>)'a'),才能编译通过。下面继续注释掉sayHello(Serializable arg)方法,输出会变为:

hello Object

这时是char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。 即使方法调用传入的参数值为null时,这个规则仍然适用。我们把sayHello(Object arg)也注释掉,输出将会变为:

hello char……

7个重载方法已经被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时候字符'a'被当做了一个数组元素。 但要注意的是,有一些在单个参数中能成立的自动转型,如char转型为int,在变长参数中是不成立的。

 

动态分派  

动态分派与多态性的另一个重要体现——方法覆写有着很紧密的关系。向上转型后调用子类覆写的方法便是一个很好地说明动态分派的例子:

/**
*方法动态分派演示
*/

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

这个运行结果相信不会出乎任何人的意料,现在的问题还是和前面的一样,虚拟机是如何知道要调用哪个方法的?

显然这里不可能再根据静态类型来决定,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。 导致这个现象的原因很明显,是这两个变量的实际类型不同,所以要根据变量的实际类型来分派方法的执行版本的。而实际类型的确定需要在程序运行时才能确定下来,那Java虚拟机是如何根据实际类型来分派方法执行版本的呢?

  • 由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

 

单分派和多分派

方法的接受者(即方法的调用者)与方法的参数统称为方法的宗量

  • 单分派是根据一个宗量对目标方法进行选择;
  • 多分派是根据多于一个宗量对目标方法进行选择;

下面给出一段示例代码:

class Eat{
}
class Drink{
}
 
class Father{
	public void doSomething(Eat arg){
		System.out.println("爸爸在吃饭");
	}
	public void doSomething(Drink arg){
		System.out.println("爸爸在喝水");
	}
}
 
class Child extends Father{
	public void doSomething(Eat arg){
		System.out.println("儿子在吃饭");
	}
	public void doSomething(Drink arg){
		System.out.println("儿子在喝水");
	}
}
 
public class SingleDoublePai{
	public static void main(String[] args){
		Father father = new Father();
		Father child = new Child();
		father.doSomething(new Eat());
		child.doSomething(new Drink());
	}
}

运行结果:  

爸爸在吃饭
儿子在喝水
  • 首先来看编译阶段编译器的选择过程,即静态分派过程:

这时候选择目标方法的依据有两点:一是方法的接受者(即调用者)的静态类型是Father还是Child,二是方法参数类型是Eat还是Drink。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向father.doSomething(new Eat())和child.doSomething(new Drink())方法的符号引用,因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

  • 然后来看运行阶段虚拟机的选择,即动态分派过程:

由于编译期已经了确定了目标方法的参数类型(编译期根据参数的静态类型进行静态分派),因此唯一可以影响到虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Child。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

     

根据以上论证可以总结如下:目前的Java语言是一门静态多分派、动态单分派的语言。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值