Java对象的内存布局

2020/10/14 Java虚拟机 共 9194 字,约 27 分钟
梦境迷离

在本文中,下面的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使用以下信息描述指针:

着实不确定该把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

“对象头(object header)”里知多少

Why 35GB Heap is Less Than 32GB 拥有35G内存机器的盆友对分配了32G堆内存的虚拟机进行的测试,发现能存储的内容反而更少了,也就是本文提到的指针压缩问题。

ObjectHeader.txt 对象头在三种情况的布局(64位、64位压缩指针、32位)

文档信息

Search

    Table of Contents