[原]JVM--详解虚拟机字节码执行引擎之静态链接、动态链接与多态性实现机制

董恒毅 17/12/09 22:46:52

兑现我之前的承诺,这篇博客我们来讲讲这个看起来十分难啃的骨头—静态链接与动态链接。


前言

从接触Java语言的第一天起,往后,我相信你一定听过什么动态链接啊,动态扩展啊,静态链接啊,它和C++相比有哪些优缺点啊… …如果你只是听说而没有探究过他们,也许你现在仍没办法说出Java语言到底有什么优缺点。

我们知道class文件是源代码经过编译后得到的字节码,如果学过编译原理会知道,这个仅仅完成了一半的工作(词法分析、语法分析、语义分析、中间代码生成),接下来就是实际的运行了。而Java选择的是动态链接的方式,即用到某个类再加载进内存,而不是像C++那样使用静态链接:将所有类加载,不论是否使用到。当然了,孰优孰劣不好判断。静态链接优点在速度,动态链接优点在灵活


静态链接

那么,首先,咱们先来聊聊静态链接。

如上面的概念所述,在C/C++中静态链接就是在编译期将所有类加载并找到他们的直接引用,不论是否使用到。而在Java中我们知道,编译Java程序之后,会得到程序中每一个类或者接口的独立的class文件。虽然独立看上去毫无关联,但是他们之间通过接口(harbor)符号互相联系,或者与Java API的class文件相联系

我们之前也讲述了类加载机制中的一个过程—解析,并在其中提到了解析就是将class文件中的一部分符号引用直接解析为直接引用的过程,但是当时我们并没有详细说明这种解析所发生的条件,现在我给大家进行补充:

方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。可以概括为:编译期可知、运行期不可变。

符合上述条件的方法主要包括静态方法和私有方法两大类。前者与类型直接关联,后者在外部不可被访问,这两种方法的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们适合在类加载阶段进行解析。

额外补充一点:

在Java虚拟机中提供了5条方法调用字节码指令,其中invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法(不知道这是个什么玩意、不重要,先放下)4类。它们在类加载的时候就会把符号引用解析为该方法的直接引用,因此这些方法也被称为非虚方法(包括final方法),与之相反的称为虚方法

解析调用一定是个静态过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用转化为可确定的直接引用,不会延迟到运行期再去完成,这也就是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((Man) 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 编译器就根据参数的静态类型决定使用哪个重载版本。这就是静态分派最典型的应用。

在静态分派中还有一个重载方法匹配优先级的问题,因为觉得并不是我要分享的重点,所以在这里我就不贴了,有兴趣的同学可以查阅《深入理解Java虚拟机》第249页或自行百度。


动态分派

动态分派与多态性的另一个重要体现——方法重写有着很紧密的关系。向上转型后调用子类覆写的方法便是一个很好地说明动态分派的例子。这种情况很常见,因此这里不再用示例程序进行分析。很显然,在判断执行父类中的方法还是子类中覆盖的方法时,如果用静态类型来判断,那么无论怎么进行向上转型,都只会调用父类中的方法,但实际情况是,根据对父类实例化的子类的不同,调用的是不同子类中覆写的方法,很明显,这里是要根据变量的实际类型来分派方法的执行版本。而实际类型的确定需要在程序运行时才能确定下来,这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

这里牵涉到invokevirtual指令的解析过程,不在这里详细解释了。有兴趣的同学操作步骤如上。

总结一下静态分派:注意静态类型,编译阶段。动态分派注意局部变量表、操作数栈、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 语言(JDK1.6)是一门静态多分派(方法重载)、动态单分派(方法重写)的语言


动态分派的实现

静态分派在编译阶段就确定了该执行的方法,也就是方法重载。因此我们并不讨论静态分派的实现机制。

动态分配是一个非常频繁的动作,且动态分派的方法版本选择过程需要在运行期才能搜索到合适的目标,如果我们不想办法进行相应的优化,如此频繁的搜索肯定是不会被那些科学家所接受。面对如此状况,我今天先叙述JVM中最常用的“稳定优化”手段—虚方法表,至于后面的内联缓存以及守护内联两种非稳定的“激进优化”手段,后面再说。

我们来看一下方法表结构:

这里写图片描述

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那么子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,如果子类重写了这个方法,子类方法表中的地址会替换为指向子类实现版本的入口地址。如上图所示,Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值之后,虚拟机会把该类的方法表也初始化完毕。


参考阅读

《深入理解Java虚拟机》—周志明

Understanding the JVM:虚拟机类加载机制

JVM-动态链接(Dynamic Linking and Resolution)

多态性实现机制——静态分派与动态分派

作者:championhengyi 发表于2017/12/9 22:46:52 原文链接
阅读:0 评论:0 查看评论