1. 概述

JVM 帮我们管理内存,这把内存管理的重担从开发者身上移除,使我们无需手动操作对象指针。手动管理内存不仅耗时而且容易出错,JVM 的自动内存管理机制大大简化了开发工作。

在底层,JVM 使用了很多巧妙的技巧来优化内存管理过程。其中一种就是压缩指针(Compressed Pointers),本文将重点介绍这一技术。首先,我们来看看 JVM 是如何在运行时表示 Java 对象的。

2. 对象的运行时表示

HotSpot JVM 使用一种叫做 oop(Ordinary Object Pointer)的数据结构来表示 Java 对象。这些 oop 实际上就是原生的 C 指针。其中,instanceOop 是一种特殊的 oop,用于表示 Java 中的对象实例。此外,JVM 还支持其他多种 oop,它们可以在 OpenJDK 源码 中找到。

我们来看看 instanceOop 在内存中的布局结构。

2.1. 对象内存布局

一个 instanceOop 的内存布局非常简单:首先是对象头(Object Header),紧接着是零个或多个实例字段的引用。

对象头由以下三部分组成:

  • 一个 Mark Word:用途多样,包括偏向锁(Biased Locking)、对象哈希值(Identity Hash Value)和 GC 信息等。它不是 oop,但为了历史原因,它在 OpenJDK 的 oop 源码 中。Mark Word 的大小在 32 位和 64 位架构中分别是 4 字节和 8 字节。
  • 一个 Klass Word:指向类元数据的指针,可能是压缩过的。在 Java 7 之前,它指向 Permanent Generation,而从 Java 8 开始,它指向 Metaspace
  • 一个 32 位的空隙(Padding):用于对象对齐,使内存布局更符合硬件访问习惯。

在这之后就是实例字段的引用。这里的 word 是指机器字长,即在 32 位系统中是 32 位,在 64 位系统中是 64 位。

数组对象的头除了包含 Mark Word 和 Klass Word 外,还会额外包含一个 32 位的字段用于表示数组长度。

2.2. 内存浪费的根源

如果我们从 32 位架构迁移到 64 位架构,可能期望性能有所提升。但在 JVM 中,这种提升并不总是成立。

导致性能下降的主要原因就是 64 位对象引用的膨胀。64 位引用是 32 位引用大小的两倍,因此会占用更多内存,进而导致更频繁的 GC 操作。GC 越频繁,留给应用线程的 CPU 时间就越少。

那我们是不是应该回到 32 位架构?即使我们愿意这么做,32 位进程的地址空间最多也只能支持不超过 4GB 的堆内存(不借助额外手段)。

3. 压缩 OOPs

JVM 提供了一种聪明的方式来优化内存使用 —— 压缩 OOPs(Compressed OOPs)。它让我们在 64 位机器上使用 32 位引用,同时还能支持超过 4GB 的堆内存!

3.1. 基本优化原理

正如我们前面看到的,JVM 会对对象进行填充(padding),使其大小是 8 字节的整数倍。因此,oop 中的最后三位始终是 0,因为 8 的倍数在二进制下末尾三位一定是 000。

Untitled-Diagram-2

既然 JVM 知道最后三位总是 0,就没有必要在堆中保存这些无意义的 0。取而代之的是,JVM 会用这三个位来存储额外的有效位。这样一来,原本只能表示 32 位地址的空间,现在可以表示 35 位地址。

这意味着我们可以在不使用 64 位引用的情况下,使用最多 32GB 的堆空间:

2^32 * 8 = 2^35 = 32 GB

为了实现这个优化,当 JVM 需要访问对象时,它会将指针左移 3 位(即在末尾补上 3 个 0)。而在从堆中加载指针时,则右移 3 位去掉这些额外的 0。JVM 用一点额外的位运算,换取了内存空间的节省。而位移操作对现代 CPU 来说几乎不耗性能。

我们可以通过以下 JVM 参数启用压缩 OOPs:

-XX:+UseCompressedOops

从 Java 7 开始,只要堆内存小于 32GB,压缩 OOPs 就是默认启用的。当堆内存超过 32GB 时,JVM 会自动关闭压缩 OOPs。

3.2. 超过 32GB 的堆内存怎么办?

即使堆内存超过 32GB,我们仍然可以通过调整对象对齐方式来继续使用压缩 OOPs。默认的对象对齐是 8 字节,但可以通过以下参数进行调整

-XX:ObjectAlignmentInBytes=16

这个值必须是 2 的幂,且范围在 8 到 256 之间。

我们可以用以下公式计算使用压缩 OOPs 时的最大堆内存:

4 GB * ObjectAlignmentInBytes

例如,当对象对齐为 16 字节时,最大堆内存可达:

4 GB * 16 = 64 GB

⚠️ 注意:随着对齐值的增加,对象之间的空闲空间也会增加,可能导致内存利用率下降。因此,使用大堆内存时,压缩 OOPs 的优势可能并不明显。

3.3. 未来 GC 的发展趋势

从 Java 11 开始引入的 ZGC 是一种实验性的低延迟垃圾回收器,它可以处理不同大小的堆内存,同时将 GC 暂停时间控制在 10 毫秒以内。

由于 ZGC 使用了 64 位的着色指针(Colored Pointers),它不支持压缩 OOPs。所以使用像 ZGC 这样的低延迟 GC,需要权衡内存占用与性能之间的关系。

到了 Java 15,ZGC 支持了压缩类指针(Compressed Class Pointers),但仍不支持压缩 OOPs。

不过,并非所有新 GC 都放弃压缩引用。例如 Shenandoah GC 就同时支持压缩引用和低暂停时间。

目前,ZGC 和 Shenandoah GC 都已在 Java 15 中正式支持。

4. 总结

本文我们探讨了 JVM 在 64 位架构下的一个内存管理问题,并介绍了压缩指针(Compressed OOPs)及其背后的优化原理。通过压缩对象引用,我们可以在使用大堆内存的同时减少内存浪费,并只需付出极小的计算代价。

如果你对压缩引用的底层机制感兴趣,推荐阅读 Aleksey Shipilëv 的这篇深度文章:Aleksey Shipilëv - Compressed References。此外,想了解 HotSpot 中对象内存分配机制,可以参考:Java 对象内存布局详解


原始标题:Compressed OOPs in the JVM | Baeldung