JVM系列之:JVM如何执行方法调用

2,600 阅读27分钟

本文为《深入学习 JVM 系列》第四篇文章

背景

我们来看一下某个类中这样一段代码:

  public static void main(String[] args) {
    OverWriteCode object = new OverWriteCode();
    object.invoke(null, 1);
    object.invoke(null, 1, 2);
    //只有手动绕开可变长参数的语法糖,才能调用第一个invoke方法
    object.invoke(null, new Object[]{1});
  }

  public void invoke(Object obj, Object... args) {
    System.out.println("print invoke1");
  }

  public void invoke(String s, Object obj, Object... args) {
    System.out.println("print invoke2");
  }

上述代码中定义了两个同名的重载方法:第一个接收一个 Object,以及声明为 Object…的变长参数;而第二个则接收一个 String、一个 Object,以及声明为 Object…的变长参数。

执行上述代码,结果如下:

print invoke2
print invoke2
print invoke1

通常来说,我们是不提倡可变长参数方法的重载,是因为 Java 编译器可能无法决定应该调用哪个目标方法。

但是通过执行结果我们发现,Java 编译器直接识别调用第二个方法,这是为什么呢?带着这个问题,我们来看看 Java 虚拟机是怎么识别目标方法的。

不过在此之前,我们先预热一下,相信大家初学 Java 基础知识时,都听说过重载和重写这两个概念,应用范围甚广,在接触到的源码中,几乎都有它们的身影。

重载与重写

重载

在 Java 程序里,如果同一个类中出现多个名字相同,并且参数类型相同的方法,那么它无法通过编译。而如果同一个类中方法名相同,参数列表不同,这些方法之间的关系,被称之为重载

重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。

选取的过程共分为三个阶段:

  1. 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
  2. 如果在阶段1中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
  3. 如果在阶段2中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。

如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。

结合我们开头的测试案例来分析,invoke(null, 1) 中的 null 既可以匹配第一个方法中声明为 Object 的形式参数,也可以匹配第二个方法中声明为 String 的形式参数。由于 String 是 Object 的子类,因此 Java 编译器会认为第二个方法更为贴切。

上述没有考虑基本类型,如果重载方法的入参包含基本类型和包装类型,那么 Javac 编译器如何选择“更加合适的”方法,我们还是用案例演示一下。

public class OverloadTest {

  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(float arg) {
    System.out.println("hello float");
  }

  public static void sayHello(double arg) {
    System.out.println("hello double");
  }

  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 的重载。另外自动转型只能向上转型,比如本案例中按照 char>int>long>float>double 的顺序转型进行匹配,但不会匹配到 byte 和 short 类型的重载,因为 char 到byte 或 short 的转型是不安全的。 后续继续注释掉参数类型为 long、float 和 double 的三个方法,看看输出变成什么。

hello Character

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

hello Serializable

这结果真的是出人意外,为什么会输出该结果呢?我们知道 java.lang.Character 实现了 java.io.Serializable,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类所实现的接口类型,所以紧接着又发生一次自动转型。char 可以转型成 int,但是 Character 是绝对不会转型为 Integer 的,它只能安全地转型为它实现的接口或父类。

查看 java.lang.Character 源码可知,它还实现了另外一个接口 java.lang.Comparable,如果我们在上述测试代码中增加一个参数类型为 Comparable 的重载方法,它俩的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示“类型模糊”(Type Ambiguous),并拒绝编译。 程序必须在调用时显式地指定字面量的静态类型, 如:sayHello((Serializable)'a'),才能编译通过。

既然编译器不允许两者共存,其实可以绕开编译器,利用字节码工具自己构建,但是具体选择哪个就不确定了。

下面继续注释掉 sayHello(Serializable arg) 方法, 输出会变为:

hello Object

这时是char装箱后转型为 Character,看看 Character 有多少个父类, 那将在继承关系中从下往上开始搜索,越接上层的优先级越低。如果找不到显式的父类,因为 Object 是所有类的父类,则会定位到 Object 类型。即使方法调用传入的参数值为 null 时,这个规则仍然适用。

这里注意一点,因为我们测试代码中的 Character 只实现了两个接口,如果 extends 某个类,那么则该类和接口的优先级是一致的。

继续注释掉 sayHello(Object arg),输出为:

hello char ...

可见变长参数的重载优先级是最低的,这时候字符'a'被当作了一个 char[]数组的元素。需要注意的是,有一些在单个参数中能成立的自动转型,如char转型为int,在变长参数中是不成立的。另外 (char... arg) 和 (Character... arg) 参数类型对于编译器来说优先级是一样的,无法匹配。

演示了那么多,如果重载方法的参数类型包含基本数据类型,包装类型,接口,父类等,它们的优先级顺序如下:

