详解“符号引用转直接引用”
提出问题
我们都知道类加载子系统中的解析阶段就是将常量池内的符号引用转换为直接引用(事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行,后面会有解释),针对这个描述很自然地联想到下面几个问题:
- 常量池是什么
- 常量池中的元素都要转吗?
- 符号引用是什么
- 直接引用是什么
- 转换过程
衍生出的问题
- 虚方法调用过程
- 关于偏移量
- 解析
ok,下面我们使用深度优先抽丝剥茧地解决这几个问题。
常量池什么
我们使用javap或者jclasslib可以解析出原始二进制字节码文件,如下
Constant pool部分就是常量池,确切地说叫class文件常量池(常量池加载到方法区后就叫运行时常量池),它是class文件的一部分,用于保存编译时确定的数据。这些数据有:
常量池中的元素都要转吗
上图将常量池包含的内容划分成了两部分,当然只会转符号引用所代表的那些数据,也就是接口、字段、类方法、接口方法、方法类型等,对应的常量池tag分别是CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等
下面以类方法为例说明符号引用和直接引用
符号引用是什么
考虑这样一个Java类:
|
|
它编译出来的Class文件的文本表现形式如下:
|
|
来考察foo()方法里的一条字节码指令:
1: invokevirtual #2 // Method bar:()V
对应到二进制字节码文件里就是
[B6] [00 02]
0xB6指令可以在{%post_link 工作/200_编程语言/java/jvm/jvm指令集 jvm指令集%}中查看含义:
指令码 | 助记符 | 说明 |
---|---|---|
0xb6 | invokevirtual | 调用虚方法 |
这里先解释下虚方法,和后面出现的虚方法表对应,非虚方法不会出现在虚方法表
如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法,这样的方法有:
- 静态方法
- 私有方法
- final方法
- 实例构造器
- 父类方法
其他方法称为虚方法,这里的其他就是指实例方法,所以在java里虚方法也可以称作实例方法
虚方法就相当于C语言中的虚函数(C中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。
(虚方法,非虚方法)可以跟(早期绑定,晚期绑定)、(静态链接、动态链接)关联起来分析。
[B6] [00 02]
后面的0x0002
是该指令的操作数,用于指定要调用的目标方法。
这个操作数是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。这两部分分别都是常量池下标,引用着另外两个常量池项。
顺着这条线索把能传递引用到的常量池项都找出来,会看到(按深度优先顺序排列):
|
|
把引用关系画成一棵树的话:
|
|
标记为Utf8的常量池项在Class文件中实际为CONSTANT_Utf8_info,是以略微修改过的UTF-8编码的字符串文本。
由此可以看出,Class文件中的invokevirtual指令的操作数#2
经过几层间接之后,最后都是由字符串X.bar()V
来表示的。这就是Class文件里的“符号引用”的实态:带有类型(tag) / 结构(符号间引用层次)的字符串。
结论:符号引用就是带有tag的字符串,如被调用方法作为操作数 符号引用#2
就是Methodref X.bar:()V
直接引用是什么
直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄。
直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。
转换过程
这里就不拿HotSpot VM来举例了,因为它的实现略复杂。下面使用元祖JVM举例
Sun Classic VM:(以32位Sun JDK 1.0.2在x86上为例)
1 2 3 4 5 6 7 8 9
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在做类加载的时候会把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(0A)被放到了别的地方;小细节:刚加载进来的时候数据仍然是按高位在前字节序存储的
而在解析后,假设找到的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
如何获取虚方法的直接引用
调用虚方法时,此时操作数栈顶元素就是虚方法所属实例,将该实例的类型记做C
尝试在类型C中找到与常量池中的方法描述名称都相符的方法,如果找到则进行访问权限校验,通过则返回这个方法的直接引用,否则报java.lang.IllegalAccessError异常。
如果没找到,按照继承关系从下往上依次对C的各个父类执行第2步操作。
如果始终没找到则抛出java.lang.AbstractMethodError异常
总结一句话就是通过method tag知道是虚方法,现在需要确定真正调用时的方法是哪个,这也是重写的本质。
上述步骤也被称为动态分派:运行期间根据实际类型确定方法调用版本的分派过程
虚方法表
在面向对象的编程中,会很频繁的使用到动态分派,如果在每次动态分派的过程中都要重新在类的方法元数据中搜索合适的目标的话就可能影响到执行效率。因此,为了提高性能,JVM采用在类的方法区建立一个虚方法表 (virtual method table)(非虚方法不会出现在表中)来实现。使用索引表来代替查找。
每个类中都有一个虚方法表,表中存放着各个方法的实际入口(符号引用)。
虚方法表是什么时候被创建的呢?
虚方法表会在类加载的链接阶段被创建并开始初始化,类的变量初始值准备完成之后,JVM会把该类的方法表也初始化完毕。
给一个直观的例子,图片来源于网上
回到例子中, 这里类X对应在JVM里的虚方法表会是这个样子的:
|
|
所以JVM在执行invokevirtual_quick要调用X.bar()时,只要顺着对象引用查找到虚方法表,然后从中取出第6项的methodblock*,就可以找到实际应该调用的目标然后调用过去了。
所以说在解析/改写后的invokevirtual_quick指令里,虚方法表下标(vtable index)也是一个“直接引用”的表现。
有点类似内存分页存储,页表项的下标表示页块号,页表项的值表示偏移量,从而完成进程中逻辑地址对物理地址的映射。
这里方法表的下标或者说偏移量(指针)的值就是“直接引用”。
关于这种“_quick”指令的设计,可以参考远古的JVM规范第1版的第9章。这里有一份拷贝:language_vm_specification.pdf
在现在的HotSpot VM里,围绕常量池、invokevirtual的解析(再次强调是resolve)的具体实现方式跟元祖JVM不一样,但是大体的思路还是相通的。
由此可见,符号引用通常是设计字符串的——用文本形式来表示引用关系。
而直接引用是JVM(或其它运行时环境)所能直接使用的形式。它既可以表现为直接指针(如上面常量池项#2解析为methodblock*),也可能是其它形式(例如invokevirtual_quick指令里的vtable index)。 关键点不在于形式是否为“直接指针”,而是在于JVM是否能“直接使用”这种形式的数据。
总结:
第一次运行的时候,发现指令没有被解析,根据指令去把常量池中有关系的所有项找出来,得到以“UTF-8”编码描述的此方法所属的“类,方法名,描述符”的常量池项,这就是“符号引用”。
之后根据这些信息去对应类的方法表里寻找对应的方法,得到方法表的偏移量(指针),这个偏移量(指针)就是“直接引用”,再将偏移量赋给常量池项#2(根据指令,在常量池里找到的第一个项)。 最后再将指令修改为:invokevirtual_quick,并把操作数修改成指向方法表的偏移量(指针), 并加上参数个数。
对“多态”实现的理解,依据的是方法表实现原理,由父类得到想要调用的方法在方法表里的偏移量(解析过程),这个偏移量由父类虚方法表得到的,子类继承父类也必然有此方法(private、static修饰的除外),并且对于继承的方法,在子、父类虚方法表中偏移量是相同的(这个是多态实现的关键),并且,如果子类重写了该方法则会覆盖继承的方法(偏移量不变)。解析完成之后,符号引用替换成直接引用。然后开始实际调用,由传入的实际指向this来确定方法的接收者(receiver)动态绑定(分派)具体对象的类型(因为是多态,所以指向的是子类对象的类型),继而找到方法区里子类的方法表,根据父类方法表对应的该方法的偏移量找到子类方法表对应的方法,实现多态性。多态性:一个方法,不同的实现(一个接口,多种实现)。在子、父类方法表中偏移量是相同的(这个是多态实现的关键)!!ps:静态成员变量,静态成员方法,非静态成员变量,只能被继承和隐藏,也就是private、final、static修饰的字段和方法不能被重写。非静态的成员方法才能被继承和覆盖(重写),只有重写的方法才能实现多态性。
关于偏移量
符号引用是只包含语义信息,不涉及具体实现的;而解析(resolve)过后的直接引用则是与具体实现息息相关的。所以当谈及某个符号引用被resolve成怎样的直接引用时,必须要结合某个具体实现来讨论才行。
查阅资料后很多人说了一个偏移量的问题,那这个偏移量是相对于什么的偏移量呢?
“相对于什么的偏移量”这就正好是上面说的“实现细节”的一部分了。
关于对象实例的内存布局
举例来说,对于下面的类C,
|
|
它的实例对象布局就是:(假定是64位HotSpot VM,开启了压缩指针的话)
|
|
所以C类的对象实例大小,在这个设定下是48字节,其中有10字节是为对齐而浪费掉的padding,12字节是对象头,剩下的26字节是用户自己代码声明的实例字段。
留意到C类里字段的排布是按照这个顺序的:对象头 - Object声明的字段(无) - A声明的字段 - B声明的字段 - C声明的字段——按继承深度从浅到深排布。而每个类里面的字段排布顺序则按前面说的规则,按宽度来重排序。同时,如果类继承边界上有空隙(例如这里A和B之间其实本来会有一个4字节的空隙,但B里正好声明了一些不宽于4字节的字段,就可以把第一个不宽于4字节的字段拉到该空隙里,也就是 B.i 的位置)。
同时也请留意到A类和C类都声明了名字为b的字段。它们之间有什么关系?——没关系。 Java里,字段是不参与多态的。派生类如果声明了跟基类同名的字段,则两个字段在最终的实例中都会存在;派生类的版本只会在名字上遮盖(shadow / hide)掉基类字段的名字,而不会与基类字段合并或令其消失。上面例子特意演示了一下A.b 与 C.b 同时存在的这个情况。
使用JOL工具可以方便地看到同样的信息:
|
|
所以,对一个这样的对象模型,实例字段的“偏移量”是从对象起始位置开始算的。对于这样的字节码:
|
|
(这里用cp#12来表示常量池的第12项的意思) 这个C.b:Z的符号引用,最终就会被解析(resolve)为+40这样的偏移量,外加一些VM自己用的元数据。 这个偏移量加上额外元数据比原本的constant pool index要宽,没办法放在原本的constant pool里,所以HotSpot VM有另外一个叫做constant pool cache的东西来存放它们。 在HotSpot VM里,上面的字节码经过解析后,就会变成:
|
|
(这里用cpc#5来表示constant pool cache的第5项的意思) 于是解析后偏移量信息就记录在了constant pool cache里,getfield根据解析出来的constant pool cache entry里记录的类型信息被改写为对应类型的版本的字节码fast_bgetfield来避免以后每次都去解析一次,然后fast_bgetfield就可以根据偏移量信息以正确的类型来访问字段了。
关于静态对象的内存布局
从JDK 1.3到JDK 6的HotSpot VM,静态变量保存在类的元数据(InstanceKlass)的末尾。而从JDK 7开始的HotSpot VM,静态变量则是保存在类的Java镜像(java.lang.Class实例)的末尾。
在HotSpot VM中,对象、类的元数据(InstanceKlass)、类的Java镜像,三者之间的关系是这样的:
|
|
每个Java对象的对象头里,_klass字段会指向一个VM内部用来记录类的元数据用的InstanceKlass对象;InsanceKlass里有个_java_mirror字段,指向该类所对应的Java镜像——java.lang.Class实例。HotSpot VM会给Class对象注入一个隐藏字段“klass”,用于指回到其对应的InstanceKlass对象。这样,klass与mirror之间就有双向引用,可以来回导航。 这个模型里,java.lang.Class实例并不负责记录真正的类元数据,而只是对VM内部的InstanceKlass对象的一个包装供Java的反射访问用。
在JDK 6及之前的HotSpot VM里,静态字段依附在InstanceKlass对象的末尾;而在JDK 7开始的HotSpot VM里,静态字段依附在java.lang.Class对象的末尾。
假如有这样的A类:
|
|
那么在JDK 6或之前的HotSpot VM里:
|
|
可以看到这个A.value静态字段就在InstanceKlass对象的末尾存着了。
而在JDK 7或之后的HotSpot VM里:
|
|
可以看到这个A.value静态字段就在java.lang.Class对象的末尾存着了。
所以对于HotSpot VM的对象模型,静态字段的“偏移量”就是:
- JDK 6或之前:相对该类对应的InstanceKlass(实际上是包装InstanceKlass的klassOopDesc)对象起始位置的偏移量
- JDK 7或之后:相对该类对应的java.lang.Class对象起始位置的偏移量。
上面说的HotSpot VM里的InstanceKlass和java.lang.Class实例都是放哪里的呢?
在JDK 7或之前的HotSpot VM里,InstanceKlass是被包装在由GC管理的klassOopDesc对象中,存放在GC堆中的所谓Permanent Generation(简称PermGen)中。
从JDK 8开始的HotSpot VM则完全移除了PermGen,改为在native memory里存放这些元数据。新的用于存放元数据的内存空间叫做Metaspace,InstanceKlass对象就存在这里。
至于java.lang.Class对象,它们从来都是“普通”Java对象,跟其它Java对象一样存在普通的Java堆(GC堆的一部分)里。
结论(jdk8):实例字段的“偏移量”是从对象起始位置开始算的,静态字段的“偏移量”就是相对该类对应的java.lang.Class对象起始位置的偏移量。
解析
解析是将符号引用替换为直接引用,解析动作针对类或接口,字段,类或接口的方法进行解析。 首先是用类加载器加载这个类。在加载的过程中逐步解析类中的字段和方法。 符号引用是以字面量的实形式明确定义在常量池中,直接引用是指向目标的指针,或者相对偏移量。
类的解析: 类的解析是将一个类的符号引用变为指向InstanceKlass对象的直接指针。指向这个对象的开头, 当创建对象的时候,这个指针会赋值给对象头中_kclass指针。 这样就定位到了该类的数据。访问类的元数据信息,是通过描述该类的类的对象实现的,当然 每个类只对应一个InstanceKlass对象。这就是类本身如何被描述的内存形态。
因为对象内部的数据在内存中的连续堆放的,当你访问一个类的某字段,是需要通过元数据InstanceKlass对象 来记录这个字段的与对象头的偏移量来获取。 当然调用对象的方法是定位到虚方法表 而不是定位到对象的内存区域 创建对象其实就是仅仅向一块内存区域写入与类元数据对应的各种字段的值,当然对象类型的值是一个引用,访问一个对象的字段的值, 是通过定位这个字段在这个对象起始地址的相对偏移量。确定相对偏移量就是在字段解析阶段完成的。
字段的解析: 字段的解析是确定一个对象的字段的访问地址,是计算相对对象起始地址的偏移量。 当第一次用getfield指令访问一个字段,字段的fieldref常量会最终解析成偏移量信息,放入cpc中,然后指令会被修改成fast_bgetfield来避免重复解析而是直接使用偏移量 以使用正确的类型 来访问字段。
方法的解析: 这里单独从元组jvm来分析 就是生成一个描述元数据的methodtable结构体。类似虚方法表。每一个类加载后,会对应一个虚方法表。 当第一次调用方法时,也就是执行invokevirtual指令,指令参数为 该方法的符号引用(包含了参数个数和类型信息,返回值类型,这样就区分了方法重载是不同的方法),也就是对应找到常量表中的methodref类型的项。(class文件中不同类型的项都有标记来标识,从而能够描述并得到这个的项的内部结构 而取到对应的值)。 解析methodref类型的项, 解析的过程通过该项的class_index项找到类信息,通过name_and_type_index项找到方法名和方法描述符,然后在ClassClass对象(类的元数据信息)中找到虚方法表,根据方法描述符找到对应指向匹配方法的下标,该下标指向methodblock*指针,也就是对应的方法内存地址入口,然后把虚方法表的下标和参数个数 写回到该类型为methodref的常量池项 比如是第二项#2。来取代之前的符号引用。也就是说符号引用变成了虚方法表的下标。这个下标就是一种直接引用的体现。 类的直接引用–> ClassClass–> methodtable - 下标 -> methodblock结构体(ClassClass) 第二次调用方法,这时候invokevirtual指令会变成invokevirtual_quick, 该指令的参数为虚方法表的下标(vtable index)和 方法的参数个数。 所以调用方法并不是直接调用方法块,而是先找到虚方法表,再去根据下标调用对应的方法块。 弄清类的加载机制必须知道class文件结构。