深入理解 JVM 对象模型

深入理解JVM运行时内存结构 中,我们宏观地探讨了 JVM 运行时的内存布局。本文将深入分析每个内存区域的细节。由于不同虚拟机的内存管理实现不同,具体的讨论需要聚焦于特定虚拟机和内存区域。基于实用优先的原则,我们以最常用的 HotSpot 虚拟机和 Java 堆内存为例,深入探讨在 HotSpot 虚拟机里:

  1. 对象在堆内存中是如何布局的?
  2. JVM 是如何实现 Java 对象?
  3. 对象是如何创建出来的?

对象的内存布局

在虚拟机里,对象由三部分构成,分别是 对象头实例数据对齐填充。对象头的结构复杂,下面会详细介绍。实例数据是对象真正存储的有效信息,包含所有我们在代码中定义的各种类型的字段的内容,无论是继承自父类还是在子类中定义的。最后是对齐填充,由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

接下来,我们详细说说对象头。HotSpot 虚拟机对象的对象头由三部分构成,分别是存储对象自身运行时数据的 Mark Word、指向类型元数据的指针、当对象是数组时,记录数组长度的 length。在 64 位系统中,对象头的大小是 16 个字节,可以通过指针压缩的方式,压缩到 12 个字节。当 JVM 中存在大量对象的时候,通过指针压缩减少对象内存占用是一个提升性能的手段。

需要注意的是,并不是所有虚拟机在实现对象时,都会在对象头中保留类型指针,还可以通过其它方式来查找对象类型的元数据信息,这点会在最后的「对象的访问」小节中补充讨论。

对象头中最复杂的部分就是Mark Word。它用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。对象需要存储的运行时数据很多,但这些运行时数据与对象自身定义的数据无关,需要额外的存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。在 64 位系统中,它的格式如下表所示。

Mark Word

对象的 HashCode 是一个 31 位的对象标识码,通过System.identifyHashCode(object)生成,我们知道 Object 类也有一个生成 HashCode 的方法hashCode() ,它们之间的区别是无论对象是否重写了 hashCode 方法,identityHashCode 都会返回对象的 HashCode。

对象分代年龄占用的空间大小是 4 位,其最大值是15。在 JVM 采用分代垃圾回收算法时,它会记录对象在 Survivor 区被复制的次数。在 Young GC 中,每 GC 一次,对象在 Survivor 区被复制一次,这个值也会加 1。当对象被复制的次数超过一定的阈值时,它就会被复制到老年代,这个阈值可以通过-XX:MaxTenuringThreshold来设置。

重量级锁采用互斥量来控制对互斥资源的访问,而轻量级锁通过 CAS 机制来实现。因此,两种锁的主要区别是:拿到时,是否存在线程调度和切换上下文的开销。需要注意的是,在拿到这样的描述中,所指的内容并不一致。重量级锁只要拿到互斥信号,就算拿到锁,而 CAS 操作通过 compare 是否成功来判断是否拿到锁。这里的锁,其本质是 是否满足某种条件。因此,需要注意表格中关于指向指针的描述。

Mark Word 中如果记录了线程 ID,则认为该线程获得了锁,如果将线程 ID 清空,则认为自己释放了锁,当然还伴随着锁标志位的改变。线程将自己的 ID 与 Mark Word 中的线程 ID 对比,就知道自己是否拿到当前访问对象的锁。

你可能会有个疑问,轻量级锁和重量级锁的 MarkWord 中并没有线程 ID,那么怎么区分是被哪个线程锁住的呢?其实在轻量级锁加锁的过程中,会拷贝 Mark Word 到锁记录中去,因此只要知道指向锁记录的指针,也就知道锁的线程 ID。那重量级锁呢?由于重量级锁是通过获取互斥信号量的方式,那么这个互斥信号量是否属于当前的线程,其实当前线程是能够判断的,这时候,线程 ID 就变得没有太大的意义了。

对象的实现

JVM 在创建对象时,并没有直接根据 Java 类型信息来创建对应的 C++ 对象,而是设计了一个 OOP-Klass 的模型来描述对象实例。其中 OOP 指的是Ordinary Object Pointer普通对象指针,用于存储对象的实例信息(包含对象的运行时数据),它从数据维度来描述一个 Java 对象中各个属性在运行期的值。而 Klass 则用来存储元数据和方法信息,Java 类的继承信息、成员变量、静态变量、成员方法、构造函数等信息都在 Klass 中保存,JVM 据此便可以在运行期反射出 Java 类的全部结构信息。与此同时,JVM 本身所定义的用于描述 Java 类的 C++ 类也使用 Klass 去描述。

