JVM常用概念之压缩引用

问题

什么是压缩的 oop/引用?压缩引用存在什么问题?

基础知识

Java 规范并未规定数据类型的存储大小。即使对于原始数据类型,它也只规定了原始类型应明确支持的范围及其操作行为,而没有规定实际的存储大小。例如,在某些实现中,这允许boolean字段占用 1、2、4 个字节。

Java 引用大小的问题比较模糊,因为规范也没有明确指出 Java 引用是什么,而是将这一决定留给了 JVM 实现。大多数 JVM 实现将 Java 引用转换为机器指针,无需额外的间接寻址,这简化了性能问题。

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();
    }

}

汇编指令如下:

bash 复制代码
....[Hottest Region 3]....................................................
c2, level 4, org.openjdk.CompressedRefs::access, version 712 (35 bytes)
         [Verified Entry Point]
  1.10%    ...b0: mov    %eax,-0x14000(%rsp) ; prolog
  6.82%    ...b7: push   %rbp                ;
  0.33%    ...b8: sub    $0x10,%rsp          ;
  1.20%    ...bc: mov    0x10(%rsi),%r10     ; get field "o" to %r10
  5.60%    ...c0: mov    0x10(%r10),%eax     ; get field "o.x" to %eax
  7.21%    ...c4: add    $0x10,%rsp          ; epilog
  0.50%    ...c8: pop    %rbp
  0.54%    ...c9: mov    0x108(%r15),%r10    ; thread-local handshake
  0.60%    ...d0: test   %eax,(%r10)
  6.63%    ...d3: retq                       ; return %eax

注意对字段的访问,无论是读取引用字段CompressedRefs.o还是原始字段MyClass.x ,都只是取消引用常规机器指针。该字段位于对象开头的偏移量 16 处,这就是我们在0x10处读取的原因。这可以通过查看CompressedRefs实例的内存表示来验证。我们会看到引用字段在 64 位 VM 上占用 8 个字节,并且它确实位于偏移量 16 处:

bash 复制代码
$ 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        VALUE
      0     4          (object header)    01 00 00 00
      4     4          (object header)    00 00 00 00
      8     4          (object header)    f0 e8 1f 57
     12     4          (object header)    34 7f 00 00
     16     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 位"模式,如日志所示:

bash 复制代码
$ 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 个字节:

bash 复制代码
$ 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        VALUE
      0     4           (object header)    01 00 00 00
      4     4           (object header)    00 00 00 00
      8     4           (object header)    85 fd 01 f8
     12     4   MyClass CompressedRefs.o   (object)
Instance size: 16 bytes

在生成的代码中,访问如下所示:

bash 复制代码
....[Hottest Region 2]...................................................
c2, level 4, org.openjdk.CompressedRefs::access, version 714 (35 bytes)
         [Verified Entry Point]
  0.87%    ...c0: mov    %eax,-0x14000(%rsp)  ; prolog
  6.90%    ...c7: push   %rbp
  0.35%    ...c8: sub    $0x10,%rsp
  1.74%    ...cc: mov    0xc(%rsi),%r11d      ; get field "o" to %r11
  5.86%    ...d0: mov    0xc(%r11),%eax       ; get field "o.x" to %eax
  7.43%    ...d4: add    $0x10,%rsp           ; epilog
  0.08%    ...d8: pop    %rbp
  0.54%    ...d9: mov    0x108(%r15),%r10     ; thread-local handshake
  0.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",例如:

bash 复制代码
$ 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

通过引用进行访问现在有点复杂:

bash 复制代码
....[Hottest Region 3].....................................................
c2, level 4, org.openjdk.CompressedRefs::access, version 715 (36 bytes)
         [Verified Entry Point]
  0.94%    ...40: mov    %eax,-0x14000(%rsp)    ; prolog
  7.43%    ...47: push   %rbp
  0.52%    ...48: sub    $0x10,%rsp
  1.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             ; epilog
  0.54%    ...59: pop    %rbp
  0.27%    ...5a: mov    0x108(%r15),%r10       ; thread-local handshake
  0.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 中,这种模式称为"非零基础"模式,你可以在这样的日志中看到它:

bash 复制代码
$ 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

从图形上看,可以这样描绘:

正如我们之前所怀疑的那样,访问看起来与从零开始的模式相同:

bash 复制代码
....[Hottest Region 1].....................................................
c2, level 4, org.openjdk.CompressedRefs::access, version 706 (36 bytes)
         [Verified Entry Point]
  0.08%    ...50: mov    %eax,-0x14000(%rsp)    ; prolog
  5.99%    ...57: push   %rbp
  0.02%    ...58: sub    $0x10,%rsp
  0.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    %rbp
  0.02%    ...6a: mov    0x108(%r15),%r10       ; thread-local handshake
  0.63%    ...71: test   %eax,(%r10)
  5.91%    ...74: retq                          ; return %eax

由上述执行结果可以看出,一样的事情是有区别的,这里唯一隐藏的区别是%r12现在携带的是非零堆基值。

限制

明显的限制是堆大小。一旦堆大小大于压缩引用工作的阈值,就会发生一件令人惊讶的事情:引用突然变为未压缩的,占用两倍的内存。根据堆中有多少引用,您可以显著增加感知到的堆占用率。

为了说明这一点,让我们通过分配一些对象来估计实际占用了多少堆,使用如下的玩具示例:

java 复制代码
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 个对象听起来够了吗?运行:

bash 复制代码
$ 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 。然后发生以下情况:

bash 复制代码
$ 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 之后适合数据集所需的最小堆大小,那么最小值将是:

bash 复制代码
$ 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 生活更轻松),但同时这种过度配置应该小心进行,较小的堆可能意味着可用的空间更多。

相关推荐
剑海风云4 小时前
JVM常见概念之条件移动
jvm·条件移动
黄名富4 小时前
深入探究 JVM 堆的垃圾回收机制(一)— 判活
java·jvm
ling__wx4 小时前
JVM常见面试总结
java·jvm
重生成为码农‍12 小时前
类加载机制
java·开发语言·jvm
黄名富12 小时前
深入探究 JVM 堆的垃圾回收机制(二)— 回收
java·jvm·算法·系统架构
江沉晚呤时13 小时前
深入解析 C# 中的装饰器模式(Decorator Pattern)
java·开发语言·javascript·jvm·microsoft·.netcore
剑海风云16 小时前
JVM常见概念之不怎么常见的一些陷阱
jvm·jvm编译不常见的陷阱
越甲八千18 小时前
C++关键字汇总
jvm·c++
日暮南城故里1 天前
Java学习------初识JVM体系结构
java·jvm·学习