在本文中,下面的mark即表示Mark World结构,klass即表示Klass Word结构。它们是
oopDesc
中的数据结构。 在本文中,hashcode 就是 identity hash code(是指不经重写过由jvm计算的hashcode)。
总览
在本教程中,我们将了解JVM如何在堆中布置对象和数组。首先,我们将从一些理论开始。然后,我们将探讨在不同情况下的不同对象和数组的内存布局。
通常,运行时数据区域的内存布局不是JVM规范的一部分,并由实现者自行决定。因此,每个JVM实现可能具有不同的策略来在内存中布局对象和数组。在本教程中,我们重点介绍一种特定的JVM实现:HotSpot JVM。
我们也可以互换使用的JVM和HotSpot JVM术语。
普通对象指针(OOP)
HotSpot JVM使用称为普通对象指针(OOPS)的数据结构来表示指向对象的指针。JVM中的所有指针(对象和数组)均基于称为oopDesc的特殊数据结构。每个oopDesc使用以下信息描述指针:
- 一个mark word
- 一个可能被压缩的klass word
着实不确定该把klass word翻译成什么,其作用是存储语言级别的类信息
mark word描述对象头。HotSpot JVM使用此结构来存储哈希码,偏向锁,锁信息和GC元数据。
此外,mark word状态仅包含一个uintptr_t,因此,在32位和64位架构中,其大小在4字节和8字节之间变化。 同样,偏向对象和普通对象的mark word也不同。但是,我们只考虑普通对象,因为Java 15将弃用偏向锁。
此外,klass word还封装了语言级别的类信息,例如类名,修饰符,超类信息等。
对于Java中的普通实例,表示为instanceOop,对象标头由mark和klass加上可能的对齐填充组成。 在对象标头之后,可能有零个或多个对实例字段的引用。因此,在64位架构中,至少有16个字节,这是因为有8个字节的mark,4个字节的klass和另外4个字节用于填充。
对于Java中的数组,表示为arrayOop,对象头除了mark,klass和填充以外,还包含4字节的数组长度。同样,由于mark的8个字节,klass的4个字节和数组长度的另外4个字节,因此至少应为16个字节。
Java对象布局工具(JOL)
示例
添加依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
让我们从查看常规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个字节,布尔和字节占1个字节,短整型和字符占2个字节,整数和浮点数占4个字节,而长整数和双精度浮点数占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]
让我们看一个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个字节的mark和4个字节的klass。之后,我们有4个字节用于存储int state
。此类中的任何对象总共将占用16个字节。
而且,对象头和state
没有值,因为我们正在解析类布局,而不是实例布局。
哈希码
hashCode()
是所有Java对象的常用方法之一。当我们不为类声明hashCode()
方法时,Java将为其计算默认的哈希码。
哈希码在对象的生命周期内不会更改。因此,HotSpot JVM一旦计算出此值,便会将其存储在mark中。
让我们看一下对象实例的内存布局:
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
如上所示,mark中目前似乎尚未存储任何重要内容。
但是,如果我们在对象实例上调用System.identityHashCode()或Object.hashCode()
,这将改变:
System.out.println("The identity hash code is " + System.identityHashCode(instance));
System.out.println(ClassLayout.parseInstance(instance).toPrintable());
现在,我们可以将哈希码作为mark的一部分:
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将哈希码存储为mark中的“25b27465”。最高有效字节为65,因为JVM以little-endian格式存储该值。因此,要恢复以十进制表示的哈希码值(1702146597),我们必须以相反的顺序读取“25b27465”字节序列:
65 74 b2 25 = 01100101 01110100 10110010 00100101 = 1702146597
对齐
默认情况下,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变量之后。
字段填充
当一个类具有多个字段时,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)
其背后的主要动机是最大程度地减少填充浪费。
锁
JVM还维护mark内的锁定信息。让我们来看看实际情况:
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)
如上所示,当我们使用monitor lock时(代码块中的synchronized由monitorenter和monitorexit指令显示实现,方法上的synchronized由ACC_SYNCHRONIZED标记隐式依赖monitor对象),mark的bit-pattern会发生变化。
年龄和寿命
为了将对象提升为老一代(当然,在分代GC中),JVM需要跟踪每个对象的生存周期。 如上所述,JVM还在mark中维护此信息。
为了模拟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;
}
每当有生命物体的地址发生变化时,这可能是由于minor GC和survivor区之间的移动所导致。 对于每次更改,我们还将打印新的对象布局以查看老化的对象。
这是mark的前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)
^^^^
伪共享和 @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解锁此功能。
数组
如前所述,数组长度也是数组oop的一部分。例如,对于包含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个字节的mark,4个字节的klass和4个字节的长度。在对象头之后,我们有3个字节的布尔数组包含3个元素。
压缩引用
到目前为止,我们的示例在启用了压缩引用的64位架构中执行。
对齐8个字节后,我们最多可以使用32GB的带有压缩引用的堆(我们可以使用压缩指针计算最大可能的堆大小,超过则指针压缩会失效)。如果我们超出此限制,或者甚至手动禁用压缩引用,那么klass字将占用8个字节而不是4个字节。
让我们看一下使用-XX:-UseCompressedOops
调整标志禁用压缩的oop时同一数组示例的内存布局:
[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个字节。
总结
在本教程中,我们了解了JVM如何在堆中布置对象和数组。
要进行更详细的探索,强烈建议您查看JVM源代码的oops部分。另外,AlekseyShipilëv在此领域有更深入的文章。
此外,JOL的更多示例 可作为项目源代码的一部分获得。
原文:https://www.baeldung.com/java-memory-layout
Mark Word 结构
Mark Word在64位虚拟机下,也就是占用64位大小即8个字节的空间。内具体容包括:
- unused 未使用的
- hashcode 上文提到的hash code,本文出现的hashcode都是指identity hash code
- thread 偏向锁记录的线程标识
- epoch 验证偏向锁有效性的时间戳
- age 分代年龄
- biased_lock 偏向锁标志
- lock 锁标志
- pointer_to_lock_record 轻量锁 lock record 指针
- pointer_to_heavy weight_monitor 重量锁 monitor指针
这部分和程序关系很大,下面是一些典型的问题:
为什么晋升到老年代的年龄设置(XX:MaxTenuringThreshold)不能超过15 ?
因为就给了age四个bit空间,最大就是1111(二进制)也就是15,多了没地方存。
为什么你的synchronized锁住的对象,没有“传说中的”偏向锁优化 ?
因为hashcode并不是对象实例化完就计算好的,是调用计算出来放在mark word里的。
你调用过hashcode方法(或者隐式调用:存到hashset里,map的key,调用了默认未经重写的toString()方法等等),把“坑位”占了,偏向锁想存的线程id没地方存了,自然就直接是轻量级锁了。(或者你只是单纯的测试的时候忘了加-XX:BiasedLockingStartupDelay=0
了)
Memory Layout of Objects in Java
Why 35GB Heap is Less Than 32GB 拥有35G内存机器的盆友对分配了32G堆内存的虚拟机进行的测试,发现能存储的内容反而更少了,也就是本文提到的指针压缩问题。
ObjectHeader.txt 对象头在三种情况的布局(64位、64位压缩指针、32位)