HotSpot 虚拟机对象

1 对象的创建

在 Java 虚拟机中,对象创建的过程可以分为以下三个步骤:

1.1 分配内存

在创建对象之前,需要先为对象分配内存空间。在 Java 中,内存的分配是在 Java 堆中进行的。在分配内存时,JVM 会根据对象的大小(由类的实例变量决定)来确定需要分配的内存大小。

分配内存的方式可以有多种,比如指针碰撞或空闲列表等:

  • 指针碰撞是一种内存分配方式,假设 Java 堆内存绝对规整,对象在一侧,空闲内存在另一侧,由一个指针作为分界点。分配内存时只需将指针向空闲空间方向移动对象大小距离。
  • 空闲列表:如果 Java 堆内存不规整,则使用空闲列表分配内存。虚拟机维护一个列表来记录可用的内存块。在分配内存时,从列表中找到一块足够大的空间划分给对象实例,并更新列表记录。

内存分配方式的选择取决于 Java 堆是否规整,而 Java 堆是否规整又取决于所采用的垃圾收集器是否带有空间压缩整理能力。因此,使用带压缩整理过程的收集器时,采用的分配算法往往是指针碰撞,而使用基于清除算法的 CMS 收集器时,理论上只能采用较为复杂的空闲列表分配算法。

在 JVM 中,创建对象是很频繁的操作。在并发情况下,仅修改指针指向的位置是不安全的,因为可能会出现多个线程同时分配内存的情况。为了解决这个问题,有两种可选方案:

  • 对内存分配的动作进行同步处理,虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
  • 把内存分配的动作按照线程划分在不同的空间中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲 (Thread Local Allocation Buffer,TLAB)。线程在本地缓冲区中分配内存,只有本地缓冲区用完时,分配新的缓存区才需要同步锁定。

    虚拟机是否使用 TLAB,可以通过 -XX:+/-UseTLAB 参数来设定。

1.2 初始化对象

内存分配完成后,虚拟机必须将分配到的内存空间(除了对象头)都初始化为零值。如果使用了 TLAB,可以在 TLAB 分配时顺便进行此操作。这确保了 Java 代码中可以不为实例字段赋初值就直接使用它们,程序能访问到这些字段的数据类型所对应的零值。

接下来,JVM 要对对象进行必要的设置,例如确定对象的类实例、如何找到类的元数据、对象的哈希码和 GC 分代年龄等信息。这些信息存储在对象头 (Object Header) 中。对象头的设置方式取决于虚拟机当前运行的状态,如是否启用偏向锁等。

完成上述工作后,从虚拟机的视角来看,新对象已经产生。但从 Java 程序的视角看,对象创建才刚刚开始。构造函数(即 Class 文件中的 <init>() 方法)还未执行,所有字段均为默认的零值,对象需要的其他资源和状态信息也尚未按照预定的意图构造好。通常,Java 编译器会在遇到 new 关键字时同时生成 new 指令和 invokespecial 指令。new 指令后面会紧接着执行 <init>() 方法,按程序员的意愿对对象进行初始化。这样,真正可用的对象才算完全构造出来。

以下是 HotSpot 虚拟机字节码解释器 bytecodeInterpreter.cpp 的代码片段。这个解释器的实现中很少被使用,在大多数平台上都会使用模板解释器,尤其是代码被即时编译器执行时,它们之间的差异更加显著。然而,这段代码对于了解 HotSpot 的运作过程是非常有用的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// 确保常量池中存放的是已解释的类
if (!constants->tag_at(index).is_unresolved_klass()) {
    // 断言确保是 klassOop 和 instanceKlassOop
    oop entry = (klassOop) *constants->obj_at_addr(index);
    assert(entry->is_klass(), "Should be resolved klass");
    klassOop k_entry = (klassOop) entry;
    assert(k_entry->klass_part()->oop_is_instance(), "Should be instanceKlass");
    instanceKlass* ik = (instanceKlass*) k_entry->klass_part();
    // 确保对象所属类型已经经过初始化阶段
    if ( ik->is_initialized() && ik->can_be_fastpath_allocated() ) {
        // 取对象长度
        size_t obj_size = ik->size_helper();
        oop result = NULL;
        // 记录是否需要将对象所有字段置零值
        bool need_zero = !ZeroTLAB;
        // 是否在 TLAB 中分配对象
        if (UseTLAB) {
            result = (oop) THREAD->tlab().allocate(obj_size);
        }
        if (result == NULL) {
            need_zero = true;
            // 直接在 eden 中分配对象
retry:
            HeapWord* compare_to = *Universe::heap()->top_addr();
            HeapWord* new_top = compare_to + obj_size;
            // cmpxchg 是 x86 中的 CAS 指令,这里是一个 C++ 方法,通过 CAS 方式分配空间,并发失败的
            //   话,转到 retry 中重试直至成功分配为止
            if (new_top <= *Universe::heap()->end_addr()) {
                if (Atomic::cmpxchg_ptr(new_top, Universe::heap()->top_addr(), compare_to) != compare_to) {
                    goto retry;
                }
                result = (oop) compare_to;
            }
        }
        if (result != NULL) {
            // 如果需要,为对象初始化零值
            if (need_zero ) {
                HeapWord* to_zero = (HeapWord*) result + sizeof(oopDesc) / oopSize;
                obj_size -= sizeof(oopDesc) / oopSize;
                if (obj_size > 0 ) {
                    memset(to_zero, 0, obj_size * HeapWordSize);
                }
            }
            // 根据是否启用偏向锁,设置对象头信息
            if (UseBiasedLocking) {
                result->set_mark(ik->prototype_header());
            } else {
                result->set_mark(markOopDesc::prototype());
            }
            result->set_klass_gap(0);
            result->set_klass(k_entry);
            // 将对象引用入栈,继续执行下一条指令
            SET_STACK_OBJECT(result, 0);
            UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1);
        }
    }
}