  1. 首先匹配对应的基本数据类型;
  2. 如果找不到对应的基本数据类型,则可以发生向上转型,匹配转好的基本数据类型,基本数据类型转型顺序为:byte>short>char>int>long>float>double;
  3. 如果基本数据类型也匹配不到,那么则自动装箱为包装类型,则匹配对应的包装类型;
  4. 如果找不到包装类型,则匹配包装类型实现的接口或直接父类,它们的优先级一样;
  5. 如果还是找不到,则匹配更上层的父类,直至到 Object;
  6. 如果上述都失败了,则匹配变长参数。

字节码工具绕开编译

根据重载的定义可知,Javac 编译器不允许同一个类中出现方法名和参数类型相同的方法。这个限制可以通过字节码工具绕开。也就是说,在编译完成之后,我们可以再向class文件中添加方法名和参数类型相同,而返回类型不同的方法。当这种包括多个方法名相同、参数类型相同,而返回类型不同的方法的类,出现在Java编译器的用户类路径上时,它是怎么确定需要调用哪个方法的呢?当前版本的Java编译器会直接选取第一个方法名以及参数类型匹配的方法。并且,它会根据所选取方法的返回类型来决定可不可以通过编译,以及需不需要进行值转换等。

下面我们通过一个案例来进行分析,使用到 Javassist 来动态处理字节码。

Java 生态里有很多可以动态处理字节码的技术,比较流行的有两个,一个是 ASM,一个是 Javassist 。

  • ASM:直接操作字节码指令,执行效率高,但涉及到JVM的操作和指令,要求使用者掌握Java类字节码文件格式及指令,对使用者的要求比较高。

  • Javassist:提供了更高级的API,执行效率相对较差,但无需掌握字节码指令的知识,简单、快速,对使用者要求较低。

考虑到简单易用性,这里选择 Javassist 工具来实现。

首先创建一个 User 类。

package com.msdn.java.hotspot.byteCode;

public class User {

  public String study() {
    System.out.println("study day by day");
    return "i love studying";
  }

}

接着再创建一个测试类

package com.msdn.java.hotspot.byteCode;

import javassist.ClassPool;
import javassist.CtClass;
import javassist.CtMethod;
import javassist.Loader;
import javassist.Translator;

public class OverWriteCode {


  public static void main(String[] args) {
    updateUserClass();
  }

  public static void updateUserClass() {
    try {
      //获取ClassPool
      ClassPool pool = ClassPool.getDefault();
      //获取User类
      CtClass ctClass = pool.get("com.msdn.java.hotspot.byteCode.User");
      CtMethod cm = ctClass.getDeclaredMethod("study", null);
      cm.setBody("{" + "System.out.println(\"你好:\");" + ""
          + "return \"123\";}");

      ctClass.addMethod(CtMethod.make("public void study() {\n"
          + "    System.out.println(\"study\");\n"
          + "  }", ctClass));
      //这里会将这个创建的类对象编译为.class文件
      ctClass.writeFile("../path/");

      Translator translator = new Translator() {
        @Override
        public void start(ClassPool classPool) {
          System.out.println("start");
        }

        @Override
        public void onLoad(ClassPool classPool, String paramString) {
          System.out.println("onLoad:" + paramString); //com.msdn.java.hotspot.byteCode.User
          new User().study();//调用的是原始类的方法
        }
      };
      Loader classLoader = new Loader(pool); //Javassist 提供的 Classloader
      classLoader.addTranslator(pool, translator); //监听 ClassLoader 的生命周期

      Class uClass = classLoader.loadClass("com.msdn.java.hotspot.byteCode.User");
      Object instance = uClass.newInstance();
      uClass.getDeclaredMethod("study").invoke(instance);
    } catch (Exception ex) {
      ex.printStackTrace();
    }
  }
}

执行上述代码,结果如下:

start
onLoad:com.msdn.java.hotspot.byteCode.User
study day by day
你好:Java

我们来查看一下编译后的 User.class 文件,内容如下所示:

根据上述输出结果和 User.class 文件内容,可以证实上面那段话的内容。

这里多提一句,关于修改类的方法,一种情况是修改未加载的类,另一种是修改已加载的类,两者关于方法的调用代码有所不同。

1、修改未加载的类,简要代码如下:

//获取ClassPool
CtClass ctClass = pool.get("com.msdn.java.hotspot.byteCode.User");
CtMethod cm = ctClass.getDeclaredMethod("study", null);
cm.setBody("{" + "System.out.println(\"你好:\");" + ""
           + "return \"123\";}");
//这里会将这个创建的类对象编译为.class文件
ctClass.writeFile("../path/");
//编译成字节码文件,使用当前线程上下文类加载器加载类
ctClass.toClass();
new User().study();

2、修改已加载的类。同个 Class 是不能在同个 ClassLoader 中加载两次的,所以在输出 CtClass 的时候需要注下。ctClass.toClass() 语句表示加载过的意思,所以解决方案就是指定一个未加载的 ClassLoader。

实现代码即 OverWriteCode 文件中所示。

除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同,那么在子类中,这两个方法同样构成了重载。

public class Person {

