写在前面

本文隶属于专栏《100个问题搞定Java虚拟机》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢!

本专栏目录结构和文献引用请见100个问题搞定Java虚拟机

解答

在 Hotspot 虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Object Header)、实例数据(Instance Data)和对齐填充(Padding)。

补充

对象头(Object Header)

HotSpot 的对象头由Mark Word和类型指针组成。

Mark Word(标记字段)

对象自身的运行时数据, 如哈希码(Hash Code)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程1D、偏向时间戳等。 这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为“Mark
Word”。

对象需要存储的运行时数据很多,其实已经超出了32位、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本。 考虑到虚拟机的空间效率, Mark
Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。 例如,在32位的Hotspot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间中的25bit
用于存储对象哈希码,4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0, 而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容见下表。

存储内容标志位状态
对象哈希码、对象分代年龄01未锁定
指向锁记录的指针00轻量级锁定
指向重量级锁的指针10膨胀(重量级锁定)
空,不需要记录信息11GC标记
偏向线程ID、偏向时间戳、对象分代年龄01可偏向

压缩指针

在 64位的Java虚拟机中,对象头的标记字段占64位,而类型指针又占了64位。也就是说,每一个Java对象在内存中的额外开销就是16个字节。

以Integer类为例,它仅有一个int类型的私有字段,占4个字节。 因此,每一个Integer对象的额外内存开销至少是400%。这也是为什么Java要引入基本类型的原因之一。

为了尽量较少对象的内存使用量,64位Java虚拟机引入了压缩指针的概念(对应虚拟机选项 -XX:+UseCompressedOops,默认开启),将堆中原本64位的Java对象指针压缩成32位的。

这样一来,对象头中的类型指针也会被压缩成32位,使得对象头的大小从16字节降至12字节。

当然,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型数组。

类型指针

类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。

如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。

实例数据(Instance Data)

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。

字段重排列

为了达到内存对齐的目的,Java虚拟机中会重新分配字段的先后顺序,这叫字段重排列。

Java 虚拟机中有三种排列方法(对应 Java 虚拟机选项 -XX:FieldsAllocationStyle,默认值为 1),但都会遵循如下两个规则。

  1. 如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。这里偏移量指的是字段地址与对象的起始地址差值。以 long 类为例,它仅有一个 long 类型的实例字段。在使用了压缩指针的 64 位虚拟机中,尽管对象头的大小为 12
    个字节,该 long 类型字段的偏移量也只能是 16,而中间空着的 4 个字节便会被浪费掉。
  2. 子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。在具体实现中,Java 虚拟机还会对齐子类字段的起始位置。对于使用了压缩指针的 64 位虚拟机,子类第一个字段需要对齐至 4N;而对于关闭了压缩指针的 64
    位虚拟机,子类第一个字段则需要对齐至 8N。

对齐填充(Padding)

对齐填充起着占位符的作用。

由于Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说,就是对象的大小必须是8字节的整数倍。

而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

为什么需要对齐填充?

打个比方,路上停着的全是房车,而且每辆房车恰好占据两个停车位。现在,我们按照顺序给它们编号。

也就是说,停在 0 号和 1 号停车位上的叫 0 号车,停在 2 号和 3 号停车位上的叫 1 号车,依次类推。

原本的内存寻址用的是车位号。

比如说我有一个值为 6 的指针,代表第 6 个车位,那么沿着这个指针可以找到 3 号车。

现在我们规定指针里存的值是车号,比如 3 指代 3 号车。当需要查找 3 号车时,我便可以将该指针的值乘以 2,再沿着 6 号车位找到 3 号车。

这样一来,32 位压缩指针最多可以标记 2 的 32 次方辆车,对应着 2 的 33 次方个车位。当然,房车也有大小之分。大房车占据的车位可能是三个甚至是更多。

不过这并不会影响我们的寻址算法:我们只需跳过部分车号,便可以保持原本车号 * 2 的寻址系统。

上述模型有一个前提,你应该已经想到了,就是每辆车都从偶数号车位停起。

类似的,JVM 为了快速的定位到对象的位置,需要引入内存对齐的概念(对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为 8)。

默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数。如果一个对象用不到 8N 个字节,那么空白的那部分空间就浪费掉了。

这些浪费掉的空间我们称之为对象间的填充,即对齐填充(padding)。

Q.E.D.


Apache Spark Contributor