深入解析Java对象和类在HotSpot VM内部的具体实现

2022-10-31 10:59:55 浏览数 (1)

本篇讨论Java对象和类在HotSpot VM内部的具体实现,探索虚拟机在底层是如何对这些Java语言的概念建模的。

对象与类

HotSpot VM使用oop描述对象,使用klass描述类,这种方式被称为对象类二分模型。理解对象类二分模型最好的方法是回归到编程语言本身来看。HotSpot VM是用C 编写的,C 的类是一个强大的抽象工具,HotSpot VM需要借助这个强大的工具,对Java各个方面做一个抽象。换句话说,用一个C 类描述一个Java语言组件。

虽然动机简单,但是随意将组件抽象成C 的类势必会造成混乱,因此HotSpot VM基本遵循一个规则,如图3-1所示。

Java层面的对象会被抽象成C 的一个oop类:普通对象(newFoo)是instanceOop,普通数组(new int[])是typeArrayOop,对象数组(new Bar[])是objArrayOop。这些类都继承自oop类,如果查看HotSpotVM源码会发现没有oop、instanceOop、objArrayOop等类,只有oopDesc、instanceOopDesc、objArrayOopDesc,其实后两者是一回事,instanceOop只是instanceOopDesc指针的别名(typedef)。Java层面的类、接口、枚举会被抽象成C 的klass类。对象的类(Foo.class)是instanceKlass,对象数组的类(Bar[].class)是objArrayKlass,普通数组的类(int[].class)是typeArrayKlass。

除此之外,还有不满足规则的特例。Java对象在虚拟机表示中除了字段外还有个对象头,里面有一个字段记录了对象的GC年龄、hash值等信息,这个字段被命名为markOop。另外,java.lang.ref.Reference及其子类不是用InstanceKlass描述而是用InstanceRefKlass描述,它们会被GC特殊对待。与之类似,java.lang.ClassLoader用InstanceClassLoaderKlass描述,java.lang.Class用InstanceMirrorKlass描述。以上便是对象和类的相关内容,它们的源码位于hotspot/share/oops,本章剩下的部分将首先讨论表示对象的oop,然后讨论表示类的klass。

对象

虚拟机中的对象由oop表示。oop的全称是Ordinary Object Pointer,它来源于Smalltalk和Self语言,字面意思是“普通对象指针”,在HotSpotVM中表示受托管的对象指针。“受托管”是指该指针能被虚拟机的各组件跟踪,如GC组件可以在发现对象不再使用时回收其内存,或者可以在发现对象年龄过大时,将对象移动到另一个内存分区等。总地来说,对象是由对象头和字段数据组成的。

创建对象

创建oop的蓝图是InstanceKlass。InstanceKlass了解对象所有信息,包括字段个数、大小、是否为数组、是否有父类,它能根据这些信息调用 InstanceKlass::allocate_instance创建对应的instanceOop/arrayOop,如代码清单3-1所示:

代码清单3-1 allocate_instance

代码语言:javascript复制
instanceOop InstanceKlass::allocate_instance(TRAPS) {
bool has_finalizer_flag = has_finalizer(); // 是否重写finalizer方法
int size = size_helper(); // 获取对象大小
instanceOop i;
// 在堆上分配对象
i = (instanceOop)Universe::heap()->obj_allocate(...);
if (has_finalizer_flag && !RegisterFinalizersAtInit) {i = register_finalizer(i, CHECK_NULL);
}
return i; // 返回对象
}
oop CollectedHeap::obj_allocate(Klass* klass, int size, TRAPS) {
ObjAllocator allocator(klass, size, THREAD);
return allocator.allocate();
}

这里虚拟机调用Java堆的 CollectedHeap::obj_allocate创建对象。

Obj_allocate内部又使用ObjAllocator创建对象。ObjAllocator做的事情很简单,如代码清单3-2所示:

代码清单3-2 内存分配与对象创建

代码语言:javascript复制
oop MemAllocator::allocate()const{ // ObjAllocator继承自MemAllocator
oop obj = NULL;
Allocation allocation(*this, &obj);
// 根据对象size分配一片内存
HeapWord* mem = mem_allocate(allocation);
if(mem != NULL) {
// 初始化对象头,initialize会返回oop(mem)
obj = initialize(mem);
}
return obj;
}

