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 堆,具体取决于堆的大小和运行平台:
- 堆大小小于 4GB: 尝试将 Java 堆分配到 4GB 以下的低地址空间,这样可以直接使用压缩指针,无需解码。
- 堆大小大于 4GB 或分配失败: 尝试将 Java 堆分配到 32GB 以下的地址空间,使用零基址压缩指针。
- 分配失败: 切换回普通的压缩指针,使用非零的窄指针基址。