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用以下信息描述指针:

标记字用于描述对象头信息。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
2
3
4
5
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>

4. 内存布局示例

让我们先看一下通用VM细节:

1
System.out.println(VM.current().details());

输出将是:

1
2
3
4
# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

这意味着引用占用4个字节,布尔型和字节类型占1个字节,短整型和字符类型占2个字节,整型和浮点类型占4个字节,最后,长整型和双精度类型占8个字节。有趣的是,如果我们将它们用作数组元素,它们消耗的内存量是相同的。

此外,如果我们通过 -XX:-UseCompressedOops 禁用了压缩引用,则只有引用大小会变为8个字节:

1
2
# Field sizes by type: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 8, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

4.1. 基本情况

让我们考虑一个SimpleInt类:

1
2
3
public class SimpleInt {
private int state;
}

如果我们打印它的类布局:

1
System.out.println(ClassLayout.parseClass(SimpleInt.class).toPrintable());

我们会看到类布局的信息:

1
2
3
4
5
6
SimpleInt object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int SimpleInt.state N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

如上所示,对象头为12个字节,包括8个字节的标记字和4个字节的类字。之后,我们有4个字节用于int类型的state字段。因此,该类的任何对象将占用16个字节。

同时,对象头和state字段没有给出值,因为我们解析的是类布局,而不是实例布局。

4.2. 标识哈希码

hashCode()是Java对象的一个常见方法。当我们为一个类不声明 hashCode() 方法时,Java将使用对象的标识哈希码。

标识哈希码在对象的生

命周期内不会改变。因此,HotSpot JVM在计算出标识哈希码后,将其存储在标记字中。

让我们看一下对象实例的内存布局:

1
2
SimpleInt instance = new SimpleInt();
System.out.println(ClassLayout.parseInstance(instance).toPrintable());

HotSpot JVM会懒惰地计算标识哈希码:

1
2
3
4
5
6
7
8
SimpleInt object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1) # mark
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0) # mark
8 4 (object header) 9b 1b 01 f8 (10011011 00011011 00000001 11111000) (-134145125) # klass
12 4 int SimpleInt.state 0
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

如上所示,目前标记字似乎还没有存储任何重要的信息。

但是,如果我们在对象实例上调用System.identityHashCode()或甚至是 Object.hashCode()

1
2
System.out.println("The identity hash code is " + System.identityHashCode(instance));
System.out.println(ClassLayout.parseInstance(instance).toPrintable());

现在,我们可以在标记字中看到标识哈希码的存在:

1
2
3
4
5
6
7
The identity hash code is 1702146597
SimpleInt object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 25 b2 74 (00000001 00100101 10110010 01110100) (1957831937)
4 4 (object header) 65 00 00 00 (01100101 00000000 00000000 00000000) (101)
8 4 (object header) 9b 1b 01 f8 (10011011 00011011 00000001 11111000) (-134145125)
12 4 int SimpleInt.state 0

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
2
3
public class SimpleLong {
private long state;
}

如果我们解析类布局:

1
System.out.println(ClassLayout.parseClass(SimpleLong.class).toPrintable());

然后,JOL将打印内存布局:

1
2
3
4
5
6
7
SimpleLong object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (alignment/padding gap)
16 8 long SimpleLong.state N/A
Instance size: 24 bytes
Space losses: 4 bytes internal + 0 bytes external = 4 bytes total

如上所示,对象头和 long 类型的state字段总共占用20个字节。为了使其成为8的倍数,JVM添加了4个字节的填充。

我们还可以通过 -XX:ObjectAlignmentInBytes 调优标志来更改默认对齐大小。 例如,对于相同的类,当 -XX:ObjectAlignmentInBytes=16 时,内存布局如下:

1
2
3
4
5
6
7
8
SimpleLong object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (alignment/padding gap)
16 8 long SimpleLong.state N/A
24 8 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 4 bytes internal + 8 bytes external = 12 bytes total

对象头和 long 变量仍然总共占用20个字节。因此,我们需要再增加12个字节,使其成为16的倍数。

如上所示,它在 long 变量前添加了4个内部填充字节,以使 long 变量从偏移量16开始(实现更加对齐访问)。然后,在 long 变量后添加了剩余的8个字节。

4.4. 字段排列

当一个类有多个字段时,JVM可能会以最小化填充浪费的方式分布这些字段。 例如,考虑FieldsArrangement类:

1
2
3
4
5
6
7
public class FieldsArrangement {
private boolean first;
private char second;
private double third;
private int fourth;
private boolean fifth;
}

字段的声明顺序与内存布局中的顺序不同:

1
2
3
4
5
6
7
8
9
10
OFFSET  SIZE      TYPE DESCRIPTION                               VALUE
0 12 (object header) N/A
12 4 int FieldsArrangement.fourth N/A
16 8 double FieldsArrangement.third N/A
24 2 char FieldsArrangement.second N/A
26 1

boolean FieldsArrangement.first N/A
27 1 boolean FieldsArrangement.fifth N/A
28 4 (loss due to the next object alignment)

主要目的是最小化填充浪费。

4.5. 锁定

JVM还在标记字中维护锁定信息。让我们看看这个过程:

1
public class Lock {}

如果我们创建此类的一个实例,它的内存布局将是:

1
2
3
4
5
6
7
Lock object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00
4 4 (object header) 00 00 00 00
8 4 (object header) 85 23 02 f8
12 4 (loss due to the next object alignment)
Instance size: 16 bytes

然而,如果我们在该实例上同步:

1
2
3
synchronized (lock) {
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}

