深入解析多态和方法调用在JVM中的实现

1. 什么是多态

多态(polymorphism)是面向对象编程的三大特性之一,它建立在继承的基础之上。在《Java核心技术卷》中这样定义:

一个对象变量可以指示多种实际类型的现象称为多态。

在面向对象语言中,多态性允许你将一个子类型的实际对象赋予给一个父类型的变量。在这样的赋值完成之后,父类变量就可以根据实际赋予它的子类对象的不同,而以不同的方式工作。

在下面的示例中,Son类继承了Father类并重写了f()方法,又将Son类型的对象赋值给Father类型的变量,再用它调用f()方法,稍微有点Java基础的程序员都知道,此时会使用的是Son类中的f(),这种重写就是一种典型的多态的体现。

class Father{
f(){ ... }
} class Son extends Father{
f(){ ... }
} // 调用代码
Father object = new Son();
object.f();

在一些资料中,也把重载称为一种多态的表现形式,本文也将重载视为多态的一种进行讲解,但这种说法确实尚存争议。

2. 一些知识准备

2.1 运行时栈帧结构

Java虚拟机规范中,为所有的Java虚拟机字节码执行引擎规定了统一的输入输出:

  • 输入为字节码形式的二进制流。
  • 输出为执行结果。

在解释运行阶段,JVM以方法作为最基本的执行单元栈帧是用于支持虚拟机进行方法调用和执行的数据结构,每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。处于栈顶的栈帧就是当前栈帧,对应的方法就是正在运行的当前方法

在这里我们以服务解释方法调用为前提,简单说明JVM的运行时栈帧结构

  • 局部变量表。用于存放方法参数和方法内部定义的局部变量。
  • 操作数栈。一个后入先出的LIFO栈,辅助方法执行中的运算操作。
  • 动态连接。动态连接是一个指向运行时常量池中该栈帧所属方法的引用,指向的显然是一个符号引用。它的存在主要是支持方法调用过程中的动态连接。
    • 方法调用中,符号引用一部分在类加载或者第一次使用时被转化成直接引用,这种转化称为静态解析
    • 另外一部分符号引用在每一次运行期间都转化为直接引用,这种转化称为动态连接
  • 方法返回地址。
    • 正常退出方法时,方法返回地址指向主调方法的PC计数器。
    • 异常退出方法时,方法返回地址指向异常处理表。
  • 附加信息。服务于调试、性能收集等等。

2.2 方法调用字节码指令

针对不同类型的方法,Java虚拟机支持以下五种方法调用字节码指令

  • invokestatic。用于调用静态方法。
  • invokespecial。用于调用实例构造器<init>()方法、私有方法和父类中的方法。
    • 在Java11以后,invokespecial已经常常不被用来调用私有方法,详见下文的实验和说明。
  • invokevirtual。用于调用所有的虚方法。
  • invokeinterface。用于调用接口方法。在运行时确定实现该接口的对象。
  • invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后执行该方法。
    • 详见《深入理解Java虚拟机》p321

非虚方法指那些能够在解析阶段确定唯一的调用版本的方法,即上面由invokestaticinvokespecial调用的那些方法。而其他那些属于类的,需要在运行时动态确定调用版本的方法,我们称之为虚方法,最常见的虚方法就是普通的实例方法。

下面我们用字节码的形式看看这些方法调用指令。

// Java代码
public class Test {
public static void staticMethod() {
System.out.println("static method");
} private void privateMethod() {
System.out.println("private method");
} public static void main(String[] args) {
Test.staticMethod(); new Test().privateMethod();
}
} javac Test.java
javap -verbose Test // javap工具得到的main部分的字节码文件
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: invokestatic #23 // Method staticMethod:()V
3: new #24 // class Test
6: dup
7: invokespecial #28 // Method "<init>":()V
10: invokevirtual #29 // Method privateMethod:()V
13: return
LineNumberTable:
line 12: 0
line 14: 3
line 15: 13

在上面的代码中,我们显然可以看到,staticMethod使用invokestatic来进行调用,"<init>"构造方法使用了invokespecial来调用,这些都符合上面的约定。

但是!作为私有方法的privateMethod方法,却在字节码中被编译为使用invokevirtrual指令来调用。这是为什么呢?

笔者查阅资料后,发现在JEP181中,对方法调用字节码指令进行了一定程度上的修改。在Java11版本及以后,嵌套类之间的私有方法的访问权限控制,就从编译期转移到了运行时,从而这样的私有方法也被使用invokevirtual指令来调用,

