Java 中的对象内存布局
转自 Memory Layout of Objects in Java
1. 概述
在本教程中,我们将了解JVM如何在堆中布局对象和数组。
首先,我们将从一些理论知识开始。然后,我们将探索不同情况下的对象和数组内存布局。
通常,运行时数据区的内存布局并不是JVM规范的一部分,而是由实现者自行决定的(参考链接)。因此,每个JVM实现可能对对象和数组在内存中的布局采用不同的策略。在本教程中,我们将专注于一个特定的JVM实现:HotSpot JVM。
在本文中,我们也会将JVM和HotSpot JVM这两个术语互换使用。
2. 普通对象指针(OOPs)
HotSpot JVM使用一种称为普通对象指针(OOPS)的数据结构来表示对对象的指针。JVM中的所有指针(包括对象和数组)都基于一个名为oopDesc的特殊数据结构。每个oopDesc用以下信息描述指针:
- 一个 mark word
- 一个可能被压缩的 klass word
标记字用于描述对象头信息。HotSpot JVM使用这个字来存储标识哈希码、偏向锁模式、锁信息和GC元数据等。
此外,标记字的状态只包含一个uintptr_t,因此在32位和64位体系结构中,其大小在4字节和8字节之间变化。同时,偏向和普通对象的标记字是不同的。不过,我们只会考虑普通对象,因为Java 15将弃用偏向锁。
此外,class word 封装了语言级别的类信息,如类名、修饰符、超类信息等。
对于Java中的普通对象,用 instanceOop 表示,对象头由标记字和类字以及可能的对齐填充组成。因此,在64位体系结构中,至少为16字节,其中标记字占8字节,类字占4字节,另有4字节用于填充。
对于数组,用 arrayOop 表示,对象头包含一个4字节的数组长度,以及标记字、类字和填充。同样,在64位体系结构中,至少为16字节,因为标记字占8字节,类字占4字节,数组长度占4字节。
现在我们对理论有了足够的了解,让我们看看实践中内存布局是如何工作的。
3. 设置 JOL
为了检查JVM中对象的内存布局,我们将广泛使用Java Object Layout(JOL)。因此,我们需要添加 jol-core 依赖:
1 | <dependency> |
4. 内存布局示例
让我们先看一下通用VM细节:
1 | System.out.println(VM.current().details()); |
输出将是:
1 | # Running 64-bit HotSpot VM. |
这意味着引用占用4个字节,布尔型和字节类型占1个字节,短整型和字符类型占2个字节,整型和浮点类型占4个字节,最后,长整型和双精度类型占8个字节。有趣的是,如果我们将它们用作数组元素,它们消耗的内存量是相同的。
此外,如果我们通过 -XX:-UseCompressedOops 禁用了压缩引用,则只有引用大小会变为8个字节:
1 | # Field sizes by type: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes] |
4.1. 基本情况
让我们考虑一个SimpleInt类:
1 | public class SimpleInt { |
如果我们打印它的类布局:
1 | System.out.println(ClassLayout.parseClass(SimpleInt.class).toPrintable()); |
我们会看到类布局的信息:
1 | SimpleInt object internals: |
如上所示,对象头为12个字节,包括8个字节的标记字和4个字节的类字。之后,我们有4个字节用于int类型的state字段。因此,该类的任何对象将占用16个字节。
同时,对象头和state字段没有给出值,因为我们解析的是类布局,而不是实例布局。
4.2. 标识哈希码
hashCode()是Java对象的一个常见方法。当我们为一个类不声明 hashCode() 方法时,Java将使用对象的标识哈希码。
标识哈希码在对象的生
命周期内不会改变。因此,HotSpot JVM在计算出标识哈希码后,将其存储在标记字中。
让我们看一下对象实例的内存布局:
1 | SimpleInt instance = new SimpleInt(); |
HotSpot JVM会懒惰地计算标识哈希码:
1 | SimpleInt object internals: |
如上所示,目前标记字似乎还没有存储任何重要的信息。
但是,如果我们在对象实例上调用System.identityHashCode()或甚至是 Object.hashCode() :
1 | System.out.println("The identity hash code is " + System.identityHashCode(instance)); |
现在,我们可以在标记字中看到标识哈希码的存在:
1 | The identity hash code is 1702146597 |
HotSpot JVM将标识哈希码作为“25 b2 74 65”存储在标记字中。其中,最高有效字节为65,因为JVM以小端格式存储该值。因此,为了将这个字节序列恢复为十进制值(1702146597),我们需要反向读取“25 b2 74 65”字节序列:
1 | 65 74 b2 25 = 01100101 01110100 10110010 00100101 = 1702146597 |
4.3. 对齐
默认情况下,JVM会添加足够的填充来使对象的大小成为8的倍数。
例如,考虑SimpleLong类:
1 | public class SimpleLong { |
如果我们解析类布局:
1 | System.out.println(ClassLayout.parseClass(SimpleLong.class).toPrintable()); |
然后,JOL将打印内存布局:
1 | SimpleLong object internals: |
如上所示,对象头和 long 类型的state字段总共占用20个字节。为了使其成为8的倍数,JVM添加了4个字节的填充。
我们还可以通过 -XX:ObjectAlignmentInBytes 调优标志来更改默认对齐大小。 例如,对于相同的类,当 -XX:ObjectAlignmentInBytes=16 时,内存布局如下:
1 | SimpleLong object internals: |
对象头和 long 变量仍然总共占用20个字节。因此,我们需要再增加12个字节,使其成为16的倍数。
如上所示,它在 long 变量前添加了4个内部填充字节,以使 long 变量从偏移量16开始(实现更加对齐访问)。然后,在 long 变量后添加了剩余的8个字节。
4.4. 字段排列
当一个类有多个字段时,JVM可能会以最小化填充浪费的方式分布这些字段。 例如,考虑FieldsArrangement类:
1 | public class FieldsArrangement { |
字段的声明顺序与内存布局中的顺序不同:
1 | OFFSET SIZE TYPE DESCRIPTION VALUE |
主要目的是最小化填充浪费。
4.5. 锁定
JVM还在标记字中维护锁定信息。让我们看看这个过程:
1 | public class Lock {} |
如果我们创建此类的一个实例,它的内存布局将是:
1 | Lock object internals: |
然而,如果我们在该实例上同步:
1 | synchronized (lock) { |
内存布局会发生变化:
1 | Lock object internals: |
如上所示,当持有监视器锁时,标记字的位模式会发生变化。
4.6. 年龄和晋升
为了将对象晋升到老年代(在分代GCs中),JVM需要跟踪每个对象的存活次数。 如前所述,JVM还将此信息维护在标记字中。
为了模拟小GC,我们将通过将对象赋值给 volatile 变量来创建大量的垃圾。这样,我们可以防止JIT编译器进行可能的死代码消除:
1 | volatile Object consumer; |
每当活动对象的地址发生变化时,这可能是因为小GC和在幸存者空间之间移动。 对于每个更改,我们还打印新的对象布局,以查看对象的晋升情况。
以下是标记字的前4个字节随时间的变化:
1 | 09 00 00 00 (00001001 00000000 00000000 00000000) |
4.7. 错误共享和 @Contended
jdk.internal.vm.annotation.Contended 注解(或Java 8上的sun.misc.Contended)是一个提示,用于JVM将带注解的字段隔离以避免错误共享。
简而言之,Contended注解在每个带注解的字段周围添加一些填充,以使每个字段都位于其自己的缓存行中。因此,这将影响内存布局。
为了更好地理解这一点,让我们考虑一个示例:
1 | public class Isolated { |
如果我们检查这个类的内存布局,会看到如下结果:
1 | Isolated object internals: |
如上所示,JVM在每个带注解的字段周围添加了128字节的填充。大多数现代计算机的缓存行大小约为64/128字节,因此添加了128字节的填充。 当然,我们可以使用 -XX:ContendedPaddingWidth 调优标志来控制Contended填充的大小。
请注意,Contended注解是JDK内部的,因此我们应该避免使用它。
此外,我们应该使用 -XX:-RestrictContended 调优标志运行代码;否则,该注解将不会生效。基本上,默认情况下,此注解仅用于内部使用,并且禁用 RestrictContended 将为公共API解锁此功能。
4.8. 数组
正如前面所述,数组长度也是数组oop的一部分。例如,对于包含3个元素的 boolean 数组:
1 | boolean[] booleans = new boolean[3]; |
内存布局如下:
1 | [Z object internals: |
在这里,我们有16字节的对象头,其中包含8字节的标记字、4字节的类元数据指针(klass word)和4字节的数组长度。紧随对象头后,我们有3字节的 boolean 数组,包含3个元素。
4.9. 压缩引用
到目前为止,我们的示例是在启用压缩引用的64位架构上运行的。
使用8字节对齐,我们可以在压缩引用的情况下使用高达32 GB的堆空间。如果我们超越了这个限制,或者手动禁用了压缩引用,则类元数据指针(klass word)将占用8字节而不是4字节。
让我们看一下当禁用压缩oops时,相同数组示例的内存布局,使用 -XX:-UseCompressedOops 调优标志:
1 | [Z object internals: |
如约而至,现在类元数据指针(klass word)增加了4个额外的字节。
5. 结论
在本教程中,我们了解了JVM在堆中如何布局对象和数组。
如果您想更详细地探索这个主题,强烈推荐查看JVM的oops部分源代码。此外,Aleksey Shipilëv在这个领域有一个更加深入的文章。
此外,JOL项目的源代码中提供了更多的JOL示例。
与往常一样,所有的示例都可以在GitHub上找到。