WEBKT

详解 Java 对象的内存布局:为什么一个空的 Object 会占用 16 个字节?

4 0 0 0

在 Java 开发中,我们每天都会创建成千上万的对象。你可能听说过“Java 对象很重”,但你是否真正计算过,一个普通的 new Object() 到底占用了多少内存?为什么在 64 位虚拟机上,即便是一个没有任何字段的空对象,也会稳稳地占据 16 个字节?

本文将带你深度拆解 HotSpot 虚拟机中的对象布局,揭开指针压缩(CompressedOOPs)背后的性能秘密。

一、 Java 对象的内存结构

在 HotSpot 虚拟机中,一个 Java 对象在内存中的布局可以分为三个部分:对象头(Header)实例数据(Instance Data)对齐填充(Padding)

1. 对象头 (Header)

对象头是导致“内存占用”的主要原因,它包含两个(或三个)部分:

  • Mark Word(标记字):存储对象自身的运行时数据,如哈希码(HashCode)、GC 分代年龄、锁状态标志、偏向线程 ID 等。在 64 位虚拟机上,它占用 8 字节
  • Klass Pointer(类型指针):指向该对象元数据的指针,虚拟机通过这个指针确定该对象是哪个类的实例。在开启指针压缩的情况下占用 4 字节,关闭时占用 8 字节
  • 数组长度(Array Length):仅当对象是数组时才存在,占用 4 字节

2. 实例数据 (Instance Data)

这是对象真正存储的有效信息,即我们在类中定义的各种字段内容。如果是空对象,这部分大小为 0。

3. 对齐填充 (Padding)

JVM 要求对象的起始地址必须是 8 字节的整数倍。如果“对象头 + 实例数据”的大小不是 8 的倍数,JVM 会通过填充字节来补齐。


二、 为什么空对象是 16 字节?

让我们以 64 位 HotSpot 为例(默认开启指针压缩),手动算一笔账:

  1. Mark Word: 8 字节
  2. Klass Pointer: 4 字节(开启压缩)
  3. 实例数据: 0 字节(空对象)
  4. 小计: 12 字节

由于 12 不是 8 的倍数,根据 8 字节对齐(8-byte alignment) 原则,JVM 会自动填充 4 字节的 Padding。
最终结果:8 + 4 + 0 + 4 = 16 字节。

如果关闭指针压缩(-XX:-UseCompressedOops):

  1. Mark Word: 8 字节
  2. Klass Pointer: 8 字节
  3. 实例数据: 0 字节
  4. 小计: 16 字节
    此时刚好是 8 的倍数,不需要 Padding。
    最终结果:8 + 8 + 0 = 16 字节。

有趣的是,无论是否开启指针压缩,一个空对象最终都占用 16 字节,但内部构造已经发生了变化。


三、 指针压缩:性能的“双刃剑”

指针压缩(Compressed Ordinary Object Pointers)是 JVM 的一项神技。在 64 位系统上,原生指针是 8 字节,这会导致比 32 位系统多出近 1.5 倍的内存消耗。为了“省钱”,JVM 引入了指针压缩。

1. 工作原理

JVM 并不是真的只用了 32 位来存储地址,而是利用了“对齐”特性。既然对象都是 8 字节对齐的,那么所有对象的地址后三位永远是 0。
JVM 在存储指针时,先将地址右移 3 位(去掉末尾的 0),将其存入 32 位寄存器;在使用时,再左移 3 位还原。这样,32 位的偏移量就可以表示 2^32 * 8 = 32GB 的内存空间。

2. 对性能的影响

开启指针压缩对性能的影响主要体现在两个维度:

  • 正向增益:内存与缓存(主要收益)

    • 减少内存占用:通常可以节省 30%-40% 的堆内存。
    • 提升 CPU 缓存命中率:这是最核心的性能提升点。指针变小了,意味着同样的 L1/L2 Cache 可以缓存更多的对象引用,大幅降低了从内存抓取数据的频率(Cache Miss)。
  • 负向损耗:计算开销

    • 编解码指令:每次解引用对象时,CPU 需要额外执行一次 shl(左移)操作。
    • 实际感知:在现代 CPU 上,这种位运算的开销极低,几乎可以忽略不计。相对于 Cache 命中率带来的提升,这点损失微乎其微。

3. 临界点问题

如果你的 Java 应用堆内存超过 32GB,指针压缩会自动失效(除非通过 -XX:ObjectAlignmentInBytes 调大对齐步长,但会增加 Padding 浪费)。
这时会出现一个尴尬的现象:31GB 的堆内存往往比 33GB 的堆内存能装下更多的对象,因为 33GB 时指针变成了 8 字节,对象变“胖”了。


四、 如何观察对象的真实布局?

理论不如实践。你可以使用 OpenJDK 提供的 JOL (Java Object Layout) 工具来观察。

在 Maven 中引入:

<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.16</version>
</dependency>

编写测试代码:

System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());

输出示例(开启压缩):

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0     4        (object header)                           01 00 00 00 ... (mark word)
      4     4        (object header)                           00 00 00 00 ... (mark word)
      8     4        (object header)                           e5 01 00 f8 ... (klass pointer)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes

五、 总结与建议

  1. 内存不仅仅是数据:在设计高并发、大数据的系统时,必须考虑对象头的额外开销。如果你有数亿个小型对象(如 Integer),内存消耗会非常惊人。
  2. 优先使用基本类型:尽量使用 int[] 而不是 List<Integer>,前者省去了大量的对象头空间和 Padding。
  3. 关注 32GB 堆界限:除非业务真的需要超大内存,否则尽量将堆保持在 32GB 以下,以维持指针压缩带来的高性能。
  4. 对齐是双刃剑:8 字节对齐虽然浪费了一些空间(Padding),但它保证了 CPU 读取内存的效率(避免跨缓存行读取)。

理解内存布局,是每一位追求极致性能的 Java 程序员的必修课。

架构师老余 JVM内存管理Java性能优化指针压缩

评论点评