总而言之,在Java11及以后,类中的私有方法往往用invokevirtual来调用,接口中的私有方法往往用invokeinterface调用,invokespecial往往仅用于实例构造器方法和父类中的方法。

2.3 字节码方法解析过程

解析过程是JVM将常量池内的符号引用替换为直接引用的过程。

  • 符号引用以一组符号来描述所引用的目标,符号可以是任意形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用是可以直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。

《Java虚拟机规范》中明确要求在执行方法调用字节码指令之前,必须先对它们使用的符号引用进行解析。即所有invoke...指令之前。由于对同一个符号引用收到多次解析请求是很常见的事,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。(invokedynamic有一些特殊性质,这里不做解释)。

方法解析第一步需要解析出方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,那么用C表示这个类,接下来虚拟机将按照以下步骤进行后续的方法搜索。

  • 如果我们在解析一个类方法,但C是一个接口,直接抛出java.lang.IncompatibleClassChangeError异常。

    • 如果我们在解析的是接口方法,但C是一个类,也抛出java.lang.IncompatibleClassChangeError异常。
  • 如果通过了第一步,在C中查找是否有简单名称和描述符都与目标匹配的方法,有则返回直接引用。

  • 否则,依次在C的父类、接口列表、父接口中进行查找。如果找到则根据情况返回直接引用或者抛出java.lang.AbstractMethodError异常。

  • 如果都找不到,说明方法查找失败。抛出java.lang.NoSuchMethodError

  • 最后,如果成功返回了直接引用,就对这个方法进行权限验证,如果发现不具备对此方法的访问权限,则抛出java.lang.IllegalAccessError异常。

2.4 静态类型和实际类型

已知有类FatherSon,且Son类继承了Father类。假设我们以以下方式初始化变量。

class Father{}
class Son extends Father{} Father object = new Son();

那我们把上面代码中的Father称为变量object的静态类型外观类型,将Son称为object的实际类型运行时类型

当变量被定义的时候,它的静态类型就已经确定,而实际类型可能会在运行过程中不断变化,例如下面给出一个例子。

class Father{}
class Son extends Father{}
class Daughter extends Father{} Father object = new Random().nextBoolean() ? new Son() : new Daughter();

这个例子中,object的静态类型始终是Father,而实际类型就只有到运行时才知道了。

3.方法调用

3.1 解析

非虚方法,即使用invokespecialinvokestatic指令调用的方法,由于无法被覆盖,不可能存在其他版本,所以可以在类加载的解析阶段直接进行方法解析,将符号引用全部转变为明确的直接引用,不必延迟到运行期完成。

解析调用一定是一个静态的过程,在编译期间就完全确定。

值得说明的一点是,《Java虚拟机规范》明确地将final方法定义为非虚方法,但final方法是使用invokevirtual调用的,故使用下面讲的分派机制,而非解析。

3.2 静态分派

静态分派用于解释重载的场景,下面给出一个简单的例子

public class Test {
public void overLoad(Father father){
System.out.println("get father method");
} public void overLoad(Son father){
System.out.println("get son method");
} public static void main(String[] args) {
Test test = new Test(); Father object = new Son(); test.overLoad(object);
}
} class Father{}
class Son extends Father{} //运行结果
get father method

显然,JVM选择了参数类型为Father的重载方法。

在虚拟机处理重载的情况时,是通过参数的静态类型而不是实际类型作为判断依据的。由于静态类型在编译期可知,所以在编译阶段Javac编译器就根据参数的静态类型决定了会使用哪个重载版本。比如上面会选择overload(Father)作为调用目标,并把这个方法的符号引用写入到main()方法的invokevirtual指令的参数中,后续在解释阶段执行invokevirtual时,这个选好的方法就会直接被使用。这个操作是在Javac前端编译的语法分析阶段直接完成的。

值得注意的是Javac编译器确定的重载版本并非确定的某一个,而是在现有的选择中选择的“最合适的”一个。下面给出一个示例。

public class Overload {
// 从上到下,优先级递减
public static void sayHello(char arg) {
System.out.println("hello char");
} 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(Object arg) {
System.out.println("hello Object");
} public static void sayHello(Serializable arg) {
System.out.println("hello Serializable");
} public static void sayHello(char... arg) {
System.out.println("hello char ...");
} public static void main(String[] args) {
sayHello('a');
}
}

