JVM 方法调用

虚方法和非虚方法

先来看广义上的定义(即指Java代码层面):

  • 非虚方法:如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。
    静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法
  • 虚方法:其他方法就叫做非虚方法

怎么理解上面的这两个定义呢?大家来抓一下重点,编译期就确定了具体的调用版本,这个是虚方法和非虚方法的本质区别,方法在编译期确定?这个是什么意思,难道方法还能不确定的?

下面来看个具体的代码例子:

class Animal{
    void test(){
        System.out.println("动物");
    }
}

class Cat extends Animal{
    @Override
    void test(){
        System.out.println("猫");
    }
}

class Test {
    void test(Animal animal){
        // 此时方法就是无法确定的
        animal.test();
    }
}

Test类里的test方法里面的这句代码animal.test();就是无法确认的,只有在运行的时候根据实际参数,才知道调用的Animal还是Cat。这下子明白方法的编译期就确定了具体的调用版本是什么意思了吧。

你也可以这么理解,不可能被重写的方法就叫做非虚方法,比如静态方法、final定义的方法、私有方法等;有可能被重写的方法就叫做虚方法,注意噢,只要是有可能重写的都是虚方法,尽管你还没重写。

再来看进一步的定义,这一个大家了解一下就好:

  • 非虚方法:invokestatic指令和invokespecial指令调用的方法称为非虚方法
  • 虚方法:其余的(final修饰的除外)称为虚方法

这个是什么意思呢?我们知道源文件要经过编译变成class文件,在编译过程中,编译器就会对我们的源代码进行修改,改成一些JVM能识别的指令,JVM提供了下面四个指令:

  • invokestatic:调用静态方法,解析阶段确定唯一方法版本
  • invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
  • invokevirtual:调用所有虚方法
  • invokeinterface:调用接口方法

在编译的时候,编译器根据源代码中方法是什么内容,就会对该方法加上什么指令。在编译的时候,编译器怎么识别一个方法是否是可以确定的其实很简单,就是看它是不是静态的、是不是私有的、是不是构造器还是调用了父类方法、是不是接口的方法,然后直接判断是否是确定的。

符号引用和直接引用

先看定义:

  • 符号引用:字符串,这个字符串包含足够的信息,以供实际使用时可以找到相应的位置
  • 直接引用:就是地址,就是我们使用的类的方法在内存中的地址
    我们先来看一个类:
class Test {
    public static void main(String[] args) {
        System.out.println("wqewqeqwe");
    }
}

这个类经过编译变成一个class文件,我们可以用javap -v来反编译这个文件,会得到以下内容(只是截取部分内容):

Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // wqewqeqwe
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // Test
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               LTest;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               Solution.java
  #20 = NameAndType        #7:#8          // "":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               wqewqeqwe
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               Test
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V

我们在源代码中,其实是使用了System这个类,但是在class文件中,这个使用就会使用字节码文件中#28对应的java/lang/System来替代,这个java/lang/System就是传说中的符号引用。

为什么要使用符号引用呢?这个是比较好理解的,因为在编译的时候,它是不知道你具体的内存使用情况的,所以是不清楚直接引用是什么,所以就使用一个唯一标识的字符串来表示。

那什么时候会转化为直接引用呢?类加载的时候,类加载的链接阶段中有一个小阶段,叫做解析,该阶段的任务就是把类的符号引用转化为直接引用。

而且这些符号引用和直接引用的翻译关系是可以复用的,也就是说虚拟机里有一个区域叫做方法区,方法区里有一个区域叫做运行时常量池,这个常量池可以看成一张表,这张表记录了符号引用和直接引用的对应关系。进行类加载的时候,会把class文件的所有符号引用加进这张表。

如果新增的符合引用已经在表中存在了,那就说明这个符号引用已经翻译过了,可以直接转化为直接引用;如果没有,那就说明该符号引用第一次出现,那就要根据字符串的内容进行搜索。运行一次之后,符号引用会被替换为直接引用,下次就不用搜索了

