JVM符号引用转换直接引用的过程?

通过JVM里的符号引用如何存储? - RednaxelaFX 的回答 我了解了调用函数时符号引用如何转换为直接引用的,但是对于类变量,实例变量的解析方法还是不太清楚。查阅资料后很多人说了一个偏移量的问题,那这个偏移量是相对于什么的偏移量呢?
关注者
63
被浏览
3489
我了解了调用函数时符号引用如何转换为直接引用的,但是对于类变量,实例变量的解析方法还是不太清楚。
符号引用是只包含语义信息,不涉及具体实现的;而解析(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的开销。

关于对象实例的内存布局,以前我在一个演讲里讲解过,请参考:valleytalk.org/wp-conte,第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大数组。这里面由于每个单元的数据都必然一样大,所以可以有效消除外部碎片——代价则是人为的打碎了一个对象的数据的连续性,增加了内部碎片。
当然做这种取舍的实现非常非常少,所以大家没怎么见过也是正常… >_<