假如按照上面的代码运行,那么会被调用的是sayHello(char arg)方法,这就是Javac认为的最合适的方法。但假如我们将sayHello(char arg)注释掉,那么会被调用的是sayHello(int arg)方法,以此类推。

当然,一个脑子正常的程序员,不应该在自己的任何工程中写出上述这样的重载代码。

3.3 动态分派

静态分派用于解释重写的场景,下面给出一个简单的例子

public class Test {
public static void main(String[] args) {
Father object = new Son(); object.override();
}
} class Father{
public void override(){
System.out.println("get father method");
}
} class Son extends Father{
public void override(){
System.out.println("get son method");
}
} //运行结果
get son method

显然,JVM选择了子类Son的重写方法。显然,在进行动态分派的时候,选择方法的依据是调用方法的变量的实际类型。为了解释清楚invokevirtual的作用方式,我们使用javap命令输出这段代码中main部分的字节码。

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #7 // class Son
3: dup
4: invokespecial #9 // Method Son."<init>":()V
7: astore_1
8: aload_1
9: invokevirtual #10 // Method Father.override:()V
12: return
LineNumberTable:
line 3: 0
line 5: 8
line 6: 12

0 ~ 7 行的字节码是一些准备工作。创建了用于存放变量object的内存空间,调用了对应的构造器,并将对象实例存放在了局部变量表的第一个槽中。实际上对应代码中下面这行。

Father object = new Son();

第 8 行 的aload_1指令将刚刚创建的object对象引用压到了操作数栈顶,这个对象即将调用override()方法。

第 9 行,正式使用了方法调用字节码指令invokevirtual。根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程分为以下几步。

  • 找到操作数栈顶第一个元素指向的对象的实际类型并记作C。
  • 在C中查找是否有简单名称和描述符都与目标匹配的方法,有则返回直接引用。
    • 这里所谓的“目标”,是目标方法的简单外观,在编译阶段就已经传递给invokevirtual作为参数
  • 否则,依次在C的父类、接口列表、父接口中进行查找。如果找到则根据情况返回直接引用或者抛出java.lang.AbstractMethodError异常。
  • 如果都找不到,说明方法查找失败。抛出java.lang.NoSuchMethodError
  • 最后,如果成功返回了直接引用,就对这个方法进行权限验证,如果发现不具备对此方法的访问权限,则抛出java.lang.IllegalAccessError异常。

你应该可以看出来,其实就是我们在2.3节中讲的字节码方法解析。重点就是我们从操作数栈顶找到了第一个元素指向的实际类型,并用它为基础来做接下来的方法查找。这种运行期根据实际类型确定方法执行版本的分派过程称为动态分派

这里再给出一个示例,帮助读者更深入地了解动态分派。

public class FieldHasNoPolymorphic {

    static class Father {
public int money = 1; public Father() {
money = 2;
showMeTheMoney();
} public void showMeTheMoney() {
System.out.println("I am Father, i have $" + money);
}
} static class Son extends Father {
public int money = 3; public Son() {
money = 4;
showMeTheMoney();
} public void showMeTheMoney() {
System.out.println("I am Son, i have $" + money);
}
} public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
} // 输出结果
I am Son, i have $0
I am Son, i have $4
This gay has $2

应该不难理解,第一行的输出来自父类Father构造器调用子类的showmeTheMoney()方法,此时子类尚未初始化,所以结果为0。

第二行的输出来自子类调用showmeTheMoney()方法,此时子类已经初始化,结果为4。

第三行的输出,使用gay.money直接取值,注意这个时候通过静态类型访问变量,自然没有类似invokevirtual的东西来找所谓的实际类型。所以使用的是变量 gay 的静态类型,那么就从Father类中取值,取到money的值为2。

所以,动态分派仅限于方法!

4. 知识补充

4.1 单分派与多分派

方法的接收者和方法的参数统称为方法的宗量。选择方法时使用一种宗量称为单分派,使用多种宗量称为多分派。那么显而易见的,我们可以总结出Java是一种静态多分派,动态单分派的语言。

  • 静态多分派:在静态分派的过程中,即重载的过程中,我们同时将方法的接收者和方法的参数作为选择方法的依据,所以是多分派。
  • 动态单分派:在动态分派的过程中,方法的参数模式在编译阶段就已经确定,唯一动态决定的是方法接收者的实际类型,所以是单分派。

注:方法的接收者指调用方法的对象。如object.f(),那么object就是方法的接收者。

4.2 虚拟机动态分派的优化实现