代码清单3-2揭示了Java对象的实质:一片内存。虚拟机首先获知对象大小,然后申请一片内存(mem_allocate),返回这片内存的首地址(HeapWord,完全等价于char*指针)。接着初始化(initialize)这片内存最前面的一个机器字,将它设置为对象头的数据。然后将这片内存地址强制类型转换为oop(oop类型是指针)返回,最后由allocate_instance再将opp强制类型转换为instanceOop返回。

有很多方法可以查看oop对象布局,了解它有助于深刻理解HotSpotVM的对象实现。使用-XX: PrintFieldLayout虚拟机参数可以输出对象字段的偏移,但是该参数的输出内容比较简略。要想获取详细的对象布局,可以使用JOL(Java Object Layout)工具,但JOL不是JDK自带的工具,需要自行下载。除了JOL外,还可以使用JDK自带的jhsdb工具获取。使用jhsdb hsdb命令打开HotSpot Debugger程序,可以查看oop的内部数据,如图3-2所示。

图3-2 使用jhsdb hsdb命令查看oop的内部数据

oop最开始的两个字段是_mark和_metadata,它们包含一些对象的元数据,接着是包含对象字段的数据。下面将详细介绍_mark和_metadata的内容。

对象头

了解“oop是指向一片内存的指针,只是将这片内存‘视作’(强制类型转换)Java对象/数组”十分重要,因为对象的本质就是用对象头和字段数据填充这片内存。对象头即oopDesc,它只有两个字段,如代码清单3-3所示:

代码清单3-3 对象头结构

代码语言:javascript复制
class oopDesc {
volatile markOop _mark;
union _metadata {
Klass* _klass;
narrowKlass _compressed_klass;
} _metadata;
};

对象头的第一个字段是_mark,也叫Mark Word,虽然由于历史原因带了个oop字样,但是它与oop并没有关系。它在形式上是一个指针,但是HotSpot VM把它当作一个整数来使用。根据CPU位数,markOop表现为32位或者64位整数,不同位(bit)有不同意义,如图3-3所示。

使用VM参数-XX: UseCompressedOops还可以开启对象指针压缩,在64位机器上开启该参数后,可以用32位无符号整数值(narrowOop)来表示oop指针。压缩对象指针允许32位整数表示64位指针。对象引用位数的减少允许堆中存放更多的其他数据,继而提高内存利用率,但是随之而来的问题是64位指针的可寻址范围可能是0~242字节或0~248字节(一般64位CPU的地址总线到不了64位),压缩后只能寻址0~232字节,显然无法覆盖可能的内存范围。对于这个问题,HotSpot VM的应对方案如图3-4所示,其中压缩对象指针有三种寻址模式:

如果堆的高位地址小于32GB,说明不需要基址(base)就能定位堆中任意对象,这种模式也叫作零地址Oop压缩模式(Zero-based Compressed Oops Mode);

如果堆的高位大于等于32GB,说明需要基址,这时如果堆大小小于4GB,说明基址 偏移能定位堆中任意对象;

如果堆大小处于4~32GB,这时只能通过基址 偏移×缩放(scale)才能定位堆中任意对象。

这三种寻址模式最大支持32GB的堆,很显然,如果Java堆大于32GB,那么将无法使用压缩对象指针。

对象头的第二个字段_metadata表示对象关联的类(klass)。它是union类型,_klass表示正常的指针,另一个narrowKlass是针对64位CPU的优化。如果开启-XX: UseCompressedClassPointers,虚拟机会将指向klass的指针压缩为一个无符号32位整数(_compressed_klass),剩下的32位则用于存放对象字段数据,如果是typeArrayOop或objArrayOop,还能存放数组长度。但是压缩klass指针也会遇到和压缩对象指针一样的问题,即寻址范围无法覆盖可能的内存区域,对此,HotSpot VM的解决方案也是使用基址 偏移×缩放进行定位,只是这时候32位无符号整数偏移是narrowKlass而不是narrowOop。

对象哈希值

