Java 指针压缩

351 阅读10分钟

JVM中的压缩OOP

什么是OOP?

Java中的术语OOP(ordinary object pointer)是一个指向对象的托管指针。站在操作系统的角度该指针和系统原生指针无二异。Java 应用程序和 GC 子系统会仔细跟踪托管指针,以便可以回收未使用对象的存储。该过程还可以重新定位(复制)正在使用的对象,以便压缩存储。

为什么要采用指针压缩技术?

CompressedOops 是一种在 HotSpot JVM 中用于压缩对象指针的技术,旨在减少内存占用,提高性能。它通过使用 32 位的值来表示托管指针(oop,ordinary object pointer),而不是传统的 64 位指针。oop 是 Java 应用程序和垃圾回收 (GC) 子系统仔细跟踪的托管指针,用于引用堆中的对象。 在 LP64 系统中,普通的对象指针需要 64 位,而 CompressedOops 仅需 32 位,它将 32 位的值缩放 8 倍并添加到 64 位基地址来找到它引用的对象。 这种技术允许应用程序寻址多达 40 亿个对象,但需要注意的是,这并不意味着每个对象都占用 8 字节,而是该技术允许在 32GB 的限制内有效利用内存。 将 32 位压缩 oop 转换为 64 位本地地址的操作称为“解码”。

CompressedOops 利用了对象在内存中对齐的特点。在 Java 中,对象通常按 8 字节对齐,这意味着对象的起始地址总是 8 的倍数。CompressedOops 将 32 位的值视为对象的偏移量,而不是内存地址。

为了找到对象的实际地址,CompressedOops 会将这个 32 位的偏移量乘以 8(相当于左移 3 位),然后再加到一个 64 位的基地址上。这个基地址是 Java 堆的起始地址。

举个例子,假设基地址是 0x100000000,一个对象的压缩指针是 0x100。 那么对象的实际地址就是:

0x100000000 + (0x100 << 3) = 0x100000800

这样,CompressedOops 就可以用 32 位的值表示 64 位的地址,从而节省内存空间。

什么样的oop会被压缩?

在 ILP32 模式的 JVM 中,或者在 LP64 模式下关闭 UseCompressedOops 标志时,所有的 oop 都是机器字长的。  

如果 UseCompressedOops 为 true,则堆中的以下 oop 将被压缩:  

  • 每个对象的 klass 字段(指向对象类信息的指针)

  • 每个 oop 实例字段(对象中的指针类型的字段)

  • oop 数组 (objArray) 的每个元素(数组元素是指针类型)

  • Hotspot 虚拟机管理 Java 类的数据结构不会被压缩。这些通常位于 Java 堆的永久代 (PermGen) 中。  

解释执行和编译执行过程中的 CompressedOops

在解释器中,oop 永远不会被压缩。这些包括 JVM 局部变量和堆栈元素、传出调用参数和返回值。解释器会立即解码从堆中加载的 oop,并在将它们存储到堆之前对其进行编码。同样,方法调用序列(无论是解释的还是编译的)都不使用压缩的 oop。  

在编译代码中,oop 是否压缩取决于各种优化的结果。优化的代码可能会成功地将压缩的 oop 从托管堆中的一个位置移动到另一个位置,而无需对其进行解码。同样,如果芯片(例如 x86)支持可用于解码操作的寻址模式,则即使压缩的 oop 用于寻址对象字段或数组元素,也可能不会对其进行解码。

因此,编译代码中的以下结构可以引用压缩的 oop 或本机堆地址:  

  • 寄存器或溢出槽内容
  • oop 映射(GC 映射)
  • 调试信息(链接到 oop 映射)
  • 直接嵌入机器代码中的 oop(在像 x86 这样允许这样做的非 RISC 芯片上)
  • nmethod 常量部分条目(包括那些由影响机器代码的重定位使用的条目)

C++ 代码中的 CompressedOops

在 HotSpot JVM 的 C++ 代码中,压缩的 oop 和本机 oop 之间的区别反映在 C++ 静态类型系统中。通常,oop 经常是未压缩的。特别是 C++ 成员函数像往常一样对由本机机器字表示的接收器(this)进行操作。JVM 中的一些函数被重载以处理压缩的 oop 或本机 oop。  

重要的 C++ 值永远不会被压缩:  

  • C++ 对象指针(this)
  • 托管指针的句柄(类型 Handle 等)
  • JNI 句柄(类型 jobject)

C++ 代码有一种名为 narrowOop 的类型,用于标记正在操作压缩 oop 的位置(通常是加载或存储)。

解压缩

CompressedOops 技术在 64 位 JVM 中使用,它将对象指针压缩成 32 位,以减少内存占用。当需要访问对象时,需要将压缩的指针解压缩回 64 位的地址。

x86 示例

movl R10, [R9 + R8<<3 + 16]
movl R11, [R12 + R10<<3 + 8]
  • 第一条指令 movl R10, [R9 + R8<<3 + 16] 从一个对象数组 (R9) 中加载一个压缩的指针到寄存器 R10

    • R8 是数组索引。
    • R8<<3 将索引乘以 8,因为对象按 8 字节对齐。
    • 16 是数组元素的偏移量。
    • 这条指令有效地计算了数组元素的地址,并将其加载到 R10
  • 第二条指令 movl R11, [R12 + R10<<3 + 8] 从对象 (R10) 中加载 _klass 字段(指向对象类信息的指针)到寄存器 R11

    • R12 是堆的基地址。
    • R10<<3 将压缩的指针 R10 乘以 8。
    • 8_klass 字段的偏移量。
    • 这条指令将压缩的指针解压缩,并加上 _klass 字段的偏移量,得到实际的内存地址,然后加载到 R11