我们可以想见的是,在代码运行过程中,一个虚方法可能会被大量多次地调用。所以一种在现代JVM中常见的优化手段是创建一个虚方法表,同理对于invokeinterface指令,也有接口方法表,它们的结构如下所示。

虚方法表中存放的是各种方法的实际入口地址。如果父类的方法在子类中没有重写,那么子类虚方法表中的地址入口和父类虚方法表中的入口地址是一致的,都指向父类的实现。否则子类的地址入口就会指向自己的实现。这样可以节省大量的,动态分派过程中搜索方法的开销。

同时要求在父类和子类的虚方法表中,具有相同签名的方法应该具有相同的索引序号,这样当类型动态发生变化的时候,只需要动态改变要查找的虚方法表,而不需要重新考虑在表中的位置。

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

4.2 虚方法的方法内联

方法内联是编译器最重要的优化手段!简单说就是把目标代码以类似复制的方式替换到调用方法的位置,避免发生真实的方法调用。下面是一个示例。

// 内联前的代码
static class C {
int val;
final int get(){
return val;
}
} public void f(){
C c = new C();
int x = c.get();
int y = c.get();
int sum = x + y;
} // 内联后的代码
public void f(){
C c = new C();
int x = c.val;
int y = c.val;
int sum = x + y;
}

方法内联有两个重要功能

  • 去除方法调用的成本,包括查找方法版本和建立栈帧等。
  • 为建立其他优化打好基础。

所以我们称方法内联为最重要的优化手段。然而在Java虚拟机中,方法内联却有着一些天生的问题存在。对于Java中的虚方法,在将Java代码翻译为字节码的编译阶段,很多情况下编译器根本不可能确定该使用哪个方法版本。而Java作为面向对象的语言,在Java编程中绝大多数的方法都是虚方法,绝大多数的方法调用都是invokevirtualinvokeinterface负责的。

但是方法内联对于优化来说又过于重要,所以Java虚拟机的设计者们想了很多办法来尽量解决问题。

Java虚拟机引入了一种名为类型继承关系分析(CHA)的技术,它用于确定在目前已经加载的类中,那些虚方法是否存在多个版本。根据分析结果的不同,Java虚拟机可以采取不同的处理方法。

  • 假如只有一个方法,那么就可以直接进行内联,即假设整个应用程序也只有这一个版本。这种内联被称为守护内联。当然我们知道,并不是所有的类都被加载,保不齐未来就会有这个方法的新版本出现,所以我们预留好了逃生门,当假设不成立时就通过逃生门抛弃掉已经编译的代码,退回到解释状态进行执行,或者重新进行编译。
  • 假如有多个方法版本可供选择,那么编译器会尝试使用内联缓存的方式来减少方法调用的开销。内联缓存的基本原理很好理解,就是当方法第一次调用发生后,缓存下方法接收者的版本信息和对应的方法调用点。
    • 每次方法调用时都比较接收者的版本,如果版本不变,那么就是一种单态内联缓存。通过该缓存进行调用就解除了方法搜索带来的开销,而仅仅多了一个比较版本的微小开销。
    • 如果版本发生改变,说明程序用到了虚方法的多态特性,这时候会退化成超多态内联缓存,这里说是一种内联缓存,其实就是不要缓存了,直接正常进行动态分派操作。
    • 当缓存未命中的时候,大多数JVM的实现时退化成超多态内联缓存,也有一些JVM选择重写单态内联缓存,就是更新缓存为新的版本。这样做的好处是以后还可能会命中,坏处是可能白白浪费一个写的开销。