_mark中有一个hash code字段,表示对象的哈希值。每个Java对象都有自己的哈希值,如果没有重写Object.hashCode()方法,那么虚拟机会为它自动生成一个哈希值。哈希值生成的策略如代码清单3-4所示:

代码清单3-4 对象hash值生成策略

代码语言:javascript复制
static inline intptr_t get_next_hash(Thread * Self, oop obj) {
intptr_t value = 0;
if (hashCode == 0) { // Park-Miller随机数生成器
value = os::random();
} else if (hashCode == 1) { // 每次STW时生成stwRandom做随机
intptr_t addrBits = cast_from_oop<intptr_t>(obj) >> 3;
value = addrBits ^ (addrBits >> 5) ^ GVars.stwRandom;
} else if (hashCode == 2) { // 所有对象均为1,测试用的
value = 1;
} else if (hashCode == 3) { // 每创建一个对象,hash值加一
value =   GVars.hcSequence;
} else if (hashCode == 4) { // 将对象内存地址当作hash值
value = cast_from_oop<intptr_t>(obj);
} else { // Marsaglia xor-shift 随机数算法,生成hashcode
unsigned t = Self->_hashStateX;
t^= (t << 11);
Self->_hashStateX = Self->_hashStateY;
Self->_hashStateY = Self->_hashStateZ;Self->_hashStateZ = Self->_hashStateW;
unsigned v = Self->_hashStateW;
v = (v ^ (v >> 19)) ^ (t ^ (t >> 8));
Self->_hashStateW = v;
value = v;
}
value &= markOopDesc::hash_mask;
if (value == 0) value = 0xBAD;
return value;
}

Java层调用Object.hashCode()或者System.identityHashCode(),最终会调用虚拟机层的runtime/synchronizer的get_next_hash()生成哈希值。

get_next_hash内置六种可选方案,如代码清单3-4所示,可以使用-XX:hashCode=<val>指定生成策略。OpenJDK 12目前默认的策略是Marsaglia XOR-Shift随机数生成器,它通过重复异或和位移自身值,可以得到一个很长的随机数序列周期,生成的随机数序列通过了所有随机性测试。另外,它的速度也非常快,能达到每秒2亿次。

Klass是一个抽象基类,它定义了一些接口(纯虚函数),由InstanceKlass继承并实现这些接口,两者结合可以描述一个Java类的方法有哪些、字段有哪些、父类是否存在等。Klass提供了相当多的关于类的信息,同样可以使用HotSpot Debugger可视化,如图3-5所示。

图3-5 使用jhsdb hsdb命令可视化查看klass

InstanceKlass在虚拟机层描述大部分的Java类,但有少部分Java类有特殊语意:普通类的对象在垃圾回收过程中只需要遍历所有实例字段;

java.lang.Class的对象需要遍历实例字段和静态字段;java.lang.ref.*的对象需要处理被引用对象;java.lang.ClassLoader需要处理类加载数据。这些类的特殊行为不能用InstanceKlass统一表示,因此InstanceKlass之下派生出InstanceMirrorKlass描述java.lang.Class类,InstanceRefKlass描述java.lang.ref.*类,InstanceClassLoaderKlass描述java.lang.ClassLoader类。

字段遍历

在垃圾回收过程中常见的任务是遍历一个对象的所有字段。以G1为例,在Full GC过程中会标记所有成员对象,如代码清单3-5所示:

代码清单3-5 字段遍历

代码语言:javascript复制
inline void G1FullGCMarker::follow_object(oop obj) {
if (obj->is_objArray()) { // 如果对象是数组,则标记每个数组成员
follow_array((objArrayOop)obj);
} else { // 否则标记对象的每个非静态数据成员
obj->oop_iterate(mark_closure());
}
}
// 该方法由上面的obj->oop_iterate调用
ALWAYSINLINE void InstanceKlass::oop_oop_iterate_oop_maps(...) {
OopMapBlock* map = start_of_nonstatic_oop_maps();
OopMapBlock* const end_map = map   nonstatic_oop_map_count();
for (; map < end_map;   map) { // 遍历每个OopMapBlock
oop_oop_iterate_oop_map<T>(map, obj, closure);
}
}

调用obj->oop_iterate后经过一个较长的调用链,会执行oop_oop_iterate_oop_maps,根据代码不难看出它的行为是获取开始OopMapBlock和结束OopMapBlock,然后遍历这些OopMapBlock。