  public void eat(String msg) {
    System.out.println("eat " + msg);
  }
}

public class Man extends Person {

  public void eat(String msg, int num) {
    System.out.println("eat " + msg + ",cost " + num);
  }
}

关于重载的知识总结

  • 方法名必须相同,参数列表必须不同(个数不同、或类型不同、参数类型排列顺序不同等)
  • 方法的返回类型可以相同也可以不相同。
  • 重载发生在同一类中或者子类中
  • 重载实现编译时的多态性

重写

如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,则这两个方法的关系,被称为重写。

不过需要注意的是,如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法,静态方法形式上可以被重写,但是会被隐藏。原因在于方法重写基于运行时动态绑定,而 static 方法是编译时静态绑定的。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。

如下代码所示:

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

相信上述结果不会出乎大家的意料,这正是我们熟知的多态。显然这里方法具体调用哪个不是在编译时决定的,我们查看字节码文件如下:

0: new           #2                  // class com/msdn/java/hotspot/byteCode/method/DynamicDispatch$Man
3: dup
4: invokespecial #3                  // Method com/msdn/java/hotspot/byteCode/method/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new           #4                  // class com/msdn/java/hotspot/byteCode/method/DynamicDispatch$Woman
11: dup
12: invokespecial #5                  // Method com/msdn/java/hotspot/byteCode/method/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6                  // Method com/msdn/java/hotspot/byteCode/method/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6                  // Method com/msdn/java/hotspot/byteCode/method/DynamicDispatch$Human.sayHello:()V
24: new           #4                  // class com/msdn/java/hotspot/byteCode/method/DynamicDispatch$Woman
27: dup
28: invokespecial #5                  // Method com/msdn/java/hotspot/byteCode/method/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6                  // Method com/msdn/java/hotspot/byteCode/method/DynamicDispatch$Human.sayHello:()V
36: return

虽然 sayHello 方法指向的是 Human,但是在实际运行时对象实际类型不一样,产生了不同的行为。背后真实原因为:因为 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型, 所以两次调用中的 invokevirtual 指令并不是把常量池中方法的符号引用解析到直接引用上就结束了, 还会根据方法接收者的实际类型来选择方法版本, 这个过程就是 Java 语言中方法重写的本质。

既然提到了多态,且这种多态性的根源在于虚方法(后续会介绍)调用指令 invokevirtual 的执行逻辑,那么多态是否适用于字段呢?自定的调用并不会使用 invokevirtual 指令,而是使用 getfield 指令。我们还是先通过一个案例来进行学习:

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

输出两句都是“I am Son”,这是因为 Son 类在创建的时候,首先隐式调用了 Father 的构造函数, 而 Father 构造函数中对 showMeTheMoney()的调用是一次虚方法调用,实际执行的版本是 Son::showMeTheMoney() 方法, 所以输出的是“I am Son”。 而这时候虽然父类的 money 字段已经被初始化成2了, 但 Son::showMeTheMoney()方法中访问的却是子类的 money 字段, 这时候结果自然还是0, 因为它要到子类的构造函数执行时才会被初始化。main()的最后一句通过静态类型访问到了父类中的 money, 输出了2。这点查看字节码也可以看出来。