SPARC 示例

代码段

ld  [ %l7 + 0x44 ], %l1
cmp  %l1, 0
sllx  %l1, 3, %l3
brnz,a   %l3, .+8
add  %l3, %g6, %l3
  • 第一条指令 ld [%l7 + 0x44], %l1 从内存地址 (%l7 + 0x44) 加载一个压缩的指针到寄存器 %l1
  • 第二条指令 cmp %l1, 0 检查指针是否为空。
  • 第三条指令 sllx %l1, 3, %l3 将指针左移 3 位(相当于乘以 8)。
  • 第四条指令 brnz,a %l3, .+8 如果指针不为空,则跳转到下一条指令。
  • 第五条指令 add %l3, %g6, %l3 将解压缩的指针加到堆的基地址 (%g6) 上。

null 处理

1. 特殊的解码逻辑:

CompressedOops 将 32 位的零值解码为 64 位的原生空值。 由于 32 位的 0 需要被扩展成 64 位的 0,这在解码逻辑中需要一个特殊的处理路径。为了提高效率,JVM 会识别并记录那些永远不会为空的压缩指针,例如 Klass 字段,并对这些指针使用简化的解码和编码操作。  

2. 利用未映射的内存页实现隐式空指针检查:

为了进一步优化空指针检查的效率,CompressedOops 利用了虚拟内存的特性。它会将 Java 堆起始的若干个虚拟内存页设置为未映射状态。 这样,如果一个压缩的空指针被解码后用于内存访问,就会尝试访问这些未映射的页面,从而触发一个陷阱或信号。这种机制使得 JVM 可以有效地进行隐式空指针检查,而无需额外的指令或判断。

对象头布局

对象头的组成部分

Java 对象头包含以下部分:

  • 标记字 (mark word): 存储对象的哈希码、锁状态标志、GC 分代年龄等信息。大小与机器字长相同。
  • 类指针 (klass word): 指向对象类元数据的指针。
  • 长度字 (length word): 如果对象是数组,则包含数组的长度。大小为 32 位。
  • 间隙 (gap): 为了满足对齐规则而存在的填充空间。大小为 32 位。
  • 实例字段、数组元素或元数据字段: 对象的数据部分。

不同情况下的对象头结构

  • 不使用 CompressedOops (UseCompressedOops 为 false) 或 ILP32 系统:

    • 标记字和类指针都是机器字长(在 64 位系统上为 64 位)。

    • 对于数组对象:

      • 在 LP64 系统上,始终存在间隙字段。
      • 在 ILP32 系统上,只有元素为 64 位的数组才存在间隙字段。
  • 使用 CompressedOops (UseCompressedOops 为 true):

    • 类指针为 32 位。
    • 对于非数组对象,类指针后面紧跟着一个间隙字段。
    • 对于数组对象,类指针后面紧跟着长度字段。

间隙字段的用途

间隙字段如果存在,通常可以用来存储实例字段。

补充说明

  • Klass 元对象: 存储类信息的元数据对象,其中包含一个 C++ 虚函数表 (vtable)。
  • ILP32 和 LP64: 分别指 32 位和 64 位系统的数据模型。在 ILP32 中,整数、长整数和指针都是 32 位;在 LP64 中,长整数和指针是 64 位。

零基址压缩指针

普通的压缩指针

普通的压缩指针使用一个任意的地址作为窄指针基址 (narrow oop base),这个基址的计算方式是 Java 堆的基地址减去一个页的大小。 这样做是为了使隐式空指针检查能够正常工作。 因此,一个普通的字段引用需要进行如下计算:  

<narrow-oop-base> + (<narrow-oop> << 3) + <field-offset>

其中:

  • <narrow-oop-base> 是窄指针基址。
  • <narrow-oop> 是压缩后的对象指针。
  • << 3 表示左移 3 位,相当于乘以 8。
  • <field-offset> 是字段在对象中的偏移量。

零基址压缩指针

如果能够将窄指针基址设置为零(Java 堆的起始地址不一定要从偏移量零开始),那么字段引用的计算就可以简化成:

(<narrow-oop> << 3) + <field-offset>

这样可以节省一次加法运算,并且不需要对压缩指针进行空指针检查。  

零基址压缩指针的优势

  • 简化解码和编码操作: 只需要进行移位操作即可完成压缩指针的解码和编码。
  • 节省堆基址加法运算: 可以减少一次加法运算,提高性能。
  • 无需空指针检查: 由于基址为零,压缩的空指针解码后仍然为零,可以直接用于内存访问,从而触发隐式空指针检查。

零基址压缩指针的实现

零基址压缩指针的实现会尝试使用不同的策略来分配 Java 堆,具体取决于堆的大小和运行平台:

  1. 堆大小小于 4GB: 尝试将 Java 堆分配到 4GB 以下的低地址空间,这样可以直接使用压缩指针,无需解码。
  2. 堆大小大于 4GB 或分配失败: 尝试将 Java 堆分配到 32GB 以下的地址空间,使用零基址压缩指针。
  3. 分配失败: 切换回普通的压缩指针,使用非零的窄指针基址。

参考文章