OopMapBlock存储了该对象的字段偏移和个数,分别用offset和count表示。offset表示第一个字段相对于对象头的偏移,count表示对象有多少个字段。另外,如果有父类,则再用一个OopMapBlock表示父类,因此通过遍历对象的所有OopMapBlock就能访问对象的全部字段。

虚表

如果使用C 编程,会用一个Node表示基类,由AddNode继承Node,它们都有一个print方法。现在有一个变量Node *n=newAddNode,静态类型为Node,动态类型为AddNode,调用n->print()函数会根据n的动态类型进行函数派发,由于n的动态类型为AddNode所以调用AddNode::print。在这个过程中,需要为每个对象插入一个虚表。虚表是一个由函数指针构成的数组,可以添加编译参数输出它[1]。

以上面的变量为例,Node虚表的第一个元素是指向Node::print的函数指针,AddNode虚表的第一个元素是指向AddNode::print的指针,n在运行时可以通过查找虚表来定位正确的方法(AddNode::print)。

如果使用Java编程,情况就不一样了。根据前面的描述,每个Java对象即oop都有对象头,对象头里面有一个_klass指向对象的正确的InstanceKlass类型,而InstanceKlass包含了类的所有方法以及父类信息,当执行n.print()时,JVM可以(但是并没有)从对象n的对象头里取出_klass,找到描述AddNode类的InstanceKlass,再在其中寻找print方法。

这一过程并不需要虚表参与。正如上面讨论的,虚表是Java动态派发的优化而不是必要组件,就像native入口之于Method,Java的虚表也是位于InstanceKlass之外,如图3-6所示。

第2章提到类会经历加载、链接、初始化三个阶段,这里我们只讨论了链接阶段的一些步骤,实际上它还会执行很多额外的步骤,如虚表的初始化也是在链接阶段进行的。HotSpot会在类加载阶段计算出虚表大小,然后在链接阶段使用 klassVtable::initialize_vtable()初始化虚表,如代码清单3-6所示:

代码清单3-6 虚表初始化

代码语言:javascript复制
void klassVtable::initialize_vtable(bool checkconstraints, TRAPS) {
...
// 处理当前类的所有方法for(int i = 0; i < len; i  ) {
methodHandle mh(THREAD, methods->at(i));
// 该方法是否为虚方法
bool needs_new_entry = update_inherited_vtable(...);
// 如果是,则需要更新当前类的虚表索引
if (needs_new_entry) {
put_method_at(mh(), initialized);
mh()->set_vtable_index(initialized);
initialized  ;
}
}
...// 和前面类似,处理default方法
}

update_inherited_vtable会经过一系列检查来确定一个方法是否为虚方法以及是否需要加入类的虚表。上述例子的Node与AddNode经过虚表初始化后的vtable如图3-7所示。

也可以开启VM参数-Xlog:vtables=trace查看所有类的虚表的创建过程。在调用虚方法时虚拟机会在运行时常量池中查找n的静态类型Node的print方法,获取它在Node虚表中的index,接着用index定位动态类型AddNode虚表中的虚方法进行调用。第一步的运行时常量池在HotSpotVM中的表示是oops/ConstantPoolCache,它也是对象和类模型的一部分。

本章小结

本章主要围绕对象和类的相关内容展开。3.1节介绍了HotSpot VM中对象和类的设计原则。3.2节介绍了对象和类模型,它们在JVM层表示Java层的对象。3.3节介绍了类模型,它们在JVM层表示Java层的Class<?>。对象和类共同构成对象类二分模型,是HotSpot VM的核心数据结构。

本文给大家讲解的内容是深入解析Java对象和类在HotSpot VM内部的具体实现

  1. 下篇文章给大家讲解的是探讨虚拟机运行时的Java线程、栈帧、Java/JVM沟通、Unsafe类;
  2. 觉得文章不错的朋友可以转发此文关注小编;
  3. 感谢大家的支持!

本文就是愿天堂没有BUG给大家分享的内容,大家有收获的话可以分享下,想学习更多的话可以到微信公众号里找我,我等你哦。

0 人点赞