Java虚拟机 - 符号引用和直接引用理解
Posted qlky
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java虚拟机 - 符号引用和直接引用理解相关的知识,希望对你有一定的参考价值。
java -- JVM的符号引用和直接引用
https://www.zhihu.com/question/50258991
在JVM中类加载过程中,在解析阶段,Java虚拟机会把类的二级制数据中的符号引用替换为直接引用。
1.符号引用(Symbolic References):
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。例如,在Class文件中它以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
链接:https://www.zhihu.com/question/50258991/answer/120450561
来源:知乎
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
我了解了调用函数时符号引用如何转换为直接引用的,但是对于类变量,实例变量的解析方法还是不太清楚。符号引用是只包含语义信息,不涉及具体实现的;而解析(resolve)过后的直接引用则是与具体实现息息相关的。所以当谈及某个符号引用被resolve成怎样的直接引用时,必须要结合某个具体实现来讨论才行。
查阅资料后很多人说了一个偏移量的问题,那这个偏移量是相对于什么的偏移量呢?
“相对于什么的偏移量”这就正好是上面说的“实现细节”的一部分了。
例如说,HotSpot VM采用的对象模型,在JDK 6 / 7之间就发生过一次变化。
在对象实例方面,HotSpot VM所采用的对象模型是比较直观的一种:Java引用通过直接指针(direct pointer)或语义上是直接指针的压缩指针(compressed pointer)来实现;指针指向的是对象的真实起始位置(没有在负偏移量上放任何数据)。
对象内的布局是:最前面是对象头,有两个VM内部字段:_mark 和 _klass。后面紧跟着就是对象的所有实例字段,紧凑排布,继承深度越浅的类所声明的字段越靠前,继承深度越深的类所声明的字段越靠后。在同一个类中声明的字段按字段的类型宽度来重排序,对普通Java类默认的排序是:long/double - 8字节、int/float - 4字节、short/char - 2字节、byte/boolean - 1字节,最后是引用类型字段(4或8字节)。每个字段按照其宽度来对齐;最终对象默认再做一次8字节对齐。在类继承的边界上如果有因对齐而带来的空隙的话,可以把子类的字段拉到空隙里。这种排布方式可以让原始类型字段最大限度地紧凑排布在一起,减少字段间因为对齐而带来的空隙;同时又让引用类型字段尽可能排布在一起,减少OopMap的开销。
关于对象实例的内存布局,以前我在一个演讲里讲解过,请参考:http://www.valleytalk.org/wp-content/uploads/2011/05/Java_Program_in_Action_20110727.pdf,第112页开始。
举例来说,对于下面的类C,class A {
boolean b;
Object o1;
}
class B extends A {
int i;
long l;
Object o2;
float f;
}
class C extends B {
boolean b;
}
--> +0 [ _mark ] (64-bit header word)
+8 [ _klass ] (32-bit header word, compressed klass pointer)
+12 [ A.b ] (boolean, 1 byte)
+13 [ (padding) ] (padding for alignment, 3 bytes)
+16 [ A.o1 ] (reference, compressed pointer, 4 bytes)
+20 [ B.i ] (int, 4 bytes)
+24 [ B.l ] (long, 8 bytes)
+32 [ B.f ] (float, 4 bytes)
+36 [ B.o2 ] (reference, compressed pointer, 4 bytes)
+40 [ C.b ] (boolean, 1 byte)
+41 [ (padding) ] (padding for object alignment, 7 bytes)
所以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 同时存在的这个情况。
$ sudo ~/sdk/jdk1.8.0/Contents/Home/bin/java -Xbootclasspath/a:. -jar ~/Downloads/jol-cli-0.5-full.jar internals C
objc[78030]: Class JavaLaunchHelper is implemented in both /Users/krismo/sdk/jdk1.8.0/Contents/Home/bin/java and /Users/krismo/sdk/jdk1.8.0/Contents/Home/jre/lib/libinstrument.dylib. One of the two will be used. Which one is undefined.
# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
C object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 09 00 00 00 (00001001 00000000 00000000 00000000) (9)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) be 3b 01 f8 (10111110 00111011 00000001 11111000) (-134136898)
12 1 boolean A.b false
13 3 (alignment/padding gap) N/A
16 4 Object A.o1 null
20 4 int B.i 0
24 8 long B.l 0
32 4 float B.f 0.0
36 4 Object B.o2 null
40 1 boolean C.b false
41 7 (loss due to the next object alignment)
Instance size: 48 bytes
Space losses: 3 bytes internal + 7 bytes external = 10 bytes total
所以,对一个这样的对象模型,实例字段的“偏移量”是从对象起始位置开始算的。对于这样的字节码:
getfield cp#12 // C.b:Z
这个C.b:Z的符号引用,最终就会被解析(resolve)为+40这样的偏移量,外加一些VM自己用的元数据。
这个偏移量加上额外元数据比原本的constant pool index要宽,没办法放在原本的constant pool里,所以HotSpot VM有另外一个叫做constant pool cache的东西来存放它们。
在HotSpot VM里,上面的字节码经过解析后,就会变成:
fast_bgetfield cpc#5 // (offset: +40, type: boolean, ...)
(这里用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实例)的末尾。
Java object InstanceKlass Java mirror
[ _mark ] (java.lang.Class instance)
[ _klass ] --> [ ... ] <-\\
[ fields ] [ _java_mirror ] --+> [ _mark ]
[ ... ] | [ _klass ]
| [ fields ]
\\ [ klass ]
每个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类:class A {
static int value = 1;
}
Java object InstanceKlass Java mirror
[ _mark ] (java.lang.Class instance)
[ _klass ] --> [ ... ] <-\\
[ fields ] [ _java_mirror ] --+> [ _mark ]
[ ... ] | [ _klass ]
[ A.value ] | [ fields ]
\\ [ klass ]
可以看到这个A.value静态字段就在InstanceKlass对象的末尾存着了。
这个情况我在前面提到的演讲稿的第121页有画过一张更好看的图。
Java object InstanceKlass Java mirror
[ _mark ] (java.lang.Class instance)
[ _klass ] --> [ ... ] <-\\
[ fields ] [ _java_mirror ] --+> [ _mark ]
[ ... ] | [ _klass ]
| [ fields ]
\\ [ klass ]
[ A.value ]
可以看到这个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堆的一部分)里。
===========================================
那么如果不是HotSpot VM,而是别的JVM呢?
——什么可能性都存在。总之“偏移量”什么的全看一个具体的JVM实现的内部各种细节是怎样的。
例如说,一个JVM完全可以把所有类的所有静态字段都放在一个大数组里,每新加载一个类就从这个数组里分配一块空间来放该类的静态字段。那么此时静态字段“偏移量”可能直接就是这个静态字段的地址(假定存放它们的数组不移动的话),或者可能是基于这个数组的起始地址的偏移量。
又例如说,一个JVM在实现对象模型时,可能会让指针不指向对象真正的开头,而是指向对象中间的某个位置。例如说,还是HotSpot VM那样的对象布局,指针可以选择指向很多种地方都合理:(下面还是假定64位HotSpot VM,开压缩指针)- 指向对象开头:_mark位于+0,这是HotSpot VM选择的做法;
- 指向对象头的第二个字段:_klass位于+0,_mark位于-8。这种做法在某些架构上或许可以加快通过_klass做vtable dispatch的速度,所以也有合理性;
- 指向实际字段的开头:_mark位于-12,_klass位于-4,第一个字段位于+0。这主要就是觉得字段访问可能是更频繁的操作,而潜在可能牺牲一点对象头访问的速度。
Maxine VM的对象模型就可以在OHM模型和HOM模型之间选择。所谓OHM就是Origin-Header-Mixed,也就是指针指向对象头第一个字段的做法;所谓HOM就是Header-Origin-Mixed,也就是指针指向对象头之后(也就是第一个字段)的做法。
还有更有趣的对象布局方式:双向布局(bidirectional layout),例如Sable VM所用的布局。一种典型的方案是把引用类型字段全部放在负偏移上,指针指向对象头,然后原始类型字段全部放在正偏移量上。这样的好处是GC在扫描对象的引用类型字段时只需要扫描一块连续的内存,非常方便。
更多对象布局的例子请跳传送门:为什么bs虚函数表的地址(int*)(&bs)与虚函数地址(int*)*(int*)(&bs) 不是同一个? - RednaxelaFX 的回答
再例如说,举个极端的例子:前面的讨论都是基于“对象实例里的所有数据分配在一块连续的内存”的假设上。但显然这不是唯一的实现方式。一种极端的做法是,对象用链表来实现,链表上每个节点存放一个字段的值和指向下一个字段的链。就像这样:
typedef union java_value_tag {
int32_t int_val;
int64_t long_val;
/* ... */
object_slot* ref_val;
} java_value;
typedef struct object_slot_tag {
java_value val;
struct object_slot_tag* next;
} object_slot;
然后假如一个类有3个字段,那么这个类的实例就有4个这样的object_slot节点组成的链表而构成:对象头 -> 第一个字段 -> 第二个字段 -> 第三个字段 -> NULL。
谁会这么做(掀桌了!其实还真有。有些有趣的实现,为了简化GC堆的实现,便于减少外部碎片的堆积,而可以把GC堆实现为一个object_slot大数组。这里面由于每个单元的数据都必然一样大,所以可以有效消除外部碎片——代价则是人为的打碎了一个对象的数据的连续性,增加了内部碎片。
当然做这种取舍的实现非常非常少,所以大家没怎么见过也是正常… >_<
以上是关于Java虚拟机 - 符号引用和直接引用理解的主要内容,如果未能解决你的问题,请参考以下文章