 getfield      #9                  // Field com/msdn/java/hotspot/byteCode/method/FieldHasNoPolymorphic$Father.money:I

当然,如果你实现 money 的 getter 方法,后续获取 money 使用 getter 方法,那么就可以访问子类中的 money。

总结:

  • 方法名,参数列表必须相同,返回类型可以相同也可以是原类型的子类型
  • 重写方法不能比原方法访问性差(即访问权限不允许缩小)。
  • 重写方法不能比原方法抛出更多的异常。
  • 重写发生在子类和父类之间
  • 重写实现运行时的多态性

静态绑定和动态绑定

上文花了那么多功夫来介绍重载和重写,可以发现,不管是重载还是重写,都需要确定方法的唯一性,那么在 Javac 编译通过后, Java 虚拟机又是怎么识别方法的。

Java 虚拟机识别方法的关键在于类名、方法名以及方法描述符(method descriptor)。类名和方法名我们都知道,至于方法描述符,它是由方法的参数列表以及返回类型所构成。在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证阶段报错。

再结合上文我们使用字节码工具的测试案例,可以发现 Java 虚拟机与 Java 语言不同,如果方法名和参数列表相同,这样的两个方法在编译时就会报错,而我们通过 Javassist 修改字节码可以让这样的两个方法同时存在,且 JVM 能够顺利执行相关代码。

Java 虚拟机中关于方法重写的判定同样基于方法描述符。也就是说,如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数列表以及返回类型一致(Java7后,返回类型可以不一致,但必须是父类返回值的派生类),Java 虚拟机才会判定为重写。

对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成桥接方法来实现 Java 中的重写语义。

来看一个案例:

public class Parent<T> {

  public void sayHello(T value) {
    System.out.println("This is Parent Class, value is " + value);
  }
}

public class Child extends Parent<String> {

  public void sayHello(String value) {
    System.out.println("This is Child class, value is " + value);
  }

  public static void main(String[] args) {
    Child child = new Child();
    Parent<String> object = child;
    object.sayHello("Java");
  }
}

然后执行下述命令:

javac Child.java Parent.java 
javap -v -c Child 

可以看到这样一个方法:

  public void sayHello(java.lang.Object);
    descriptor: (Ljava/lang/Object;)V
    flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: checkcast     #13                 // class java/lang/String
         5: invokevirtual #14                 // Method sayHello:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 8: 0

因为类型擦除,T 关键字会被替换为 Object,然后编译器会生成一个桥方法,从而保证多态性。推荐阅读本文

通过上述种种描述,可以发现 Javac 编译器和 Java 虚拟机对于重载和重写的处理是有所区别的,简要概括为:

  • 如果方法名和参数列表相同的方法,则编译报错,可理解为重载由编译区分;但是 Java 虚拟机的验证条件更宽泛一些,如果方法名和方法描述符相同,才会在验证阶段报错。

  • 如果子类和父类拥有同一个方法,且方法名和方法描述符都相同,Java 虚拟机才认为是重写;对于 Java 语言中重写而 Java 虚拟机中非重写的情况(即方法描述符不同),编译器会通过生成桥接方法来实现 Java 中的重写语义。

Javac 编译器对应编译阶段,Java 虚拟机对应运行阶段,重载发生在编译阶段,重写发生在运行阶段。在某些地方,重载也被称为静态绑定(static binding),或者编译时多态(compile-time polymorphism);而重写则被称为动态绑定(dynamic binding)。但这个说法在 Java 虚拟机语境下并非完全正确。这是因为某个类中的重载方法可能被它的子类所重写,因此 Java 编译器会将所有对非私有实例方法的调用编译为需要动态绑定的类型。

基于上述情况,编译器在处理非静态非私有非final方法时,都是直接使用动态绑定的(final方法因为不会被继承,所以使用静态绑定)。总结来看,不可被子类继承的方法(静态方法,私有方法,final方法)都会被编译成静态绑定。 有可能被子类继承重写造成需要运行时判断对象实例类型后才能决定调用哪个方法的,都会被编译成动态绑定。

上文我们多次查看字节码文件,那么有必要了解一下 Java 字节码中与调用相关的指令,共有五种。