主要是“符号引用”和“直接引用”这俩概念看起来很“空洞”,不结合一个实际实现来看的话理解起来不形象。

先看Class文件里的“符号引用”。

考虑这样一个Java类:

public class X {
  public void foo() {
    bar();
  }

  public void bar() { }
}

它编译出来的Class文件的文本表现形式如下:

Classfile /private/tmp/X.class
  Last modified Jun 13, 2015; size 372 bytes
  MD5 checksum 8abb9cbb66266e8bc3f5eeb35c3cc4dd
  Compiled from "X.java"
public class X
  SourceFile: "X.java"
  minor version: 0
  major version: 51
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #4.#16         //  java/lang/Object."":()V
   #2 = Methodref          #3.#17         //  X.bar:()V
   #3 = Class              #18            //  X
   #4 = Class              #19            //  java/lang/Object
   #5 = Utf8               
   #6 = Utf8               ()V
   #7 = Utf8               Code
   #8 = Utf8               LineNumberTable
   #9 = Utf8               LocalVariableTable
  #10 = Utf8               this
  #11 = Utf8               LX;
  #12 = Utf8               foo
  #13 = Utf8               bar
  #14 = Utf8               SourceFile
  #15 = Utf8               X.java
  #16 = NameAndType        #5:#6          //  "":()V
  #17 = NameAndType        #13:#6         //  bar:()V
  #18 = Utf8               X
  #19 = Utf8               java/lang/Object
{
  public X();
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0       
         1: invokespecial #1                  // Method java/lang/Object."":()V
         4: return        
      LineNumberTable:
        line 1: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0       5     0  this   LX;

  public void foo();
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0       
         1: invokevirtual #2                  // Method bar:()V
         4: return        
      LineNumberTable:
        line 3: 0
        line 4: 4
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0       5     0  this   LX;

  public void bar();
    flags: ACC_PUBLIC
    Code:
      stack=0, locals=1, args_size=1
         0: return        
      LineNumberTable:
        line 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
               0       1     0  this   LX;
}

可以看到Class文件里有一段叫做“常量池”,里面存储的该Class文件里的大部分常量的内容。

来考察foo()方法里的一条字节码指令:

1: invokevirtual #2  // Method bar:()V

这在Class文件中的实际编码为:

[B6] [00 02]

其中0xB6是

invokevirtual指令

的操作码(opcode),后面的0x0002是该指令的操作数(operand),用于指定要调用的目标方法。

这个参数是Class文件里的常量池的下标。那么去找下标为2的常量池项,是:

#2 = Methodref          #3.#17         //  X.bar:()V

这在Class文件中的实际编码为(以十六进制表示,Class文件里使用高位在前字节序(big-endian)):

[0A] [00 03] [00 11]

其中0x0A是CONSTANT_Methodref_info的tag,后面的0x0003和0x0011是该常量池项的两个部分:class_index和name_and_type_index。这两部分分别都是常量池下标,引用着另外两个常量池项。

顺着这条线索把能传递引用到的常量池项都找出来,会看到(按深度优先顺序排列):

   #2 = Methodref          #3.#17         //  X.bar:()V
   #3 = Class              #18            //  X
  #18 = Utf8               X
  #17 = NameAndType        #13:#6         //  bar:()V
  #13 = Utf8               bar
   #6 = Utf8               ()V

把引用关系画成一棵树的话:

     #2 Methodref X.bar:()V
     /                     \
#3 Class X       #17 NameAndType bar:()V
    |                /             \
#18 Utf8 X    #13 Utf8 bar     #6 Utf8 ()V

标记为Utf8的常量池项在Class文件中实际为CONSTANT_Utf8_info,是以略微修改过的UTF-8编码的字符串文本。

这样就清楚了对不对?

由此可以看出,Class文件中的invokevirtual指令的操作数经过几层间接之后,最后都是由字符串来表示的。这就是Class文件里的“符号引用”的实态:带有类型(tag) / 结构(符号间引用层次)的字符串。

