我们知道 class 文件是源代码经过编译后得到的字节码,如果学过编译原理会知道,这个仅仅完成了一半的工作(词法分析、语法分析、语义分析、中间代码生成),接下来就是实际的运行了。而 Java 选择的是动态链接的方式,即用到某个类再加载进内存,而不是像 C++ 那样使用静态链接:将所有类加载,不论是否使用到。当然了,孰优孰劣不好判断。静态链接优点在速度,动态链接优点在灵活。
静态链接
那么,首先,咱们先来聊聊静态链接。
如上面的概念所述,在 C/C++ 中静态链接就是在编译期将所有类加载并找到他们的直接引用,不论是否使用到。而在 Java 中我们知道,编译 Java 程序之后,会得到程序中每一个类或者接口的独立的 class 文件。虽然独立看上去毫无关联,但是他们之间通过接口(harbor)符号互相联系,或者与 Java API 的 class 文件相联系。
我们之前也讲述了类加载机制中的一个过程—解析,并在其中提到了解析就是将class文件中的一部分符号引用直接解析为直接引用的过程,但是当时我们并没有详细说明这种解析所发生的条件,现在我给大家进行补充:
方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。可以概括为:编译期可知、运行期不可变。
符合上述条件的方法主要包括静态方法和私有方法两大类。前者与类型直接关联,后者在外部不可被访问,这两种方法的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们适合在类加载阶段进行解析。
额外补充一点:
在Java虚拟机中提供了 5 条方法调用字节码指令,其中 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类。它们在类加载的时候就会把符号引用解析为该方法的直接引用,因此这些方法也被称为非虚方法(包括 final 方法),与之相反的称为虚方法。
方法调用指令
指令 | 解释 |
---|---|
invokevirtual | 指令用于调用对象的实例方法即非私有的实例方法,根据对象实际类型进行分派(虚方法分派) |
invokeinterface | 指令用于调用对象的接口方法,会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用 |
invokespecial | 指令用于调用一些需要特殊处理的实例方法,包括初始化方法、私有方法和父类方法 |
invokestatic | 指令用于调用类的 static 方法 |
invokedynamic | JDK7 之后支持,调用动态方法,在运行时动态解析出调用点限定符所引用的方法之后,调用该方法 |
解析调用一定是个静态过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用转化为可确定的直接引用,不会延迟到运行期再去完成,这也就是 Java 中的静态链接。
动态链接
上面大概说完了静态链接,那么什么是动态链接、它有什么用?
如上所述,在 Class 文件中的常量持中存有大量的符号引用。字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分在类的加载阶段(解析)或第一次使用的时候就转化为了直接引用(指向数据所存地址的指针或句柄等),这种转化称为静态链接。而相反的,另一部分在运行期间转化为直接引用,就称为动态链接。
与那些在编译时进行链接的语言不同,Java 类型的加载和链接过程都是在运行的时候进行的,这样虽然在类加载的时候稍微增加一些性能开销,但是却能为 Java 应用程序提供高度的灵活性,Java 中天生可以动态扩展的语言特性就是依赖动态加载和动态链接这个特点实现的。
动态扩展就是在运行期可以动态修改字节码,也就是反射机制与 cglib,有兴趣的朋友可以查一下。
分派
我们先不谈分派是什么,先来说说学习分派对你有什么用。
分派会解释多态性特征的一些最基本的体现,如“重载”、“重写”在 Java 虚拟机中是如何实现的,当然这里的实现不是语法上该怎么写,我们关心的是虚拟机如何确定正确的目标方法。
静态分派
这就是刚才不谈分派的原因,分派的概念比较泛,分为静态分派、动态分派、单分派、多分派。我们先来说说静态分派。
来看一下静态分派的概念:所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。
你应该会对静态类型这个名词感到疑惑。
再来解释一下:
Human man = new Man();
如上代码,Human 被称为静态类型,Man 被称为实际类型。
再来看一段代码:
//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化
StaticDispatch sr = new StaticDispatch();
sr.sayHello((Human) man);
sr.sayHello((Woman) man);
可以看到的静态类型和实际类型都会发生变化,但是有区别:静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的,而实际类型变化的结果在运行期才可确定。
知道这些东西之后,我给大家贴上完整代码:
class Human {
}
class Man extends Human {
}
class Woman extends Human {
}
public class StaticDispatch {
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);
}
}
运行结果:
hello, guy!
hello, guy!
如上代码与运行结果,在调用 sayHello() 方法时,方法的调用者都为 sr 的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型。代码中刻意定义了两个静态类型相同、实际类型不同的变量,可见编译器(不是虚拟机,因为如果是根据静态类型做出的判断,那么在编译期就确定了)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,所以在编译阶段,javac 编译器就根据参数的静态类型决定使用哪个重载版本。这就是静态分派最典型的应用。
动态分派
动态分派与多态性的另一个重要体现—方法重写有着很紧密的关系。向上转型后调用子类覆写的方法便是一个很好地说明动态分派的例子。这种情况很常见,因此这里不再用示例程序进行分析。很显然,在判断执行父类中的方法还是子类中覆盖的方法时,如果用静态类型来判断,那么无论怎么进行向上转型,都只会调用父类中的方法,但实际情况是,根据对父类实例化的子类的不同,调用的是不同子类中覆写的方法,很明显,这里是要根据变量的实际类型来分派方法的执行版本。而实际类型的确定需要在程序运行时才能确定下来,这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
总结一下静态分派:注意静态类型,编译阶段。动态分派注意局部变量表、操作数栈、invokevirtual 指令的解析过程。
单分派与多分派
先给出宗量的定义:方法的接受者(亦即方法的调用者)与方法的参数统称为方法的宗量。单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。
为了方便理解:
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。因为是根据两个宗量进行选择,所以 Java 语言的静态分派属于多分派类型。
再来看运行阶段虚拟机的选择,即动态分派过程。由于编译期已经了确定了目标方法的参数类型(编译期根据参数的静态类型进行静态分派),因此唯一可以影响到虚拟机选择的因素只有此方法的接受者的实际类型是 Father 还是 Child。因为只有一个宗量作为选择依据,所以 Java 语言的动态分派属于单分派类型。
总结
根据以上论证,我们可以总结如下:目前的 Java 语言是一门静态多分派(方法重载)、动态单分派(方法重写)的语言。
参考
https://www.javatt.com/p/45427
https://segmentfault.com/a/1190000022640316