深入解析多态和方法调用在JVM中的实现的更多相关文章

  1. java中方法调用在内存中的体现

    在java中,方法以及局部变量(即在方法中声明的变量)是放在栈内存上的.当你调用一个方法时,该方法会放在调用栈的栈顶.栈顶的方法是目前正在执行的方法,直到执行完毕才会从栈顶释放.我们知道,栈是一种执行 ...

  2. 欧莱雅浅谈OC中方法调用的顺序中的Category

    OC特有的分类Category,依赖于类.它可以在不改变原来的类内容的基础上,为类增加一些方法.分类的使用注意: (1)分类只能增加方法,不能增加成员变量: (2)在分类方法的实现中可以访问原来类中的 ...

  3. PHP通过反射方法调用执行类中的私有方法

    PHP 5 具有完整的反射 API,添加了对类.接口.函数.方法和扩展进行反向工程的能力. 下面我们演示一下如何通过反射,来调用执行一个类中的私有方法: <?php //MyClass这个类中包 ...

  4. JVM(十二):方法调用

    JVM(十二):方法调用 在 JVM(七):JVM内存结构 中,我们说到了方法执行在何种内存结构上执行:Java 方法活动在虚拟机栈中的栈帧上,栈帧的具体结构在内存结构中已经详细讲解过了,下面就让我们 ...

  5. Android中使用ContentProvider进行跨进程方法调用

    原文同一时候发表在我的博客 点我进入还能看到很多其它 需求背景 近期接到这样一个需求,须要和别的 App 进行联动交互,比方下载器 App 和桌面 App 进行联动.桌面的 App 能直接显示下载器 ...

  6. struts2的通配符与动态方法调用

    1.Action标签中的method属性 我们知道action默认的执行的方法是execute方法,但是一个action只执行一个方法我们觉得有点浪费,我们希望在一个action中实现同一模块的不同功 ...

  7. 从字节码指令看重写在JVM中的实现

    Java是解释执行的.包含动态链接的特性.都给解析或执行期间提供了非常多灵活扩展的空间.面向对象语言的继承.封装和多态的特性,在JVM中是怎样进行编译.解析,以及通过字节码指令怎样确定方法调用的版本号 ...

  8. 《深入Java虚拟机学习笔记》- 第7章 类型的生命周期/对象在JVM中的生命周期

    一.类型生命周期的开始 如图所示 初始化时机 所有Java虚拟机实现必须在每个类或接口首次主动使用时初始化: 以下几种情形符合主动使用的要求: 当创建某个类的新实例时(或者通过在字节码中执行new指令 ...

  9. 多态:JVM是如何进行方法调用的

    在我们平时的工作学习中写java代码时,如果我们在同一个类中定义了两个方法名和参数类型都相同的方法时,编译器会直接报错给我们.还有在代码运行的时候,如果子类定义了一个与父类完全相同的方法的时候,父类的 ...

随机推荐

  1. java基础---枚举类与注解

    一.枚举类 类的对象只有有限个,确定的.我们称此类为枚举类 如果枚举类中只有一个对象,则可以作为单例模式的实现方式. 定义枚举类 方式一:jdk5.0之前,自定义枚举类 public class Se ...

  2. Spring Boot(二):Spring Boot中的配置参数

    Spring Boot 配置参数 Spring Boot 帮助我们完成了许许多多的自动化配置 如果我们需要根据自己的需求修改配置 也是可以的 可以使用.properties 和 .yml 格式配置 这 ...

  3. C语言:字符型数据(常量)

    字符型数据就是字符. 字符型数据的表示 字符型数据是用单引号括起来的一个字符.例如:'a'.'b'.'='.'+'.'?'都是合法字符型数据.在C语言中,字符型数据有以下特点: 字符型数据只能用单引号 ...

  4. 高校表白App-团队冲刺第九天

    今天要做什么 在Fragment首页加上轮转播报,点击图片进入相应连接 做了什么 功能实现,通过连接第三方库来进行实现,比较简单.(url就可以) 遇到的问题 在调用以前的工具类时,有点小问题,发现以 ...

  5. 微信小程序云开发-云存储的应用-识别驾驶证

    一.准备工作 1.创建云函数identify 2.云函数identify中index.js代码 1 // 云函数入口文件 2 const cloud = require('wx-server-sdk' ...

  6. 微信小程序云开发-云函数-云函数获取参数并实现运算

    1.编写加法运算的云函数addData 2.在本地小程序页面调用云函数

  7. Odoo的附件大小限制

    Odoo使用binary类型来保存附件数据,可以直接支持附件数据的上传.但是在实际使用中,有可能遇到附件文件大小超过限制的情况,如下图: 但是ERP定制过程中难免会遇到客户确实需要上传超大附件,那么怎 ...

  8. 添加底部导航栏tabbar

    效果图: 如果要添加底部导航栏,最少2个,最多5个. app.json { "pages": [ "pages/index/index", "page ...

  9. .NET同步原语Barrier简介

    Barrier(屏障)是一种自定义的同步原语(synchronization primitive),它解决了多个线程(参与者)在多个阶段之间的并发和协调问题. 1)多个参与者执行相同的几个阶段的操作 ...

  10. 获取不到自定义的request的header属性

    java获取headers的代码如下: // 获取http-header里面对应的签名信息 Enumeration<?> headerNames = request.getHeaderNa ...