欢迎来到尧图网

客户服务 关于我们

您的位置:首页 > 健康 > 养生 > JVM常用概念之压缩引用

JVM常用概念之压缩引用

2025/3/21 12:07:33 来源:https://blog.csdn.net/nanxiaotao/article/details/146307782  浏览:    关键词:JVM常用概念之压缩引用

问题

什么是压缩的 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) 323=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 生活更轻松),但同时这种过度配置应该小心进行,较小的堆可能意味着可用的空间更多。

版权声明:

本网仅为发布的内容提供存储空间,不对发表、转载的内容提供任何形式的保证。凡本网注明“来源:XXX网络”的作品,均转载自其它媒体,著作权归作者所有,商业转载请联系作者获得授权,非商业转载请注明出处。

我们尊重并感谢每一位作者,均已注明文章来源和作者。如因作品内容、版权或其它问题,请及时与我们联系,联系邮箱:809451989@qq.com,投稿邮箱:809451989@qq.com

热搜词