Skip to content

对象创建、布局和访问

对象的创建

在语言层面上,对象的创建通常是通过 new 关键字来完成的(例外:复制、反序列化)。

而在虚拟机层面上,对象(仅讨论普通 Java 对象,不包括数组和 Class 对象)的创建包含下面的五个步骤:

  1. 类加载检查;
  2. 分配内存;
  3. 初始化零值:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值。这一步保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用。
  4. 设置对象头:初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象头中。
  5. 执行 <init> 方法:最后,执行 <init> 方法,即构造方法,将对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生。

类加载检查

虚拟机遇到一条字节码 new 指令时,首先检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。

在 HotSpot 虚拟机中,对象的内存分配方式有两种:指针碰撞(Bump the Pointer)和空闲列表(Free List)。

  • 指针碰撞:假设 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器。在分配内存时,只需要把指针向空闲内存那边移动一段与对象大小相等的距离,这种分配方式称为指针碰撞。
  • 空闲列表:如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了。虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。

选择哪种分配方式取决于 Java 堆是否规整,而 Java 堆是否规整又取决于采用的垃圾收集器是否带有压缩整理(Compact)能力。

因此,当使用 Serial、ParNew 这类带 Compact 能力的收集器时,采用的分配算法是指针碰撞,简单、高效;而使用 CMS 这类基于清除(Sweep)算法的收集器时,理论上采用较为复杂的空闲列表来分配内存。

之所以“理论上”是因为 CMS 在实现上,为了满足多数情况下分配内存更快,设计了 Linear Allocation Buffer(LAB)的分配缓冲区,通过空闲列表拿到一大块分配缓冲区后,在它里面依然可以使用指针碰撞的方式来分配内存。

除了上面介绍的两种如何划分对象可用的空间外,还需要注意在虚拟机中对象的创建非常频繁的,即使修改指针的位置,也可能会引发并发安全问题。

为了解决这个问题,虚拟机采用了两种方式来避免并发安全问题:

  1. 对分配内存空间的动作进行同步处理,即采用 CAS 操作加上失败重试的方法保证操作的原子性。

    CAS(Compare and Swap)是一种乐观锁技术,通过比较并交换的方式来实现原子操作。即认为自己在操作的时候不会有其他线程来干扰,当操作完成后,再去检查一下在操作过程中有没有其他线程对数据进行了修改。

    CAS 操作包含三个操作数:

    • 内存位置 V:表示要更新的变量所在的内存地址;
    • 预期原值 A:表示该内存位置的原始期望值;
    • 新值 B:表示要写入的新值。
  2. 把内存分配的动作按照线程划分在不同的空间之中进行。

    在堆内存中预先划分出一小块内存作为 TLAB(Thread Local Allocation Buffer)缓冲区,每个线程在 Java 堆中预先分配一小块内存,避免了多线程分配内存时的竞争。

    哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有当本地缓冲区用完了,才需要同步锁定,分配新的缓冲区。

初始化零值

内存分配完成后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值,如果使用了 TLAB 的话,这一工作可以提前至 TLAB 分配时顺便进行。

初始化零值保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能够访问到这些字段的数据类型对应的零值。

设置对象头

初始化零值之后,还要设置对象头。例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码(实际上对象的哈希码要延后到调用 Object::hashCode() 方法时才计算)、对象的 GC 分代年龄、锁信息等。

执行 <init> 方法

在上面的步骤完成后,所有字段默认都为零值,并且对象需要的其他资源和状态信息都还没有按照预定的意图构造好。所以在最后,需要执行字节码文件中的 <init> 方法,即构造方法,将对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生。

一般来说 new 指令之后会接着执行 <init> 方法,但是需要根据字节码流中 new 指令后面是否跟随 invokespecial 指令来判断是否执行构造方法,Java 编译器会在遇到 new 关键字的地方同时生成这两条字节码指令。但是也有例外情况,例如在构造函数中调用了 this()super() 方法,这时候会先执行被调用的构造函数。