1.3 设置对象的引用

在对象初始化完成之后,需要将对象的引用返回给调用者或存储到变量中,以便后续对对象的访问和操作。在 Java 中,对象的引用通常是通过栈中的变量来进行存储和传递的。

需要注意的是,虽然对象的创建过程是一个简单的过程,但是在实际应用中,可能会出现一些复杂的情况,例如对象的多线程访问、对象的逃逸分析等。在这些情况下,对象的创建过程可能会变得更加复杂,需要更加深入地理解 Java 内存模型和线程安全性等概念。

另外,对象的创建过程可能会受到 Java 虚拟机参数的影响,例如堆内存大小、GC 策略等。因此,在进行对象创建时,需要了解 Java 虚拟机的一些基本参数和机制,并根据实际情况进行调整,以提高程序的性能和可靠性。

2 对象的内存布局

在 HotSpot 中,一个对象的内存布局可以分为三个部分:对象头 (Header)实例数据 (Instance Data)对齐填充 (Padding)

2.1 对象头

HotSpot 虚拟机对象的对象头部分包括标记字段类型指针两类信息:

  1. 标记字段

    标记字段主要用于存储对象的运行时状态信息,包括对象的哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,其中的哈希码是为了方便在哈希表中查找对象而添加的,锁状态标志则用于标记对象是否被锁定,线程持有的锁以及偏向线程 ID、偏向时间戳则是为了支持偏向锁而添加的。

  2. 类型指针

    类型指针指向对象所属的类型元数据,即类对象。Java 虚拟机通过类型指针来确定该对象是哪个类的实例,从而可以根据对象所属的类进行方法调用、字段访问等操作。

    类型指针的大小在不同的虚拟机中可能不同,通常是一个指针大小,即 32 位虚拟机中是 4 个字节,64 位虚拟机中是 8 个字节。由于类型指针在对象头中占用的存储空间比较大,如果对象定义的类型是已知的,可以通过使用 JVM 参数 -XX:+UseCompressedOops-XX:+UseCompressedClassPointers 来压缩类型指针的大小,从而节省内存空间。

    需要注意的是,并非所有的虚拟机实现都需要在对象数据上保留类型指针,一些虚拟机实现会将类型指针存储在另外的位置,例如在一张哈希表中,这些实现可能会比在对象头中存储类型指针更高效。

    在 HotSpot 虚拟机中,对象头的存储布局会随着虚拟机的版本和使用的指针压缩技术而有所不同。例如,在 32 位的 HotSpot 虚拟机中,对象头部分的标记字段和类型指针共占用 32 个比特的存储空间,而在 64 位的 HotSpot 虚拟机中,对象头部分的标记字段和类型指针共占用 64 个比特的存储空间。不同的对象状态可能会影响对象头部分的存储结构和存储内容,例如,如果对象被锁定,则需要在对象头中存储锁状态标志。

2.2 对象数据

实例数据部分存储对象中有效信息,包括程序代码中定义的各种类型的字段,无论从父类继承还是在子类中定义的字段都需要记录。存储顺序受到虚拟机分配策略和 Java 源码中字段定义顺序的影响。HotSpot 虚拟机默认的分配顺序为 longs/doublesintshorts/charsbytes/booleansOOPs (Ordinary Object Pointers)。此外,从默认的分配策略可以看出,相同宽度的字段总是被分配到一起存放。在满足此前提条件的情况下,父类中定义的变量出现在子类之前。若 HotSpot 虚拟机的 +XX:CompactFields 参数为 true,子类中较窄的变量也可以插入父类变量的空隙中,以节省空间。

2.3 对齐填充

对齐填充是用于对齐对象的内存布局的一种技术。由于 JVM 在分配内存时通常按照 8 字节对齐,因此可能会出现对象的实例变量未对齐的情况。为了解决这个问题,HotSpot 会在实例数据的末尾添加一些额外的空间,以确保对象的大小是 8 字节的倍数。

3 对象的访问定位

创建对象的目的是为了使用它,程序通过栈上的 reference 操作堆上的对象。由于《Java 虚拟机规范》只定义了 reference 为指向对象的引用,没有规定引用如何定位、访问堆中对象的位置,因此对象的访问方式由虚拟机实现。主流访问方式有句柄直接指针两种。

  • 使用句柄访问时,Java 堆会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。如图所示:

    通过句柄访问对象
  • 使用直接指针访问时,必须考虑如何放置访问类型数据的相关信息,因为直接使用对象地址访问会遗漏这些信息。Reference 存储的是对象地址,如果只需要访问对象本身,就不需要额外的间接访问开销。如图所示:

    通过直接指针访问对象

HotSpot 使用的是句柄访问方式。


欢迎关注我的公众号,第一时间获取文章更新:

微信公众号

相关内容