JVM 在oopsHierarchy.hpp 定义了 OOP 和 Klass 各自的体系。

首先是 OOP 的体系:

// JDK 11
typedef class oopDesc*                            oop;
typedef class   instanceOopDesc*            instanceOop;
typedef class   arrayOopDesc*                    arrayOop;
typedef class     objArrayOopDesc*            objArrayOop;
typedef class     typeArrayOopDesc*            typeArrayOop;

OOP 体系的顶级父类是oopDesc,instanceOop 描述 Java 类实例,arrayOop 描述数组,而其下的 objArrayOop 和 typeArrayOop 分别用来描述引用类型数组对象和基本类型数组对象。

在介绍对象内存布局时,我们说对象头用于存储对象自身的运行时数据,而 OOP 中也存储了对象的运行时数据,那我们就到源码中看看是不是这么回事。下面是 oopDesc 的部分代码,声明了其私有的成员变量,包含 Mark Word 以及指向 Klass 对象的指针。如果你到 objArrayOop 和 typeArrayOop 中,还能找到数组长度的成员变量。这跟我们前面所讲的完全一致。

// JDK 11 
// hotspot/src/hotspot/share/oops/oop.hpp
class oopDesc {
    private:
        // Mark Word
        volatile markOop _mark;
        
        // 元数据
        // 使用了union来声明metadata是为了在64位机器上对对象指针进行压缩
        union _metadata {
            // kclass对象,即类的元数据
            Klass* _klass;
              // 如果开启对象指针压缩
            narrowKlass _compressed_klass;
        } _metadata;
}

OOP 就这么多,再来看看 Klass 部分。Klass 提供了一个与 Java 类对等的 C++ 类型描述,除此之外,它还提供虚拟机内部的函数分发机制,这是 Java 多态的基础,后面我们会通过一个简单的示例详细介绍。Klass 体系如下:

class Klass;								// klass体系的基类,继承自:metadata
class   InstanceKlass;						// 描述 Java 类
class     InstanceMirrorKlass;				// 描述 java.lang.Class
class     InstanceClassLoaderKlass;			// 描述类加载器
class     InstanceRefKlass;					// 描述 java.lang.ref.Reference
class   ArrayKlass;							// 描述数组,抽象基类
class     ObjArrayKlass;					// 描述引用类型数组 
class     TypeArrayKlass;					// 描述基本数据类型数组

Klass 系对象用于描述对象的元数据,其中 instanceKlass 可以简单理解为是 java.lang.Class 的 VM 级别的表示,但它们并不等价,instanceKlass 主要作用于整个程序运行过程中,而 Class 类只用于 Java 的反射 API。接下来将以 instanceKlass 为例来介绍 Klass。

Klass 类定义了所有 Klass 类型共有的数据结构和行为,比如类型名称、与其它类之间的关系、访问标识符等等:

// JDK 11
// hotspot/src/share/vm/oops/klass.hpp
class Klass : public Metadata {
        // 反映对象整体布局的描述符,在32位系统中占用4个字节
        // 如果值为正数,表示对象大小,如果值为负数,表示数组
        jint _layout_helper;
        // 类名称,比如:"java/lang/String"表示String对象
        // 而[Ljava/lang/String描述String类型的一维数组
        Symbol* _name;
        // 对应的Java语言层面的Class对象实例
        OopHandle _java_mirror;
        // 父类,指针指向其父类的首地址
        Klass* _super;
        // 第一个子类
        Klass* _subklass;
        // subklass指向第一个子类,如果有多个子类
        // 那么可以通过subklass->nextsibling()找到下一个子类
        Klass* _next_sibling;
        // Java 中类名和类加载器唯一标识了一个类
        // 由同一个类加载器加载的类通过 nextlink 连接起来
        Klass* _next_link;
        ClassLoaderData* _class_loader_data;
        // 访问标识符,Java层面通过 Class.getModifiers()获取
        // 比如:1表示public
        jint _modifier_flags;
        // 类或者接口的访问修饰符
        AccessFlags _access_flags;
        // ......
}

HotSpot 中为每一个已加载的 Java 类创建一个 instanceKlass 对象,用于在 JVM 层面表示 Java 类,它包含了虚拟机内部运行一个类所需要的全部信息,这些成员变量在类的解析阶段 (主要是将常量池中的符号引用转换为直接引用,即运行时实际内存地址) 完成赋值:

