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文件格式中。

 
2.直接引用:
 直接引用可以是
(1)直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
(2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
(3)一个能间接定位到目标的句柄
直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。
 
RednaxelaFX的解释:

作者:RednaxelaFX
链接: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的开销。

关于对象实例的内存布局,以前我在一个演讲里讲解过,请参考:,第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;
}
它的实例对象布局就是:(假定是64位HotSpot VM,开启了压缩指针的话)
-->  +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 同时存在的这个情况。

使用JOL工具可以方便地看到同样的信息:
$ 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
(这里用cp#12来表示常量池的第12项的意思)
这个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实例)的末尾。

在HotSpot VM中,对象、类的元数据(InstanceKlass)、类的Java镜像,三者之间的关系是这样的:
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;
}
那么在JDK 6或之前的HotSpot VM里:
Java object      InstanceKlass       Java mirror
 [ _mark  ]                          (java.lang.Class instance)
 [ _klass ] --> [ ...          ] <-\\              
 [ fields ]     [ _java_mirror ] --+> [ _mark  ]
                [ ...          ]   |  [ _klass ]
                [ A.value      ]   |  [ fields ]
                                    \\ [ klass  ]

可以看到这个A.value静态字段就在InstanceKlass对象的末尾存着了。
这个情况我在前面提到的演讲稿的第121页有画过一张更好看的图。

而在JDK 7或之后的HotSpot VM里:
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虚拟机 - 符号引用和直接引用理解的主要内容,如果未能解决你的问题,请参考以下文章

《深入理解Java虚拟机》- 重载与重写

深入理解Java虚拟机——类加载过程

深入理解Java虚拟机类的初始化过程

读《深入理解Java虚拟机》

Java虚拟机常量池项中字面量和符号引用

Java 符号引用 与 直接引用