  1. invokestatic:用于调用静态方法。
  2. invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
  3. invokevirtual:用于调用非私有实例方法。
  4. invokeinterface:用于调用接口方法。
  5. invokedynamic:用于调用动态方法。

invokestaticinvokespecial 是静态绑定的,invokevirtualinvokeinterface 是动态绑定的。invokedynamic 涉及的内容比较复杂,后续章节会专门介绍。

虚方法调用

上文我们提到虚方法这个术语,这里简单介绍一下,后续在讲解方法内联时会着重描述。

只有使用 invokespecial 指令调用的私有方法、实例构造器、父类方法和使用 invokestatic 指令调用的静态方法才会在编译期进行解析,再加上被 final 修饰的方法(尽管它使用invokevirtual指令调用) ,这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)。

那么 final 方法是怎么处理的呢?首先调用 final 方法使用的是 invokevirtual 指令,而我们都知道,当使用 final 关键字声明方法时,它被称为 final 方法。final 方法无法被覆盖(重写),所以 Java 虚拟机可以确定调用者的类型。如果虚方法调用指向一个标记为 final 的方法,因为那么 Java 虚拟机也可以静态绑定该虚方法调用的目标方法。

调用指令的符号引用

在编译过程中,我们并不知道目标方法的具体内存地址。因此,Java 编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类接口的名字,以及目标方法的方法名和方法描述符

符号引用存储在 class 文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。

在执行使用了符号引用的字节码前,Java 虚拟机需要解析这些符号引用,并替换为实际引用。

对于非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找。

  1. 在 C 中查找符合名字及描述符的方法。
  2. 如果没有找到,在 C 的父类中继续搜索,直至 Object 类。
  3. 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足 C 与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。

我们之前讲过在继承关系中,调用子类的静态方法时,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。

来看一下小案例:

public class Person {
  public static int eat(int num){
    System.out.println("person eat");
    return num;
  }
}

public class Man extends Person {
  public static int eat(int num) {
    System.out.println("man eat");
    return num;
  }

  public static void main(String[] args) {
    Man.eat(12);
  }
}

比如说我们调用 eat 方法,它的符号引用为“com/msdn/java/hotspot/byteCode/Man.eat:(I)I”,即然后我们就去 Man 类中查找。关于 eat 的方法描述符为 eat:(I)I,入参是一个 int 类型的变量,方法返回类型也是 int,在 Man 中我们找到了对应的方法,则直接返回了。

如果删除上述代码中 Man 类中的 eat 方法,重新编译执行,查看字节码可知,还是先从 Man.java 中开始查找,没有找到,则前往父类 Person 中查找,最终定位到 eat 方法。

对于接口符号引用,假定该符号引用所指向的接口为 I,则 Java 虚拟机会按照如下步骤进行查找。

  1. 在 I 中查找符合名字及描述符的方法。
  2. 如果没有找到,在 Object 类中的公有实例方法(比如说 toString 方法)中搜索。
  3. 如果没有找到,则在 I 的超接口中搜索。这一步的搜索结果的要求与非接口符号引用步骤 3 的要求一致。

关于上述步骤 3,我们通过一个示例来进行学习。

public interface Rent {

  default void rent() {
    System.out.println("parent rent");
  }
}

public interface RentChild extends Rent {
  void say();
}

public class Host implements RentChild {

  @Override
  public void say() {
    System.out.println("说两句话");
  }

  @Override
  public void rent() {
    System.out.println("child rent");
  }
}

public class Tenant {

  public void rent(RentChild rent) {
    rent.rent();
    System.out.println("花了我一大笔钱");
    System.out.println(rent.toString());
  }

  public static void main(String[] args) {
    Tenant tenant = new Tenant();
    RentChild rentChild = new Host();
    tenant.rent(rentChild);
  }
}

按照上述代码执行,最后调用的是 Host 中的 rent() 方法,如果删除 Host 中的 rent() 实现,则会调用 Rent 接口中的 rent() 方法。

经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。

关于方法表指的是什么,我们接下来会详细介绍。

方法表

在介绍那篇类加载机制的连接部分中,在准备阶段,它除了为静态字段分配内存之外,还会构造与该类相关联的方法表。