// JDK 11
// hotspot/src/share/vm/oops/instanceKlass.hpp
class InstanceKlass: public Klass {
     protected:
        // 注解
        Annotations* _annotations;
        // 常量池
        ConstantPool* _constants;
        // 内部类
          Array<jushort>* _inner_classes;
        // 方法列表
        Array<Method*>* _methods;
        // 方法顺序
        Array<int>* _method_ordering;
        Array<Method*>* _default_methods;
        // 实现的接口
        Array<Klass*>* _local_interfaces;
        // 继承来的接口
        Array<Klass*>* _transitive_interfaces;
        // 静态变量的数量
        u2 _static_oop_field_count;
        // 成员变量的数量
        u2 _java_fields_count;
        // 实例以及静态变量信息
        Array<u2>*      _fields;
        // ......
}

通过上述的内容,相信你对 JVM 如何实现 Java 对象有了更全面的认识,总结一下就是 JVM 对象是按照如下方式存储的。

  • 对象的引用保存在栈上
  • 对象实例(OOP)存储在堆上
  • 对象元数据(Klass)存储在方法区

JVM 对象存储结构

我们都知道面向对象编程有三大特性:封装、继承和多态,而 OOP-Klass 就是多态的 JVM 层实现。最后,通过一个简单的示例来简单介绍 OOP-Klass 的具体应用,这个示例来自「康杨:云时代的JVM原理与实战」第 10 讲

public class Book {
    private String  name;
    public String getName(){
        return  name;
    }
    public void print(){
        System.out.println("Common Book");
    }
}

public class ColorBook extends Book {
    public void print(){
        System.out.println("Color Book");
    }
    public static void main(String[] args) {
        Book book = new Book();
        Book colorBook = new ColorBook();
        book.print();
        colorBook.print();
    }
}

运行程序会打印Common BookColor Book。也就是说,我们虽然定义 colorBook 的类型是 Book,但是它还是准确定位到了 ColorBook 这个类,那它是如何做到的呢?这就涉及了 Klass 模型里的函数表功能。

函数表是 Klass 的一部分,它是一个存储类方法地址指针的数组。每个方法在函数表中都有一个条目,用来标识方法的地址。换句话说,函数表中的每个条目都对应 Java 类中的一个方法,且条目中存储了方法的地址,Java 虚拟机能够根据方法的索引或名称来查找并调用相应的方法。

当通过父类的引用调用方法的时候,Java 虚拟机会根据实际对象的类型,在函数表里查找对应的方法地址,然后进行方法调用。Java 采用的这种动态绑定机制,是实现多态特性的重要手段之一。

你可以结合图示来理解,ColorBook 拷贝了一份 Book 函数表,使它的函数表指针指向新的函数表,因为 ColorBook 覆写了 Book 的函数 print(),所以把函数表里覆写函数的函数指针替换成了 ColorBook 覆写的函数指针,而被调用函数在函数表里偏移量是固定的,这就是多态功能的原理。

对象的创建

当 JVM 遇到 new 指令,知道需要创建一个对象的时候,它首先回去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

如果这个类还没加载好,或者这个类还没有经历完整的加载、链接、初始化流程。那么它会先启动完整的加载流程,或继续执行之前未完成的类加载步骤,具体内容可以参考 深入理解 JVM 类加载机制。如果这个类已经加载好,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定(如何确定对象所需内存大小,在本文第一小节已经介绍),现在要做的就是在堆内存中划分一块同等大小的内存块出来。

而划分指定大小内存块的难易程度取决于堆空间是否规整。假如堆内存是绝对规整的,已经使用过的内存在一边,空闲的空间被放在另一边,中间放着一个指针作为分界点的指示器,那么分配内存只需要将指针往空闲空间的方向挪动一段距离即可,这种分配方式称为指针碰撞。但如果堆中的内存并不规整,已使用的内存和空闲的内存相互交错在一起,JVM 就需要维护一个全局的空闲列表,用来记录当前 JVM 堆的使用情况。在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为空闲列表

因此,当你选择了 ParNew、Serial 这种带有整理过程的垃圾回收器时,因为有整理的过程,所以堆通常是非常规整的,系统采用的分配算法是指针碰撞。而当使用CMS这种基于清除(Sweep)算法的收集器时,堆一般是不规整的,理论上系统只能采用较为复杂的空闲列表来分配内存。

另外,空间的分配还存在线程安全问题,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。解决这个有两种可选方案:一种是采用CAS配上失败重试的方式保证更新操作的原子性;另外一种是为每个线程预先分配一小块内存,称为本地线程分配缓冲(TLAB),先在 TLAB 中分配,等 TLAB 用完了,再使用前一种方案。