然后再看JVM里的“直接引用”的样子。

这里就不拿HotSpot VM来举例了,因为它的实现略复杂。让我们看个更简单的实现,Sun的元祖JVM——Sun JDK 1.0.2的32位x86上的做法。

请先参考另一个回答里讲到Sun Classic VM的部分:

为什么bs虚函数表的地址(int)(&bs)与虚函数地址(int(int)(&bs) 不是同一个? - RednaxelaFX 的回答
Sun Classic VM:(以32位Sun JDK 1.0.2在x86上为例)

image.png

(请留心阅读上面链接里关于虚方法表与JVM的部分。Sun的元祖JVM也是用虚方法表的喔。)

元祖JVM在做类加载的时候会把Class文件的各个部分分别解析(parse)为JVM的内部数据结构。例如说类的元数据记录在ClassClass结构体里,每个方法的元数据记录在各自的methodblock结构体里,等等。

在刚加载好一个类的时候,Class文件里的常量池和每个方法的字节码(Code属性)会被基本原样的拷贝到内存里先放着,也就是说仍然处于使用“符号引用”的状态;直到真的要被使用到的时候才会被解析(resolve)为直接引用。

假定我们要第一次执行到foo()方法里调用bar()方法的那条invokevirtual指令了。

此时JVM会发现该指令尚未被解析(resolve),所以会先去解析一下。

通过其操作数所记录的常量池下标0x0002,找到常量池项#2,发现该常量池项也尚未被解析(resolve),于是进一步去解析一下。

通过Methodref所记录的class_index找到类名,进一步找到被调用方法的类的ClassClass结构体;然后通过name_and_type_index找到方法名和方法描述符,到ClassClass结构体上记录的方法列表里找到匹配的那个methodblock;最终把找到的methodblock的指针写回到常量池项#2里。

也就是说,原本常量池项#2在类加载后的运行时常量池里的内容跟Class文件里的一致,是:

[00 03] [00 11]

(tag被放到了别的地方;小细节:刚加载进来的时候数据仍然是按高位在前字节序存储的)

而在解析后,假设找到的methodblock*是0x45762300,那么常量池项#2的内容会变为:

[00 23 76 45]

(解析后字节序使用x86原生使用的低位在前字节序(little-endian),为了后续使用方便)

这样,以后再查询到常量池项#2时,里面就不再是一个符号引用,而是一个能直接找到Java方法元数据的methodblock了。这里的methodblock就是一个“直接引用”

解析好常量池项#2之后回到invokevirtual指令的解析。

回顾一下,在解析前那条指令的内容是:

[B6] [00 02]

而在解析后,这块代码被改写为:

[D6] [06] [01]

其中opcode部分从invokevirtual改写为invokevirtual_quick,以表示该指令已经解析完毕。

原本存储操作数的2字节空间现在分别存了2个1字节信息,第一个是虚方法表的下标(vtable index),第二个是方法的参数个数。这两项信息都由前面解析常量池项#2得到的methodblock*读取而来。

也就是:

invokevirtual_quick vtable_index=6, args_size=1

这里例子里,类X对应在JVM里的虚方法表会是这个样子的:

[0]: java.lang.Object.hashCode:()I
[1]: java.lang.Object.equals:(Ljava/lang/Object;)Z
[2]: java.lang.Object.clone:()Ljava/lang/Object;
[3]: java.lang.Object.toString:()Ljava/lang/String;
[4]: java.lang.Object.finalize:()V
[5]: X.foo:()V
[6]: X.bar:()V

所以JVM在执行invokevirtual_quick要调用X.bar()时,只要顺着对象引用查找到虚方法表,然后从中取出第6项的methodblock*,就可以找到实际应该调用的目标然后调用过去了。

假如类X还有子类Y,并且Y覆写了bar()方法,那么类Y的虚方法表就会像这样:

[0]: java.lang.Object.hashCode:()I
[1]: java.lang.Object.equals:(Ljava/lang/Object;)Z
[2]: java.lang.Object.clone:()Ljava/lang/Object;
[3]: java.lang.Object.toString:()Ljava/lang/String;
[4]: java.lang.Object.finalize:()V
[5]: X.foo:()V
[6]: Y.bar:()V

于是通过vtable_index=6就可以找到类Y所实现的bar()方法。

所以说在解析/改写后的invokevirtual_quick指令里,虚方法表下标(vtable index)也是一个“直接引用”的表现。

关于这种“_quick”指令的设计,可以参考远古的JVM规范第1版的第9章。这里有一份拷贝:

http://www.cs.miami.edu/~burt/reference/java/language_vm_specification.pdf

在现在的HotSpot VM里,围绕常量池、invokevirtual的解析(再次强调是resolve)的具体实现方式跟元祖JVM不一样,但是大体的思路还是相通的。

HotSpot VM的运行时常量池有ConstantPool和ConstantPoolCache两部分,有些类型的常量池项会直接在ConstantPool里解析,另一些会把解析的结果放到ConstantPoolCache里。以前发过一帖有简易的图解例子,可以参考:

请问,jvm实现读取class文件常量池信息是怎样呢?

由此可见,符号引用通常是设计字符串的——用文本形式来表示引用关系。

而直接引用是JVM(或其它运行时环境)所能直接使用的形式。它既可以表现为直接指针(如上面常量池项#2解析为methodblock*),也可能是其它形式(例如invokevirtual_quick指令里的vtable index)。

关键点不在于形式是否为“直接指针”,而是在于JVM是否能“直接使用”这种形式的数据。

栈帧中的动态链接

我们知道栈帧中有一个内容叫做动态链接,这个是什么呢?

先来看一个类,代码如下:

class Test {
    public static void main(String[] args) {
        System.out.println("wqewqeqwe");
    }
}

使用javap -v反编译一下:

标红的这些就是所谓的动态链接,我个人不喜欢这么叫,我认为有歧义,应该叫做栈里面指向方法区运行时常量池的引用,这样才比较好理解。

也就是说,动态链接就是一个指针,该指针指向方法区的运行时常量池中的符号引用,如果指向的对象已经经过解析了,那么就是一个直接引用,也就是指向一个具体的地址了。

在看一个例子

虚拟机栈: -> 栈帧—对应每个方法----> 包含: 局部变量表, 本地方法栈, 动态链接, 方法出口,

有些地方会把方法返回地址,动态链接,一些附加信息叫做帧数据区

public class DynamicLinkingTest {
     int num;
     String info;

    public void test1(){
        info="JVM";
        this.test2();
    }
    public void test2(){
        num=2;
    }
}

打开反编译后的字节码文件

public class com.qf.DynamicLinkingTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #7.#23         // java/lang/Object."":()V
   #2 = String             #24            // JVM
   #3 = Fieldref           #6.#25         // com/qf/DynamicLinkingTest.info:Ljava/lang/String;
   #4 = Methodref          #6.#26         // com/qf/DynamicLinkingTest.test2:()V
   #5 = Fieldref           #6.#27         // com/qf/DynamicLinkingTest.num:I
   #6 = Class              #28            // com/qf/DynamicLinkingTest
   #7 = Class              #29            // java/lang/Object
   #8 = Utf8               num
   #9 = Utf8               I
  #10 = Utf8               info
  #11 = Utf8               Ljava/lang/String;
  #12 = Utf8               
  #13 = Utf8               ()V
  #14 = Utf8               Code
  #15 = Utf8               LineNumberTable
  #16 = Utf8               LocalVariableTable
  #17 = Utf8               this
  #18 = Utf8               Lcom/qf/DynamicLinkingTest;
  #19 = Utf8               test1
  #20 = Utf8               test2
  #21 = Utf8               SourceFile
  #22 = Utf8               DynamicLinkingTest.java
  #23 = NameAndType        #12:#13        // "":()V
  #24 = Utf8               JVM
  #25 = NameAndType        #10:#11        // info:Ljava/lang/String;
  #26 = NameAndType        #20:#13        // test2:()V
  #27 = NameAndType        #8:#9          // num:I
  #28 = Utf8               com/qf/DynamicLinkingTest
  #29 = Utf8               java/lang/Object
{
  int num;
    descriptor: I
    flags:

  java.lang.String info;
    descriptor: Ljava/lang/String;
    flags:

  public com.qf.DynamicLinkingTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."":()V
         4: return
      LineNumberTable:
        line 8: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcom/qf/DynamicLinkingTest;

  public void test1();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: ldc           #2                  // String JVM
         3: putfield      #3                  // Field info:Ljava/lang/String;
         6: aload_0
         7: invokevirtual #4                  // Method test2:()V
        10: return
      LineNumberTable:
        line 13: 0
        line 14: 6
        line 15: 10
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      11     0  this   Lcom/qf/DynamicLinkingTest;

  public void test2();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=1, args_size=1
         0: aload_0
         1: iconst_2
         2: putfield      #5                  // Field num:I
         5: return
      LineNumberTable:
        line 17: 0
        line 18: 5
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       6     0  this   Lcom/qf/DynamicLinkingTest;
}
SourceFile: "DynamicLinkingTest.java"

以Test1为例
我们可以看到 Code1里面有个#2
指向的是常量池里面的#2
我们定义的String类型赋值的JVM存储在这个位置
然后#2又指向了#24
这里的#xxx指向的都是常量池

当编译Java程序的时候,会得到程序中每一个类或者接口的独立的class文件。虽然独立看上去毫无关联,但是他们之间通过接口(harbor)符号互相联系,或者与Java API的class文件相联系。当运行程序的时候,Java虚拟机装载程序的类和接口,并且在动态连接的过程中把它们互相勾连起来。在程序运行中,Java虚拟机内部组织了一张互相连接的类和接口的网。

class把他们所有的引用符号放在一个地方——常量池。每一个class文件有一个常量池,每一个被Java虚拟机装载的类或者接口都有一份内部版本常量池,被称作运行时常量池。运行时常量池是特定与实现的数据结构,数据结构映射到class文件中的常量池。因此,当一个类型被首次装载的时候,所有来自于类型的符号引用都装载到了类型的运行时常量池。

在程序运行的过程中,如果某个特定的符号引用将要被使用,它首先要被解析。解析过程就是首先根据符号引用查找到实体,再把符号引用替换成直接引用的过程。因为所有的符号引用都是保存在常量池中,所以这种解析叫做常量池解析

还有一对概念:动态链接和静态解析

  • 静态解析:当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时,这种情况下降调用方法的符号引用转换为直接引用的过程称之为静态链接

  • 动态链接:如果被调用的方法在编译期无法被确定下来,只能够在程序运行期将调用的方法的符号转换为直接引用,由于这种引用转换过程具备动态性,因此也被称之为动态链接。

注意,这里的动态链接和栈帧中的动态链接完全没有关系,不要弄混。我个人把这一堆概念理解为,类加载的解析阶段发生在不同时间。如果解析阶段发生在类加载期间,那就是静态解析;如果是运行的时候再发生解析,那就是动态链接。

虚方法表

先来看一下方法执行的实质:

  • 找到操作数栈顶的第一个元素所执行的对象的实际类型,记作C。
  • 如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError 异常。
  • 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
  • 如果始终没有找到合适的方法,则抛出java.1ang.AbstractMethodsrror异常。

在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表 (virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。

什么是虚方法表,看下下面两张图就懂了:


image.png
image.png

总结一下,前面我们说过4个指令,这4个指令可以用来判断一个方法是不是虚方法,执行引擎在执行指令过程中,假如遇到了一个虚方法,那他就会去查这个类的虚方法表,然后实现具体的方法。

你可能感兴趣的:(JVM 方法调用)