问题
什么是压缩的 oop/引用?压缩引用存在什么问题?
基础知识
Java 规范并未规定数据类型的存储大小。即使对于原始数据类型,它也只规定了原始类型应明确支持的范围及其操作行为,而没有规定实际的存储大小。例如,在某些实现中,这允许boolean字段占用 1、2、4 个字节。
Java 引用大小的问题比较模糊,因为规范也没有明确指出 Java 引用是什么,而是将这一决定留给了 JVM 实现。大多数 JVM 实现将 Java 引用转换为机器指针,无需额外的间接寻址,这简化了性能问题。
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(3)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class CompressedRefs {static class MyClass {int x;public MyClass(int x) { this.x = x; }public int x() { return x; }}private MyClass o = new MyClass(42);@Benchmark@CompilerControl(CompilerControl.Mode.DONT_INLINE)public int access() {return o.x();}}
汇编指令如下:
....[Hottest Region 3]....................................................
c2, level 4, org.openjdk.CompressedRefs::access, version 712 (35 bytes)[Verified Entry Point]1.10% ...b0: mov %eax,-0x14000(%rsp) ; prolog6.82% ...b7: push %rbp ;0.33% ...b8: sub $0x10,%rsp ;1.20% ...bc: mov 0x10(%rsi),%r10 ; get field "o" to %r105.60% ...c0: mov 0x10(%r10),%eax ; get field "o.x" to %eax7.21% ...c4: add $0x10,%rsp ; epilog0.50% ...c8: pop %rbp0.54% ...c9: mov 0x108(%r15),%r10 ; thread-local handshake0.60% ...d0: test %eax,(%r10)6.63% ...d3: retq ; return %eax
注意对字段的访问,无论是读取引用字段CompressedRefs.o还是原始字段MyClass.x ,都只是取消引用常规机器指针。该字段位于对象开头的偏移量 16 处,这就是我们在0x10处读取的原因。这可以通过查看CompressedRefs实例的内存表示来验证。我们会看到引用字段在 64 位 VM 上占用 8 个字节,并且它确实位于偏移量 16 处:
$ java ... -jar ~/utils/jol-cli.jar internals -cp target/bench.jar org.openjdk.CompressedRefs
...
# Running 64-bit HotSpot VM.
# Objects are 8 bytes aligned.
# 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]Instantiated the sample instance via default constructor.org.openjdk.CompressedRefs object internals:OFFSET SIZE TYPE DESCRIPTION VALUE0 4 (object header) 01 00 00 004 4 (object header) 00 00 00 008 4 (object header) f0 e8 1f 5712 4 (object header) 34 7f 00 0016 8 MyClass CompressedRefs.o (object)
Instance size: 24 bytes
压缩引用
但这是否意味着 Java 引用的大小与机器指针宽度相同?不一定。Java 对象通常引用量很大,运行时面临着采用优化来减小引用的压力。最普遍的技巧是压缩引用:使其表示小于机器指针宽度。事实上,上述示例是在明确禁用该优化的情况下执行的。
由于 Java 运行时环境完全控制内部表示,因此无需更改任何用户程序即可完成此操作。在其他环境中也可以这样做,但您需要处理通过 ABI 等造成的泄漏,例如,参见X32ABI。
在 Hotspot 中,由于历史事故,内部名称已泄露给控制此优化的 VM 参数列表。在 Hotspot中,对Java对象的引用称为“普通对象指针”或“oops” ,这就是为什么 Hotspot VM 选项有这些奇怪的名称: -XX:+UseCompressedOops 、 -XX:+PrintCompressedOopsMode 、 -Xlog:gc+heap+coops 。在本文中,我们将尽可能尝试使用正确的命名法。
“32位”模式
在大多数堆大小上,64 位机器指针的高位通常为零。在可以映射到前 4 GB 虚拟内存的堆上,高 32 位肯定为零。在这种情况下,我们可以只使用低 32 位来存储 32 位机器指针中的引用。在 Hotspot 中,这称为“32 位”模式,如日志所示:
$ java -Xmx2g -Xlog:gc+heap+coops ...
[0.016s][info][gc,heap,coops] Heap address: 0x0000000080000000, size: 2048 MB, Compressed Oops mode: 32-bit
当堆大小小于 4 GB(或 2 32字节)时,显然可以实现整个过程。从技术上讲,堆起始地址可能远离零地址,因此实际限制低于 4 GB。请参阅上面日志中的“堆地址”。它表示堆从 0x0000000080000000 标记开始,接近 2 GB。
从图形上看,可以这样描绘:
现在,引用字段仅占用 4 个字节,实例大小降至 16 个字节:
$ java -Xmx1g -jar ~/utils/jol-cli.jar internals -cp target/bench.jar org.openjdk.CompressedRefs
# Running 64-bit HotSpot VM.
# Using compressed oop with 0-bit shift.
# Using compressed klass with 3-bit shift.
# 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]Instantiated the sample instance via default constructor.org.openjdk.CompressedRefs object internals:OFFSET SIZE TYPE DESCRIPTION VALUE0 4 (object header) 01 00 00 004 4 (object header) 00 00 00 008 4 (object header) 85 fd 01 f812 4 MyClass CompressedRefs.o (object)
Instance size: 16 bytes
在生成的代码中,访问如下所示:
....[Hottest Region 2]...................................................
c2, level 4, org.openjdk.CompressedRefs::access, version 714 (35 bytes)[Verified Entry Point]0.87% ...c0: mov %eax,-0x14000(%rsp) ; prolog6.90% ...c7: push %rbp0.35% ...c8: sub $0x10,%rsp1.74% ...cc: mov 0xc(%rsi),%r11d ; get field "o" to %r115.86% ...d0: mov 0xc(%r11),%eax ; get field "o.x" to %eax7.43% ...d4: add $0x10,%rsp ; epilog0.08% ...d8: pop %rbp0.54% ...d9: mov 0x108(%r15),%r10 ; thread-local handshake0.98% ...e0: test %eax,(%r10)6.79% ...e3: retq ; return %eax
通过上述结果,访问仍是相同的形式,这是因为硬件本身只接受 32 位指针,并在访问时将其扩展为 64 位。我们几乎不费吹灰之力就获得了这种优化。
零基础”模式
但是如果我们无法将未处理的引用放入 32 位中怎么办?还有一种方法,它利用了对象对齐的事实:对象始终以对齐的某个倍数开始。因此,未处理的引用表示的最低位始终为零。这开辟了使用这些位来存储无法放入 32 位中的有效位的方法。最简单的方法是将引用位右移,这样我们就可以将 2 ( 32 + 移位 ) 2^{(32+移位)} 2(32+移位)字节的堆编码为 32 位。
从图形上看,可以这样描绘:
由于默认对象对齐为 8 字节,移位为 3 ( 2 3 = 8 ) 3(2^3 = 8) 3(23=8),因此我们可以将引用表示为 2 3 5 2^35 235 = 32 GB 的堆。同样,这里也存在与基堆地址相同的问题,这使得实际限制略低。
在 Hotspot 中,这种模式称为“基于零的压缩 oops”,例如:
$ java -Xmx20g -Xlog:gc+heap+coops ...
[0.010s][info][gc,heap,coops] Heap address: 0x0000000300000000, size: 20480 MB, Compressed Oops mode: Zero based, Oop shift amount: 3
通过引用进行访问现在有点复杂:
....[Hottest Region 3].....................................................
c2, level 4, org.openjdk.CompressedRefs::access, version 715 (36 bytes)[Verified Entry Point]0.94% ...40: mov %eax,-0x14000(%rsp) ; prolog7.43% ...47: push %rbp0.52% ...48: sub $0x10,%rsp1.26% ...4c: mov 0xc(%rsi),%r11d ; get field "o"6.08% ...50: mov 0xc(%r12,%r11,8),%eax ; get field "o.x"6.94% ...55: add $0x10,%rsp ; epilog0.54% ...59: pop %rbp0.27% ...5a: mov 0x108(%r15),%r10 ; thread-local handshake0.57% ...61: test %eax,(%r10)6.50% ...64: retq
获取字段o.x需要执行mov 0xc(%r12,%r11,8),%eax :“从 %r11 中获取引用,将引用乘以 8,添加 %r12 中的堆基数,这就是您现在可以在偏移量0xc处读取的对象;请将该值放入%eax中”。换句话说,该指令将压缩引用的解码与通过它的访问相结合,并且一次性完成。在零基模式下, %r12为零,但代码生成器更容易发出涉及%r12访问。代码生成器也可以在其他地方使用%r12在此模式下为零的事实。
为了简化内部实现,Hotspot 通常只在寄存器中携带未压缩的引用,这就是为什么对字段o的访问只是从偏移量0xc处的this (即%rsi中)进行简单的访问。
“非零基础”模式
但是基于零的压缩引用仍然依赖于堆被映射到较低地址的假设。如果不是,我们可以使堆基地址非零以进行解码。这基本上与基于零的模式相同,但现在堆基地址将具有更多含义并参与实际的编码/解码。
在 Hotspot 中,这种模式称为“非零基础”模式,你可以在这样的日志中看到它:
$ java -Xmx20g -XX:HeapBaseMinAddress=100G -Xlog:gc+heap+coops
[0.015s][info][gc,heap,coops] Heap address: 0x0000001900400000, size: 20480 MB, Compressed Oops mode: Non-zero based: 0x0000001900000000, Oop shift amount: 3
从图形上看,可以这样描绘:
正如我们之前所怀疑的那样,访问看起来与从零开始的模式相同:
....[Hottest Region 1].....................................................
c2, level 4, org.openjdk.CompressedRefs::access, version 706 (36 bytes)[Verified Entry Point]0.08% ...50: mov %eax,-0x14000(%rsp) ; prolog5.99% ...57: push %rbp0.02% ...58: sub $0x10,%rsp0.82% ...5c: mov 0xc(%rsi),%r11d ; get field "o"5.14% ...60: mov 0xc(%r12,%r11,8),%eax ; get field "o.x"28.05% ...65: add $0x10,%rsp ; epilog...69: pop %rbp0.02% ...6a: mov 0x108(%r15),%r10 ; thread-local handshake0.63% ...71: test %eax,(%r10)5.91% ...74: retq ; return %eax
由上述执行结果可以看出,一样的事情是有区别的,这里唯一隐藏的区别是%r12现在携带的是非零堆基值。
限制
明显的限制是堆大小。一旦堆大小大于压缩引用工作的阈值,就会发生一件令人惊讶的事情:引用突然变为未压缩的,占用两倍的内存。根据堆中有多少引用,您可以显著增加感知到的堆占用率。
为了说明这一点,让我们通过分配一些对象来估计实际占用了多少堆,使用如下的玩具示例:
import java.util.stream.IntStream;public class RandomAllocate {static Object[] arr;public static void main(String... args) {int size = Integer.parseInt(args[0]);arr = new Object[size];IntStream.range(0, size).parallel().forEach(x -> arr[x] = new byte[(x % 20) + 1]);System.out.println("All done.");}
}
使用Epsilon GC运行要方便得多,因为 Epsilon GC 会在堆耗尽时失败,而不是尝试使用 GC 来解决。这个例子没有必要使用 GC,因为所有对象都是可访问的。Epsilon 还会打印堆占用统计数据以方便我们查看。
让我们取一些合理数量的小对象。800M 个对象听起来够了吗?运行:
$ java -XX:+UseEpsilonGC -Xlog:gc -Xlog:gc+heap+coops -Xmx31g RandomAllocate 800000000
[0.004s][info][gc] Using Epsilon
[0.004s][info][gc,heap,coops] Heap address: 0x0000001000001000, size: 31744 MB, Compressed Oops mode: Non-zero disjoint base: 0x0000001000000000, Oop shift amount: 3
All done.
[2.380s][info][gc] Heap: 31744M reserved, 26322M (82.92%) committed, 26277M (82.78%) used
在那里,我们用了 26 GB 来存储这些对象,很好。压缩引用已启用,因此对这些byte[]数组的引用现在更小了。但让我们假设管理服务器的朋友对自己说:“嘿,我们有 1 或 2 GB 可以用于 Java 安装”,并将旧的-Xmx31g提升到-Xmx33g 。然后发生以下情况:
$ java -XX:+UseEpsilonGC -Xlog:gc -Xlog:gc+heap+coops -Xmx33g RandomAllocate 800000000
[0.004s][info][gc] Using Epsilon
Terminating due to java.lang.OutOfMemoryError: Java heap space
现在的问题是压缩引用被禁用,因为堆大小太大。引用变得更大,数据集不再适合。我再说一遍:同样的数据集不再适合,只是因为我们请求了过大的堆大小,即使我们根本不使用它。
如果我们试图找出 32 GB 之后适合数据集所需的最小堆大小,那么最小值将是:
$ java -XX:+UseEpsilonGC -Xlog:gc -Xlog:gc+heap+coops -Xmx36g RandomAllocate 800000000
[0.004s][info][gc] Using Epsilon
All done.
[3.527s][info][gc] Heap: 36864M reserved, 35515M (96.34%) committed, 35439M (96.13%) used
结果也使很明显的,我们以前占用约 26 GB 的数据集,现在我们占用约 35 GB,增加了近 40%!。
总结
压缩引用是一项很好的优化,它可以在引用繁重的工作负载下控制内存占用。此优化带来的改进非常令人印象深刻。但当此默认启用的优化由于堆大小和/或其他环境问题而停止工作时,也可能会令人感到意外。
当堆大小达到 4 GB 和 32 GB 这两个有趣的阈值时,了解这种优化的工作原理、何时会中断以及如何处理中断非常重要。有一些方法可以通过调整对象对齐来缓解这种中断,“对象对齐”在其他博客中会描述。
但有一点很清楚:为应用程序过度配置堆有时是件好事(例如,使 GC 生活更轻松),但同时这种过度配置应该小心进行,较小的堆可能意味着可用的空间更多。