内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

接下来,JVM 还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的 HashCode、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。根据 JVM 当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

这时,从虚拟机的视角来看,一个新的对象已经产生了,它已经有了运行时空间的地址,可以对它进行操作了。但从 Java 语言层面来说,需要对已创建的对象初始化,即 Class 文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。等对象初始化完成后,一个真正可用的对象才算完全被构造出来。

对象的访问

为了能够使用创建好的对象,JVM 在堆中创建对象时,会在栈中创建 Reference 数据来操作这个对象。虚拟机规范里只规定了 Reference 类型是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位和访问到堆中对象的具体位置,所以对象的访问方式也是由虚拟机的实现而定的,主流的访问方式有使用句柄直接指针两种。

使用句柄

使用句柄访问时,会在堆中划分出一块内存作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据的具体地址,其结构如下左图所示。

句柄从本质上可以看作是一种指向指针的指针。应用程序启动后,组成程序的各个对象是驻留内存的,一般来说,只要知道了对象的内存首地址,就可以随时通过这个地址来访问对象。但在某些操作系统中,其内存管理器经常在内存中来回移动对象,以此来满足内存分配的需要。这跟某些垃圾回收算法类似,内存经过整理后,可以得到更大块的连续内存空间,而不是内存碎片。而句柄就是用来记录这些对象的内存地址的,每当对象的内存地址发生变化时,就会在句柄中修改该对象的内存地址。而只要持有这个对象的句柄,不管对象在内存中如何移动,都可以通过句柄访问这个对象。

这也是使用句柄来访问对象的最大优势,reference 中存储的是句柄地址,在对象被移动时 (垃圾回收时移动对象是非常普遍的行为 ),reference 本身不需要做任何修改。

在讲解对象内存布局时,我们提到过,并不是所有虚拟机在实现对象时,都会在对象头中保留类型指针,还可以通过其它方式来查找对象类型的元数据信息。当使用句柄来访问对象时,就无需在对象头中存储类型指针。

直接指针

顾名思义,使用直接指针方式访问对象时,reference 中存储的是对象的首地址,但在对象实例数据中就必须存放对象类型数据的指针,其结构如上右图所示。

使用直接指针的最大好处就是速度快,相比于句柄访问,节省了一次指针定位的时间开销,在 Java 中访问对象非常频繁,这类开销积少成多也是一项极为可观的成本。但需要注意的是,如果是访问对象本身的类型数据,仍然需要一次指针定位。就 Hotspot 而言,它主要是用这种方式来进行对象访问。

最后

JVM 对象的内存布局以及 OOP-Klass 模型相关的知识点比较繁杂,所以我总结了两点核心内容,帮你梳理记忆。

首先,在 HotSpot 虚拟机中,对象在内存中的布局主要分为 3 个部分:对象头、实例数据、对齐填充。其中,对象头主要存储对象的状态信息以及类的元数据指针,虚拟机可以通过这个指针访问到这个类对应的所有类型信息;而实例数据则是对象真正存储的有效性信息,即在代码中定义的各种类型的字段内容;对齐填充不是一定存在的,也没有特殊的含义,仅仅起到占位的作用。

其次,JVM 通过 OOP-Klass 模型在虚拟机层面实现了 Java 中的类和对象,其中 Klass 是虚拟机层面的 Java 类,它通过函数表来实现语言层面的多态。而 OOP 则用于存储对象的实例信息,它从数据维度来描述一个 Java 对象中各个属性在运行期的值。

最后,内存布局描述了 JVM 层面对象是如何存储的,OOP-Klass描述了 JVM 层面对象是如何实现的。熟练掌握对象的内存布局以及 OOP-Klass 模型,对我们理解和掌握 Java 的 GC 机制、锁机制,尤其是 synchronized 的实现,有很大帮助。

参考资料

深入理解Java虚拟机(第三版)
揭秘Java虚拟机:JVM设计原理与实现
JVM对象的内部机制和存在方式是怎样的?
klass-oop对象模型研究

深入理解 JVM 系列的第 7 篇,完整目录请移步:深入理解 JVM 系列
本站暂时未接入任何评论系统,如对本文有任何疑问,可移步到微信公众号评论
在微信客户端内打开右侧链接即可评论:深入理解 JVM 对象模型
不遗漏重要文章,欢迎关注本站公众号,手机扫描下方二维码或微信搜索 橙子成绩好
微信公众号二维码

回到首页