1. 符号引用
考虑这样一个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. 直接引用
这里就不拿HotSpot VM来举例了,因为它的实现略复杂。让我们看个更简单的实现,Sun的元祖JVM——Sun JDK 1.0.2的32位x86上的做法。
请先参考另一个回答里讲到Sun Classic VM的部分:为什么bs虚函数表的地址(int*)(&bs)与虚函数地址(int*)
)(&bs) 不是同一个? - RednaxelaFX 的回答
un 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章。这里有一份拷贝:
在现在的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. 分派
分派指的是确定方法的调用版本,例如有两个重载的方法,具体调用哪个,可以通过参数类型和参数个数来判断,这个过程就是分派。
而分派又分为静态分派和动态分派,编译期间即可确定的需要执行的方法版本称为静态分派,而动态分派则是需要在实际运行时才能确定需要执行的方法版本。它们的应用分别对应“重载”和“重写”
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语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
了解了调用函数时符号引用如何转换为直接引用的,下面看下对于类变量,实例变量的解析
查阅资料后很多人说了一个偏移量的问题,那这个偏移量是相对于什么的偏移量呢?
“相对于什么的偏移量”这就正好是上面说的“实现细节”的一部分了。
例如说,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的开销。
关于对象实例的内存布局,以前我在一个演讲里讲解过,请参考:www.valleytalk.org/wp-content/…,第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大数组。这里面由于每个单元的数据都必然一样大,所以可以有效消除外部碎片——代价则是人为的打碎了一个对象的数据的连续性,增加了内部碎片。
当然做这种取舍的实现非常非常少,所以大家没怎么见过也是正常… >_<