JVM常用概念之压缩引用

news/2025/3/22 20:07:37/

问题

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


http://www.ppmy.cn/news/1581214.html

相关文章

OpenCV旋转估计(2)用于自动检测波浪校正类型的函数autoDetectWaveCorrectKind()

操作系统:ubuntu22.04 OpenCV版本:OpenCV4.9 IDE:Visual Studio Code 编程语言:C11 算法描述 cv::detail::autoDetectWaveCorrectKind 是 OpenCV 中用于自动检测波浪校正类型的函数,它根据输入的旋转矩阵集合来决定使用哪种波浪…

基于百度翻译的python爬虫示例

(今年java工作真难找啊,有广州java高级岗位招人的好心人麻烦推一下,拜谢。。) 花了一周时间,从零基础开始学习了python,学有所获之后,就总想爬些什么,不然感觉不得劲,所以花了一天时…

gcc version 11.4.0 (Ubuntu 11.4.0-1ubuntu1~22.04) 上编译问题笔记

编译错误如下: In file included from /usr/include/glib-2.0/glib/glib-typeof.h:39, from /usr/include/glib-2.0/glib/gatomic.h:28, from /usr/include/glib-2.0/glib/gthread.h:32, from /usr/include/gl…

ChatGPT、DeepSeek、Grok 与大数据:智能 AI 在数据时代的角色与未来

📝个人主页🌹:一ge科研小菜鸡-CSDN博客 🌹🌹期待您的关注 🌹🌹 1. 引言 随着大数据技术的飞速发展,人工智能(AI)成为处理海量数据的核心驱动力。ChatGPT、De…

【USTC 计算机网络】第二章:应用层 - DNS

本文介绍了互联网中的一个核心基础服务:域名系统(DNS),从如何命名设备、如何完成名字到 IP 地址的转换、如何维护域名这三个问题逐步讲解了 DNS 的名字空间、名字服务器以及报文格式,最后简单介绍了 DNS 的攻击与防御手…

基于STM32电子钟闹钟数码管显示设计(Proteus仿真+程序+设计报告+原理图PCB+讲解视频)

基于STM32电子钟闹钟数码管显示设计 1.主要功能2.仿真设计3.程序设计4.设计报告5.原理图PCB6.实物图7.下载链接 基于STM32电子钟闹钟数码管显示设计(Proteus仿真程序设计报告原理图PCB讲解视频) 仿真图proteus 8.9 程序编译器:keil 5 编程语言&#xf…

Dify:开源大模型应用开发平台全解析

从部署到实践,打造你的AI工作流 一、项目简介 Dify 是一款面向开发者和企业的开源大语言模型(LLM)应用开发平台,旨在降低AI应用开发门槛,让用户通过可视化界面快速构建、管理和部署基于大模型的智能应用。其名称寓意“…

深度学习:从零开始的DeepSeek-R1-Distill有监督微调训练实战(SFT)

原文链接:从零开始的DeepSeek微调训练实战(SFT) 微调参考示例:由unsloth官方提供https://colab.research.google.com/github/unslothai/notebooks/blob/main/nb/Qwen2.5_(7B)-Alpaca.ipynbhttps://colab.research.google.com/git…