符号引用和直接引用,解析和分派

知乎-RednaxelaFX——JVM里的符号引用如何存储?
【深入理解JVM】:解析与分派

1. 符号引用

这部分内容来自知乎RednaxelaFX大神的回答

考虑这样一个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."<init>":()V
   #2 = Methodref          #3.#17         //  X.bar:()V
   #3 = Class              #18            //  X
   #4 = Class              #19            //  java/lang/Object
   #5 = Utf8               <init>
   #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          //  "<init>":()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."<init>":()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文件里有一段叫做“常量池(Constant pool)”,里面存储的该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) / 结构(符号间引用层次)的字符串。

2. 直接引用

这部分内容以然来自知乎RednaxelaFX大神的回答

这里就不拿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上为例)

         HObject             ClassObject
                       -4 [ hdr            ]
--> +0 [ obj     ] --> +0 [ ... fields ... ]
    +4 [ methods ] \
                    \         methodtable            ClassClass
                     > +0  [ classdescriptor ] --> +0 [ ... ]
                       +4  [ vtable[0]       ]      methodblock
                       +8  [ vtable[1]       ] --> +0 [ ... ]
                       ... [ vtable...       ]

(请留心阅读上面链接里关于虚方法表与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是否能“直接使用”这种形式的数据。

3. 解析

就如上所说,解析(resolve)就是将一个符号引用转换成直接引用的一个过程。

当一个java文件编译成class之后,方法都是以符号引用的方式保存。而在加载类时,部分符合条件的符号引用会被转换成**“直接引用”,这个过程我们称之为“解析(Resolution)”**。

而符合这以条件的方法有:静态方法、私有方法、构造方法、父类方法。它们在类加载的的解析阶段就会将符号引用解析为该方法的直接引用。

jvm中提供了5条方法调用字节码指令:

  • invokestatic: 调用静态方法
  • invokespecail: 调用构造方法、私有方法和父类方法。
  • invokevirtual: 调用所有的虚方法。
  • invokeinterface: 调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic: 现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。在此之前的4条调用指令,分派逻辑是固化在jvm内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。(没懂)

只要能被invokestatic和invokespecail指令调用的方法,都可以在解析(Resolution)阶段中确定方法的调用版本,符合这个条件的有静态方法、私有方法、构造方法、父类方法。它们在类加载时就会把符号引用解析(resolve)为该方法的直接引用,这些方法可以称为非虚方法,与之相反称为虚方法(除final方法)。而虚方法则会在程序运行期间才解析成直接引用。

4. 分派

以下内容来自 深入理解Java虚拟机_JVM高级特性与最佳实践 第2版

分派指的是确定方法的调用版本,例如有两个重载的方法,具体调用哪个,可以通过参数类型和参数个数来判断,这个过程就是分派。

而分派又分为静态分派和动态分派,编译期间即可确定的需要执行的方法版本称为静态分派,而动态分派则是需要在实际运行时才能确定需要执行的方法版本。它们的应用分别对应“重载”和“重写”。

4.1 静态分派

静态分派在编译期间即可确定方法的执行版本,经典的例子就是“重载”。(AItsuki:实际上静态分派和“静态代理”一样,是相对于动态分派和动态代理而出现的,实际上英文文档中并没有静态分派和静态代理的说法)

public class StaticDispatch {  
    
    static abstract class Human{  
    }  
    
    static class Man extends Human{  
    }  
    
    static class Woman extends Human{  
    }  

    public void sayHello(Human guy){  
        System.out.println("hello,guy!");  
    }
  
    public void sayHello(Man guy){  
        System.out.println("hello,gentlemen!");  
    }
  
    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!

运行结果很容易得出,但是这里要说一个重要的概念。

Human man = new Man();  

在这行代码中,Human被称为变量的**“静态类型”,或者“外观类型”,后面的Man则被称为“实际类型”**。

静态类型和实际类型在程序中都可以发生变化,但是静态类型的变化仅仅发生在使用时,变量本身的静态类型不会改变,最终的静态类型在编译期是可知的;
而实际类型变化的结果在运行期才可以确定,编译时并不知道一个对象的实际类型是什么。例如:

// 实际类型变化
Human man = new Man();
man = new Woman();

// 静态类型变化
sr.sayHello((Man) man);
sr.sayHello((Woman) man);

在上面StaticDispatch的案例代码中,main()方法的两次调用sayHello(),在方法接收者已经确定是对象sr的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意的定义了两个静态类型相同但实际类型不同的变量,但编译器在重载时时通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型时编译期可知的,因此,在编译阶段,javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用是方法重载,发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

另外,静态分派会选择最合适的重载版本。

public class Main {

    public static void main(String[] args) {
        sayHello('a');
    }

    public static void sayHello(char a) {
        System.out.println("say char");
    }

    public static void sayHello(int a) {
        System.out.println("say int");
    }

    public static void sayHello(long a) {
        System.out.println("say long");
    }

    public static void sayHello(Character a) {
        System.out.println("say Character");
    }

    public static void sayHello(Serializable a) {
        System.out.println("say Serializable");
    }
}

例如上面的代码会输出

say char

而注释掉sayHello(char)后则会调用sayHello(int),这里发生了一次自动类型转换,a被转换成97。

继续注释掉sayHello(int)后则会调用sayHello(long),这里发生了两次自动类型转换,a先转换成97之后,进一步转型为97L。
代码中没有float、double等重载,不过实际上自动转型还能继续发生多次,按照char->int->long->float->double的顺序转型进行匹配。但不会匹配到byte和short类型,因为这个转型是不安全的。

我们继续注释掉sayHello(long),那么会调用 sayHello(Character),这里发生了一次自动装箱。

继续注释掉sayHello(Character),最后则会调用sayHello(Serializable),因为Character实现了Serializable接口,会自动转型为接口类型。

4.2 动态分派

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

结果不出预料,习惯了面向对象思维的java程序员会觉得这是理所当然的,但是虚拟机是如何知道要调用哪个方法的?

显然这里不是根据静态类型来决定,看看这两段代码的字节码:

public class com.aitsuki.proxytest.DynamicDispatch {
  public com.aitsuki.proxytest.DynamicDispatch();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class com/aitsuki/proxytest/DynamicDispatch$Man
       3: dup
       4: invokespecial #3                  // Method com/aitsuki/proxytest/DynamicDispatch$Man."<init>":()V
       7: astore_1
       8: new           #4                  // class com/aitsuki/proxytest/DynamicDispatch$Woman
      11: dup
      12: invokespecial #5                  // Method com/aitsuki/proxytest/DynamicDispatch$Woman."<init>":()V
      15: astore_2
      16: aload_1
      17: invokevirtual #6                  // Method com/aitsuki/proxytest/DynamicDispatch$Human.sayHello:()V
      20: aload_2
      21: invokevirtual #6                  // Method com/aitsuki/proxytest/DynamicDispatch$Human.sayHello:()V
      24: new           #4                  // class com/aitsuki/proxytest/DynamicDispatch$Woman
      27: dup
      28: invokespecial #5                  // Method com/aitsuki/proxytest/DynamicDispatch$Woman."<init>":()V
      31: astore_1
      32: aload_1
      33: invokevirtual #6                  // Method com/aitsuki/proxytest/DynamicDispatch$Human.sayHello:()V
      36: return
}

0~15行是字节码的准备动作,建立man和woman的内存空间,调用Man和Woman的构造器,将这两个实例的引用存放在第1、2个局部变量表Slot中,这个动作也对应了代码中的这两句:

Human man = new Man();
Human woman = new Woman();

16、20行分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21行是方法调用指令,可以看到它们对应的常量池地址都是#6,代表它们的符号引用是完全一样的。但是这两句指令最终执行的目标方法并不相同,原因就要从invokevirtual指令的多态查找过程说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:

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

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

5. 总结

5.1 符号引用

java代码被编译成字节码时,方法是以符号引用表示。

5.2 直接引用

符号引用在某个时刻(可能类加载时,也可能运行时)会被解析成直接引用,直接引用即JVM能直接使用的形式,可能表现为“直接指针” methodblock* 也可能是其它形式。

5.3 解析

解析(Resolve)是将部分符合条件的符号引用转换成直接引用的过程。
or
解析(Resolution)是类加载的其中一个过程,该过程该过程其中一个作用是将部分符合条件的符号引用转换成直接引用。(RednaxelaFX大神好像将这个过程称为parse)

5.4 分派

分派指的是确定方法调用版本,又分为静态分派和动态分派。
静态分派是在编译期即根据变量的静态类型即可确定方法调用版本。
动态分派是需要在运行时根据实际类型才能确定方法调用版本。


来个急转弯给一些思维短路的朋友(其实是我通宵写文章脑子没转过来):
静态分派是在编译期即根据变量的静态类型即可确定方法调用版本,那么动态分派不也是在编译期间可以确定了么,因为不是静态分派就是动态分派啊?

上面的问题是偷换概念了,概念不要放在静态分派和动态分派这两个术语上,而是要放在确定方法调用版本这句话上。
在编译期即可确定静态分派或动态分派这句话没错,但是在编译期即可“确定方法调用版本”就不对了。分派指的是一个方式,确定方法调用版本的方式。

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值