内存布局会发生变化:

1
2
3
4
5
6
Lock object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) f0 78 12 03
4 4 (object header) 00 70 00 00
8 4 (object header) 85 23 02 f8
12 4 (loss due to the next object alignment)

如上所示,当持有监视器锁时,标记字的位模式会发生变化。

4.6. 年龄和晋升

为了将对象晋升到老年代(在分代GCs中),JVM需要跟踪每个对象的存活次数。 如前所述,JVM还将此信息维护在标记字中。

为了模拟小GC,我们将通过将对象赋值给 volatile 变量来创建大量的垃圾。这样,我们可以防止JIT编译器进行可能的死代码消除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
volatile Object consumer;
Object instance = new Object();
long lastAddr = VM.current().addressOf(instance);
ClassLayout layout = ClassLayout.parseInstance(instance);

for (int i = 0; i < 10_000; i++) {
long currentAddr = VM.current().addressOf(instance);
if (currentAddr != lastAddr) {
System.out.println(layout.toPrintable());
}

for (int j = 0; j < 10_000; j++) {
consumer = new Object();
}

lastAddr = currentAddr;
}

每当活动对象的地址发生变化时,这可能是因为小GC和在幸存者空间之间移动。 对于每个更改,我们还打印新的对象布局,以查看对象的晋升情况。

以下是标记字的前4个字节随时间的变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
09 00 00 00 (00001001 00000000 00000000 00000000)
^^^^
11 00 00 00 (00010001 00000000 00000000 00000000)
^^^^
19 00 00 00 (00011001 00000000 00000000 00000000)
^^^^
21 00 00 00 (00100001 00000000 00000000 00000000)
^^^^
29 00 00 00 (00101001 00000000 00000000 00000000)
^^^^
31 00 00 00 (00110001 00000000 00000000 00000000)
^^^^
31 00 00 00 (00110001 00000000 00000000 00000000)
^^^^

4.7. 错误共享和 @Contended

jdk.internal.vm.annotation.Contended 注解(或Java 8上的sun.misc.Contended)是一个提示,用于JVM将带注解的字段隔离以避免错误共享

简而言之,Contended注解在每个带注解的字段周围添加一些填充,以使每个字段都位于其自己的缓存行中。因此,这将影响内存布局。

为了更好地理解这一点,让我们考虑一个示例:

1
2
3
4
5
6
7
8
public class Isolated {

@Contended
private int v1;

@Contended
private long v2;
}

如果我们检查这个类的内存布局,会看到如下结果:

1
2
3
4
5
6
7
8
9
Isolated object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 128 (alignment/padding gap)
140 4 int Isolated.i N/A
144 128 (alignment/padding gap)
272 8 long Isolated.l N/A
Instance size: 280 bytes
Space losses: 256 bytes internal + 0 bytes external = 256 bytes total

如上所示,JVM在每个带注解的字段周围添加了128字节的填充。大多数现代计算机的缓存行大小约为64/128字节,因此添加了128字节的填充。 当然,我们可以使用 -XX:ContendedPaddingWidth 调优标志来控制Contended填充的大小。

请注意,Contended注解是JDK内部的,因此我们应该避免使用它。

此外,我们应该使用 -XX:-RestrictContended 调优标志运行代码;否则,该注解将不会生效。基本上,默认情况下,此注解仅用于内部使用,并且禁用 RestrictContended 将为公共API解锁此功能。

4.8. 数组

正如前面所述,数组长度也是数组oop的一部分。例如,对于包含3个元素的 boolean 数组:

1
2
boolean[] booleans = new boolean[3];
System.out.println(ClassLayout.parseInstance(booleans).toPrintable());

内存布局如下:

1
2
3
4
5
6
7
8
9
10
[Z object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 # mark
4 4 (object header) 00 00 00 00 # mark
8 4 (object header) 05 00 00 f8 # klass
12 4 (object header) 03 00 00 00 # array length
16 3 boolean [Z.<elements> N/A
19 5 (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 5 bytes external = 5 bytes total

在这里,我们有16字节的对象头,其中包含8字节的标记字、4字节的类元数据指针(klass word)和4字节的数组长度。紧随对象头后,我们有3字节的 boolean 数组,包含3个元素。

4.9. 压缩引用

到目前为止,我们的示例是在启用压缩引用的64位架构上运行的。

使用8字节对齐,我们可以在压缩引用的情况下使用高达32 GB的堆空间如果我们超越了这个限制,或者手动禁用了压缩引用,则类元数据指针(klass word)将占用8字节而不是4字节。

让我们看一下当禁用压缩oops时,相同数组示例的内存布局,使用 -XX:-UseCompressedOops 调优标志:

1
2
3
4
5
6
7
8
9
10
[Z object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 # mark
4 4 (object header) 00 00 00 00 # mark
8 4 (object header) 28 60 d2 11 # klass
12 4 (object header) 01 00 00 00 # klass
16 4 (object header) 03 00 00 00 # length
20 4 (alignment/padding gap)
24 3 boolean [Z.<elements> N/A
27 5 (loss due to the next object alignment)

如约而至,现在类元数据指针(klass word)增加了4个额外的字节。

5. 结论

在本教程中,我们了解了JVM在堆中如何布局对象和数组。

如果您想更详细地探索这个主题,强烈推荐查看JVM的oops部分源代码。此外,Aleksey Shipilëv在这个领域有一个更加深入的文章

此外,JOL项目的源代码中提供了更多的JOL示例

与往常一样,所有的示例都可以在GitHub上找到。