方法表是 Java 虚拟机实现动态绑定的关键所在,接下来会以 invokevirtual 所使用的虚方法表为例介绍方法表的用法,invokeinterface 所使用的接口方法表(interface method table,itable)稍微复杂些,但是原理其实是类似的。

方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。

这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足两个特质:其一,子类方法表中包含父类方法表中的所有方法;其二,子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。

我们知道,方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。

接下来我们查看一个简单的示例:

public abstract class Passenger {

  abstract void say();

  @Override
  public String toString() {
    return "Passenger";
  }
}

public class ForeignerPassenger extends Passenger {

  @Override
  void say() {
    System.out.println("say hello");
  }
}

public class ChinesePassenger extends Passenger {

  @Override
  void say() {
    System.out.println("说你好");
  }

  void eat() {
    System.out.println("吃包子");
  }

  public static void main(String[] args) {
    Passenger passenger = new ChinesePassenger();
    passenger.say();
  }
}

上述例子中各文件的方法表如下图所示:

因为方法表的特质规定,所以子类会包含父类的所有方法,各文件的方法表中都存在 toString 方法,父类中 toString 方法的索引值为0,则子类该方法的索引值也是 0。

实际上,使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:

1、访问栈上的数据,栈上存放了对象的引用,该引用指向堆中的对象实例;

2、对象在堆内存中分为三块区域:对象头、实例数据和对齐填充,其中对象头中包括两部分信息:运行时数据和类型指针,我们取得类型指针后,通过指针找到方法区(用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据)中类的信息;

3、从而获得该类的方法表,最后根据索引从方法表找到对应的方法。

相对于创建并初始化 Java 栈帧来说,这几个内存解引用操作的开销相对没有那么大。但是并不能认为虚方法调用对性能没有太大的影响,不过好在即时编译有两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)(方法内联知识点比较复杂,放在后续专门讲解)。

内联缓存

内联缓存是一种加快动态绑定的优化技术。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。

直白点说,类似于常见的缓存技术,将使用过的类实例与方法名缓存起来,下次虚方法再调用,先去缓存中查找,如果没有再按照上述三个步骤进行定位。

在针对多态的优化手段中,我们通常会提及以下三个术语。

  • 单态(monomorphic)指的是仅有一种状态的情况。
  • 多态(polymorphic)指的是有限数量种状态的情况。二态(bimorphic)是多态的其中一种。
  • 超多态(megamorphic)指的是更多种状态的情况。通常我们用一个具体数值来区分多态和超多态。在这个数值之下,我们称之为多态。否则,我们称之为超多态。

对于内联缓存来说,我们也有对应的单态内联缓存、多态内联缓存和超多态内联缓存。

单态内联缓存比较好理解,就是只缓存一种对象类型及其所对应的目标方法;而多态内联缓存,则是缓存多种对象类型(子类)及其目标方法。这样就存在空间浪费的问题,如果子类数量过多,则每次多态内联缓存要缓存多种对象类型的数据,而且实际应用中,单态使用的场景更广。为了节省内存空间,Java 虚拟机只采用单态内联缓存。

除此之外,我们会将更加热门的动态类型(即热点数据)放在前面。

确定采用单态内联缓存后,那么我们都知道缓存也是要占用空间的,不可能每次来新数据,统统都缓存起来,那么 JVM 是如何处理新数据(未命中缓存的方法调用)的呢?

对于内联缓存中的内容,我们有两种选择。

1、替换单态内联缓存中的纪录。这种做法就好比 CPU 中的数据缓存,它对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。

但是上述选择存在问题,我们用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。也就是说,只有写缓存的额外开销,而没有用缓存的性能提升。这让我想到了 MySQL 中的写缓存 change buffer,它适合写多读少的情况,如果写后立马查询,不仅没有起到应有的价值,反而会增加维护代价。

2、劣化为超多态状态。这也是 JVM 的具体实现方式。说白了就是放弃优化的机会,还是按部就班地去访问方法表,这样就不需要考虑写缓存的额外开销。

虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。

参考文献

Javassist 字节码 简介

Javassist用法

基于 Javassist 和 Javaagent 实现动态切面

从字节码的角度分析方法调用的本质

详解“符号引用转直接引用”

极客时间 郑雨迪 《深入拆解Java虚拟机》

《深入理解Java虚拟机》