1. 概述
在本教程中,我们将了解JVM如何在堆中布局对象和数组。
首先,我们将从一些理论开始。然后,我们将探讨不同情况下的不同对象和数组内存布局。
通常,运行时数据区的内存布局不是JVM规范的一部分,由实现者自行决定。因此,每个JVM实现可能有不同的策略来在内存中布局对象和数组。在本教程中,我们将重点介绍一个特定的JVM实现:HotSpot JVM。
我们也可以互换使用JVM和HotSpot JVM术语。
2. 普通对象指针(OOP)
HotSpot JVM使用一种称为普通对象指针(OOPS)的数据结构来表示指向对象的指针。JVM中的所有指针(包括对象和数组)都基于一种称为oopDesc的特殊数据结构。每个oopDesc使用以下信息描述指针:
标记字描述对象头。HotSpot JVM使用这个字来存储身份哈希码、偏向锁定模式、锁定信息和GC元数据。
此外,标记字状态仅包含一个uintptr_t,因此,其大小在32位和64位架构中分别在4和8字节之间变化。此外,有偏向的和正常的对象的标记字是不同的。但是,我们只会考虑普通对象,因为Java 15将弃用偏向锁。
此外,klass字封装了语言级别的类信息,例如类名、修饰符、超类信息等。
对于Java中的普通对象(表示为instanceOop),对象头由标记和klass字加上可能的对齐填充组成。在对象头之后,可能有0个或多个对实例字段的引用。因此,在64位架构中至少有16个字节,因为标记有8个字节、4个字节的klass和另外4个字节用于填充。
对于表示为arrayOop的数组,对象标头除了标记、klass和填充之外,还包含一个4字节的数组长度。同样,由于8个字节的标记、4个字节的klass和另外4个字节的数组长度,这将至少为16个字节。
现在我们已经对理论有了足够的了解,让我们看看内存布局在实践中是如何工作的。
3. 设置JOL
为了检查JVM中对象的内存布局,我们将广泛使用Java对象布局(JOL)。因此,我们需要添加jol-core依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
4. 内存布局示例
让我们首先查看常规VM详细信息:
System.out.println(VM.current().details());
这将打印:
# 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个字节,boolean和byte占用1个字节,short和char占用2个字节,int和float占用4个字节,最后,long和double占用8个字节。有趣的是,如果我们将它们用作数组元素,它们会消耗相同数量的内存。
此外,如果我们通过-XX:-UseCompressedOops禁用压缩引用,则只有引用大小更改为8字节:
# 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类:
public class SimpleInt {
private int state;
}
如果我们打印它的类布局:
System.out.println(ClassLayout.parseClass(SimpleInt.class).toPrintable());
我们会看到类似以下内容:
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个字节的klass。之后,我们有4个字节用于int state。总的来说,这个类的任何对象都会消耗16个字节。
此外,对象标头和状态没有值,因为我们正在解析类布局,而不是实例布局。
4.2 身份哈希码
hashCode()是所有Java对象的通用方法之一。当我们没有为类声明hashCode()方法时,Java将为其使用身份哈希码。
对象的身份哈希码在其生命周期内不会更改。因此,HotSpot JVM在计算出该值后将其存储在标记字中。
让我们看看对象实例的内存布局:
SimpleInt instance = new SimpleInt();
System.out.println(ClassLayout.parseInstance(instance).toPrintable());
HotSpot JVM延迟计算身份哈希码:
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(),这将会改变:
System.out.println("The identity hash code is " + System.identityHashCode(instance));
System.out.println(ClassLayout.parseInstance(instance).toPrintable());
现在,我们可以发现身份哈希码作为标记字的一部分:
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”字节序列:
65 74 b2 25 = 01100101 01110100 10110010 00100101 = 1702146597
4.3 对齐
默认情况下,JVM会向对象添加足够的填充以使其大小成为8的倍数。
例如,考虑SimpleLong类:
public class SimpleLong {
private long state;
}
如果我们解析类布局:
System.out.println(ClassLayout.parseClass(SimpleLong.class).toPrintable());
然后JOL将打印内存布局:
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的内存布局将是:
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的倍数。
如上所示,它添加了4个内部填充字节以在偏移量16处开始long变量(启用更对齐的访问)。然后它将剩余的8个字节添加到long变量之后。
4.4 字段包装
当一个类有多个字段时,JVM可能会以最小化填充浪费的方式分配这些字段。例如,考虑FieldsArrangement类:
public class FieldsArrangement {
private boolean first;
private char second;
private double third;
private int fourth;
private boolean fifth;
}
字段声明顺序及其在内存布局中的顺序是不同的:
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还在标记字中维护锁信息,让我们看看实际效果:
public class Lock {}
如果我们创建这个类的一个实例,它的内存布局将是:
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
但是,如果我们在这个实例上同步:
synchronized (lock) {
System.out.println(ClassLayout.parseInstance(lock).toPrintable());
}
内存布局更改为:
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 年龄和任期
为了将对象提升到老年代(当然是在分代GC中),JVM需要跟踪每个对象的存活数。如前所述,JVM也在标记字内部维护此信息。
为了模拟minor GC,我们将通过将对象分配给volatile变量来创建大量垃圾。这样我们就可以防止JIT编译器可能消除死代码:
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个字节随时间的变化:
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注解在每个标注字段周围添加一些填充,以将每个字段隔离在其自己的缓存行上。因此,这将影响内存布局。
为了更好地理解这一点,让我们考虑一个例子:
public class Isolated {
@Contended
private int v1;
@Contended
private long v2;
}
如果我们检查这个类的内存布局,我们会看到类似这样的东西:
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 数组
正如我们之前提到的,数组长度也是arrayOop的一部分。例如,对于包含3个元素的布尔数组:
boolean[] booleans = new boolean[3];
System.out.println(ClassLayout.parseInstance(booleans).toPrintable());
内存布局如下所示:
[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字和4个字节的长度。在对象头之后,我们有3个字节用于包含3个元素的布尔数组。
4.9 压缩引用
到目前为止,我们的示例是在启用了压缩引用的64位架构中执行的。
使用8字节对齐,我们最多可以使用32GB的堆压缩引用。如果我们超出这个限制甚至手动禁用压缩引用,那么klass字将消耗8个字节而不是4个字节。
让我们看看使用-XX:-UseCompressedOops调整标志禁用压缩oops时同一数组示例的内存布局:
[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字还有4个字节。
5. 总结
在本教程中,我们了解了JVM如何在堆中布置对象和数组。
要进行更详细的探索,强烈建议查看JVM源代码的oops部分。此外,Aleksey Shipilëv在这方面有一篇更深入的文章。
此外,JOL的更多示例作为项目源代码的一部分提供。
与往常一样,本教程的完整源代码可在GitHub上获得。