你未必出类拔萃,但一定与众不同
方法调用
概述
方法调用并不等同与方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本,暂时还未涉及方法内部的具体运行过程。
在程序运行时,进行方法的调用是最普遍,最频繁的操作之一。
Class文件的编译过程中不包含传统语言编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。
解析
所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:
方法在程序真正运行事前就有一个可确定的调用版本,这个调用版本是不可改变的,换句话说,调用目标在程序代码写好,编译器进行编译的那一刻就已经定下来了,这类方法的调用被称为解析。
符合“编译器可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问。
调用不同类型的方法,字节码指令集里设计了不同的指令,在Java虚拟机支持一下5条方法调用字节码指令分别是
- invokestatic。用于调用静态方法
- invokespecial 用于调用实例构造器方法
- invokevirtual 用于调用所有的虚方法
- invokeinterface 用于调用接口方法 会在运行时再确定一个实现该接口的对象
- invokedynamic 先在运行时动态解析出调用点限定符所引用的方法,然后在执行该方法
public class Demo {
public static void test(){
System.out.println("TEST!");
}
public void hello(){
System.out.println("HELLO!");
}
public final void testHelloFinal(){
System.out.println("testHelloFinal!");
}
public final static void testHello(){
System.out.println("testHello!");
}
public static void main(String[] args) {
Demo demo = new Demo();
demo.hello();
demo.testHelloFinal();
Demo.test();
Demo.testHello();
}
}
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #8 // class day1034/Demo
3: dup
4: invokespecial #9 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #10 // Method hello:()V
12: aload_1
13: invokevirtual #11 // Method testHelloFinal:()V
16: invokestatic #12 // Method test:()V
19: invokestatic #13 // Method testHello:()V
22: return
LineNumberTable:
line 25: 0
line 26: 8
line 27: 12
line 28: 16
line 29: 19
line 30: 22
LocalVariableTable:
Start Length Slot Name Signature
0 23 0 args [Ljava/lang/String;
8 15 1 demo Lday1034/Demo;
由此可以看出 Java中的非虚方法除了使用invokestatic,invokespecial调用的方法之外还有一种就是被final修饰的实例方法,final方法是使用invokevirtual指令来调用的,但是由于无法被覆盖,没有其他版本可能,所以也无须对方法接受者进行多态选择。
分派
Java具有面向对象的三个基本特征:封装,继承,多态,而分派调用过程会揭示多态性特征的最基本的提现
静态分派
public class Demo {
static abstract class Human{
}
static class Man extends Human{
}
static class Woman extends Human{
}
public void sayHello(Human guy){
System.out.println("hello Human guy");
}
public void sayHello(Man guy){
System.out.println("hello Man guy");
}
public void sayHello(Woman guy){
System.out.println("hello Woman guy");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
Demo demo = new Demo();
demo.sayHello(man);
demo.sayHello(woman);
}
}
结果
hello Human guy
hello Human guy
Human man = new Man(); Human称之为变量的静态类型,或者叫外观类型,后面的Man则为实际类型,或者叫运行时类型
静态类型和实际类型在程序中都有可能会发生变化,区别是 静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的,而实际类型在运行期才能确定。
main里面的两次方法调用,在方法接受者确定对象是demo的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型,代码中定义了两个静态类型相同,而实际类型不同的变量,但虚拟机在重载时通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知,因此Javac编译器就根据参数的类型决定了会使用哪个重载版本,
所有依赖静态类型来决定方法执行版本的分派动作,都被成为静态分派,静态分派最典型的例子就是方法重载。
动态分派
动态分派则和重写有着很密切的关联
public class Test {
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在两次调用中还执行了两个不同的方法,导致这个现象的原因很明显,是因为这两个变量的实际类型不同
查看反编译以后的字节码
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class day1939/Test$Man
3: dup
4: invokespecial #3 // Method day1939/Test$Man."<init>":()V
7: astore_1
8: new #4 // class day1939/Test$Woman
11: dup
12: invokespecial #5 // Method day1939/Test$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method day1939/Test$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method day1939/Test$Human.sayHello:()V
24: new #4 // class day1939/Test$Woman
27: dup
28: invokespecial #5 // Method day1939/Test$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method day1939/Test$Human.sayHello:()V
36: return
LineNumberTable:
line 31: 0
line 32: 8
line 33: 16
line 34: 20
line 35: 24
line 36: 32
line 37: 36
LocalVariableTable:
Start Length Slot Name Signature
0 37 0 args [Ljava/lang/String;
8 29 1 man Lday1939/Test$Human;
16 21 2 woman Lday1939/Test$Human;
0-15行是准备动作,作用是简历man和woman的内存空间,调用Man和Woman的实例构造器,将实例引用存放在第1,2局部变量表的变量槽中
接下来16-21行aload指令分别把刚刚创建的两个对象的引用压到栈顶,这两个方法是hi将要执行的sayHello方法的所有者,称为接受者
17-21行是方法调用指令,这指令无论是参数还是指令都是完全一样的 但是这两句指令的最终执行的目标方法并不相同,因此需要从invokevirtual入手
invokevirtual的运行时解析过程分为以下几步
- 找到操作数栈顶的第一个元素所指向的对象的实际类型记做C
- 如果在类型 C中找到宇常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,不通过则返回java.lang.IllegalAccessError异常
- 否则,按照继承关系从上往下对C的各个父类进行第二步的搜索和验证过程
- 如果始终没有找到合适 的方法,则抛出java.lang.AbstractMethodError异常
正是因为invokevirtual指令执行的第一步就是在运行期间确定接受者的实际类型,两次调用invokevirtual指令并不是把常量池中的方法的符号引用解析到直接引用上就结束了,还会根据方法接受者的实际类型来选择方法版本。这个过程就是重写的本质
我们把这种在运行期间根据实际类型确定方法执行版本的分派过程称为动态分派
单分派与多分派
方法的接受者与方法的参数统称为方法的宗量
根据分派基于多少种宗量 可以将分派划分为单分派和多分派两种,单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择
public class Dispatch {
static class War{
}
static class Peace{
}
public static class People{
public void choose(War arg){
System.out.println("people choose war");
}
public void choose(Peace arg){
System.out.println("people choose peace");
}
}
public static class BadPerson extends People{
public void choose(War arg){
System.out.println("BadPerson choose war");
}
public void choose(Peace arg){
System.out.println("BadPerson choose peace");
}
}
public static void main(String[] args) {
People people = new People();
People badPerson = new BadPerson();
people.choose(new Peace());
badPerson.choose(new War());
}
}
结果
people choose peace
BadPerson choose war
main方法里调用了两次choose方法,这两次choose方法的选择结果却不相同
首先关注编译阶段中编译器的选择过程,也就是静态分派的过程,这是依据两点选择目标方法
- 静态类型是People还是BadPerson
- 方法参数是War还是Peace
这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令分别为常量池中指向People::choose(war)和People::choose(peace)方法的引用,因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型
运行阶段中虚拟机的选择,也就是动态分派的过程,执行badPerson.choose(new War());这段代码
也就是在执行这段代码的对应的invokevirtual指令,由于编译期已经决定目标方法的签名必须为.choose( War);虚拟机不会关注到底传递过来的参数 是war还是哪场战争,因为这时候参数的静态类型,实际类型都对方法的选择不会产生影响,唯一可以影响虚拟机选择的因素只有该方法的接受者的实际类型是people还是badperson,因为只有一个宗量作为选择考量因此Java 语言的动态分派属于单分派类型
虚拟机动态分派的实现
动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时再接受者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑。真正运行时一般不会如此频繁地区反复搜索类型元数据。
而是使用虚方法表来代替元数据查找以提高性能
方法表结构
虚方法表中存放各个方法的实际入口地址
如果某个方法在子类中没有重写,那子类的虚方法表的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口
如果子类重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本 的入口地址。
虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机 将该类的虚方法表一同初始完毕