对象的内存布局

在 HotSpot 虚拟机中,对象在内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头

HotSpot 虚拟机中对象头主要包含两类信息:对象自身运行时数据(Mark Word)和类型指针。

  • 对象自身运行时数据:包括哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等。

    对象需要存储的运行时数据很多,已经超出 32、64 位 Bitmap 结构所能记录的范围,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个有着动态定义的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用存储空间。

    例如,如果对象没有被锁定,那么锁标志信息就没有意义,这部分空间可以用来存储对象的哈希码。如果对象是数组,那么对象头中必须有数组长度信息,这部分空间就不能用来存储哈希码。

24122301.png

表 HotSpot 虚拟机对象头 Mark Word
  • 类型指针:对象指向它的类元数据的指针,虚拟机通过这个指针确定该对象是哪个类的实例。此外,如果对象是一个 Java 数组,那在对象头中还必顿有一块用于记录数组长度的数据。

实例数据

实例数据部分是对象真正存储的有效信息,包括程序中定义的各种类型的字段(包括父类继承下来的字段和子类定义的字段)。

实例数据部分的存储顺序是根据虚拟机分配策略参数(-XX:FieldsAllocationStyle)和字段在 Java 源码中定义的顺序来决定的。HotSpot 虚拟机默认的分配顺序为:longs/doubles、ints、shorts/chars、bytes/booleans、oops(ordinary object pointers,即引用类型)。

从上面的分配顺序可以看出,相同宽度的字段总是被分配到一起,这样做是为了在对象实例数据部分的存储空间上尽量地节省空间。在满足这个条件的前提下,在父类中定义的变量会出现在子类之前。如果 HotSpot 虚拟机的 -XX:+CompactFields 参数被设置为 true(默认为 true),那么子类的较窄的变量有可能会插入到父类变量的空隙中,以节省空间。

对齐填充

对齐填充并不是必然存在的,它仅仅起到占位符的作用。由于 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,而对象头部分正好是 8 字节的整数倍,但是对象头和实例数据之间可能存在一些空隙,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全,以提高访问效率。

对象的访问定位

创建对象自然是为了使用对象,根据 JVM 运行时数据区域部分的介绍,Java 程序会通过栈(虚拟机栈)上的 reference 数据来操作堆上的具体对象。在《Java 虚拟机规范》里只规定了 reference 是指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问堆中的对象,因此对象的访问方式并不是虚拟机规范中定义的内容,而是由具体的虚拟机实现来自行决定的。

Java 中访问对象的方式有两种:句柄访问和直接指针访问。

句柄访问

如果使用句柄访问,Java 堆中将可能会划分出一块内存区域作为句柄池,reference 中存储的就是句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

24122302.png

图 通过句柄访问对象

使用句柄访问对象的优点就是 reference 中存储的是稳定的句柄地址,在对象被移动(例如垃圾收集行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。但是由于需要多一次指针定位的过程,会增加一次额外的访问开销。

直接指针访问

如果使用直接指针访问,reference 中存储的直接就是对象地址。

使用直接指针访问对象的优点就是速度更快,因为对象的访问是直接的,不需要句柄中间层的访问,节省了一次指针定位的时间开销。但是由于对象被移动时,reference 中存储的直接指针就会失效,因此在对象移动时,除了修改对象实例数据指针,还需要修改 reference 中的指针。

24122303.png

图 通过直接指针访问对象
编程洪同学服务平台是一个广泛收集编程相关内容和资源,旨在满足编程爱好者和专业开发人员的需求的网站。无论您是初学者还是经验丰富的开发者,都可以在这里找到有用的信息和资料,我们将助您提升编程技能和知识。
专业开发
高端定制
售后无忧
站内资源均为本站制作或收集于互联网等平台,如有侵权,请第一时间联系本站,敬请谅解!本站资源仅限于学习与参考,严禁用于各种非法活动,否则后果自行负责,本站概不承担!