当前位置: 首页 > news >正文

JVM常用概念之代际障碍

问题

代际屏障为什么会影响JVM的性能?

基础知识

大部分的垃圾回收期都是分代的,或者是分区域的。如果任何垃圾收集器想要收集托管堆的部分而不触及整个堆,那么它必须了解哪些引用指向该收集部分。否则,它无法可靠地判断该收集部分中什么是可访问的,因此必须保守地假设一切都是可访问的……​ 这使得它处于没有任何东西可以被视为垃圾的境地。其次,如果它移动了该收集部分中的任何对象,它希望了解要更新哪些位置 — 这主要涉及在处理收集部分时不会被访问的“外部”指针。

那垃圾回收器是如何解决这个难题的呢?其实只要将堆分成多个部分就可以,将堆分成几部分的最简单(也是最有效的)方法是按年龄隔离对象,即引入代数。这里的关键思想是弱代假说,即“新对象会早逝”。利用弱代假说的实际情况是,在标记收集器时,性能取决于幸存对象的数量。这意味着,我们可以有一个年轻代,其中所有东西都基本死了,我们可以快速甚至更频繁地处理它,而老一代则被放在一边。

但是,在单独收集年轻代时,可能需要注意从老代到年轻代的引用。老年代通常与整个堆一起收集,年轻代对老年代的引用可以从跟踪中省略。年轻代对年轻代和老年代到老年代的引用也是如此,因为它们的引用将在同一次收集中被访问。
在这里插入图片描述

以 OpenJDK 的 Parallel GC 为例,它借助卡表记录老年代对年轻代的引用:卡表是横跨老年代的粗略位图。存储时,需要翻转该卡表中的一位。该位意味着老年代“卡片下方”的部分可能具有指向年轻代的指针,并且在收集年轻代时需要对其进行扫描。要使所有这些工作正常,引用存储必须通过写屏障进行增强,即执行卡表管理的小代码片段。

实验

用例源码

@State(Scope.Benchmark)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(value = 3, jvmArgsAppend = {"-Xms4g", "-Xmx4g", "-Xmn2g"})
@Threads(Threads.MAX)
public class HashWalkBench {

    @Param({"16", "256", "4096", "65536", "1048576", "16777216"})
    int size;

    Map<String, String> map;

    @Setup
    public void setup() throws Exception {
        create();
        System.gc();
    }

    private void create() {
        String[] keys = new String[size];
        String[] values = new String[size];
        for (int c = 0; c < size; c++) {
            keys[c] = "Key" + c;
            values[c] = "Value" + c;
        }
        map = new HashMap<>(size);
        for (int c = 0; c < size; c++) {
            String key = keys[c];
            String val = values[c];
            map.put(key, val);
        }
    }

    @Benchmark
    public void walk(Blackhole bh) {
        for (Map.Entry<String, String> e : map.entrySet()) {
            bh.consume(e.getKey());
            bh.consume(e.getValue());
        }
    }
}

运行结果

Benchmark             (size)  Mode  Cnt       Score      Error  Units

# Epsilon
HashWalkBench.walk        16  avgt   15       0.238 ±    0.005  us/op
HashWalkBench.walk       256  avgt   15       3.638 ±    0.072  us/op
HashWalkBench.walk      4096  avgt   15      59.222 ±    1.974  us/op
HashWalkBench.walk     65536  avgt   15    1102.590 ±    4.331  us/op
HashWalkBench.walk   1048576  avgt   15   19683.680 ±  195.086  us/op
HashWalkBench.walk  16777216  avgt   15  328319.596 ± 7137.066  us/op

# Parallel
HashWalkBench.walk        16  avgt   15       0.240 ±    0.001  us/op
HashWalkBench.walk       256  avgt   15       3.679 ±    0.078  us/op
HashWalkBench.walk      4096  avgt   15      64.778 ±    0.275  us/op
HashWalkBench.walk     65536  avgt   15    1377.634 ±   28.132  us/op
HashWalkBench.walk   1048576  avgt   15   25223.994 ±  853.601  us/op
HashWalkBench.walk  16777216  avgt   15  400679.042 ± 8155.414  us/op

汇编

1.58%    0.91%   ...a4e2c: mov    %edi,0x18(%r9)       ; %r9 = iterator, 0x18(%r9) = field
0.27%    0.33%   ...a4e30: mov    %r9,%r11             ; r11 = &iterator
0.26%    0.38%   ...a4e33: shr    $0x9,%r11            ; r11 = r11 >> 9
0.13%    0.20%   ...a4e37: movabs $0x7f2c535aa000,%rbx ; rbx = card table base
0.58%    0.57%   ...a4e41: mov    %r12b,(%rbx,%r11,1)  ; put 0 to (rbx+r11)

如上述执行结果所述,字符串数据是存储在隐式实例化HashMap.EntrySetIterator的当前条目的字段中,且从上述结果可以观察到具体的屏障,具体如下:

  • 卡标记设置整个字节,而不仅仅是一个位。这避免了同步:大多数硬件可以自动执行字节存储,而无需触及周围的数据。这使得卡表比理论上的更强大,但仍然相当密集:每 512 字节堆有 1 个卡表字节,注意移位 9。
  • 屏障是无条件发生的,而我们只需要记录老年代对年轻代的引用。这似乎很实用:我们在热路径上没有多余的分支,并且我们交换跨越​​整个堆的卡片表,而不仅仅是旧堆。考虑到卡片表的密度,我们只会引入一小部分额外的占用空间。这也有助于在可以自我调整的收集器中移动年轻代和老年代之间的短暂边界,而无需修补代码。
  • 卡表地址被编码在生成的代码中,这又很实用,因为它是一个本机不可移动的结构。这节省了内存负载,因为否则我们必须从某个地方轮询卡表地址。
  • 卡标记“设置”实际上被编码为“0”。这同样很实用,因为我们可以重用零寄存器(尤其是在明确具有零寄存器的架构上)来获取源操作数。稍后在本地 GC 代码中,卡表初始化使用什么值(0 或 1)并不重要。

我们可以通过-prof perfnorm来进一步分析,执行结果如下:

Benchmark                                  (size)  Mode  Cnt    Score    Error  Units

# Epsilon
HashWalkBench.walk                        1048576  avgt   15    0.019 ±  0.001  us/op
HashWalkBench.walk:L1-dcache-load-misses  1048576  avgt    3    0.389 ±  0.495   #/op
HashWalkBench.walk:L1-dcache-loads        1048576  avgt    3   25.439 ±  2.411   #/op
HashWalkBench.walk:L1-dcache-stores       1048576  avgt    3   20.090 ±  1.184   #/op
HashWalkBench.walk:cycles                 1048576  avgt    3   75.230 ± 11.333   #/op
HashWalkBench.walk:instructions           1048576  avgt    3   90.075 ± 10.484   #/op

# Parallel
HashWalkBench.walk                        1048576  avgt   15    0.024 ±  0.001  us/op
HashWalkBench.walk:L1-dcache-load-misses  1048576  avgt    3    1.156 ±  0.360   #/op
HashWalkBench.walk:L1-dcache-loads        1048576  avgt    3   25.417 ±  1.711   #/op
HashWalkBench.walk:L1-dcache-stores       1048576  avgt    3   23.265 ±  3.552   #/op
HashWalkBench.walk:cycles                 1048576  avgt    3   97.435 ± 69.688   #/op
HashWalkBench.walk:instructions           1048576  avgt    3  102.477 ± 12.689   #/op

由上述执行结果可知,并行执行相同数量的加载(得益于编码的卡表指针),并进行 3 次额外的存储,这需要大约 22 个额外的周期和 12 条指令。此增加似乎对应于此特定工作负载中的三个写入屏障。请注意,L1 缓存未命中率也略高:因为卡标记存储会污染它,从而降低应用程序的有效缓存容量。

总结

GC 通常带有一组屏障,即使没有实际的 GC 工作发生,这些屏障也会影响应用程序的性能。即使是在非常基本的代际收集器(如 Serial/Parallel)的情况下,您也至少有一个引用存储屏障,必须记录代际屏障。更高级的收集器(如 G1)甚至有更复杂的屏障,可以跟踪区域之间的引用。在某些情况下,这种成本是不可接受的,以至于需要一些技巧来避免它,包括无操作 GC,如 Epsilon。

相关文章:

  • Selenium的免登录和滚动条到底部的学习总结(3)
  • 焕新|16GB+1TB 、UV 双段,AORO M8 防爆手机安全性能双升级
  • 使用pnpm管理前端项目依赖
  • 数字IC后端项目典型问题(2025.03.10数字后端项目问题记录)
  • SQL Server 列存储索引:大幅提升查询性能的利器
  • TDengine 配置 ODBC 数据源
  • c#如何直接获取json中的某个值
  • Bad owner or permissions on ssh/config - 解决方案
  • 表、索引统计信息锁定和解锁
  • 第十课:爬虫综合实战:从数据采集到可视化分析
  • K8s 1.27.1 实战系列(十二)Ingress
  • Redis----大key、热key解决方案、脑裂问题
  • 【教学类-43-25】20240311 数独3宫格的所有可能(图片版 12套样式,空1格-空8格,每套510张,共6120小图)
  • DTL698电表数据 转 EthernetIP协议项目案例
  • 大模型安全新范式:DeepSeek一体机内容安全卫士发布
  • 【数据结构】1数据结构基本概念 【作业1数据结构综述】
  • C++【类和对象】(超详细!!!)
  • 【外部链接跳转uniapp开发的App内指定页面】实现方案
  • git切换版本
  • 微信小程序使用的SSL证书在哪里申请?
  • 英国警方再逮捕一名涉嫌参与首相住宅纵火案嫌疑人
  • 李成钢:近期个别经济体实施所谓“对等关税”,严重违反世贸组织规则
  • 中国社联成立95周年,《中国社联期刊汇编》等研究丛书出版
  • 恒生银行回应裁员传闻:受影响的员工数目占银行核心业务员工总数约1%
  • 终于越过萨巴伦卡这座高山,郑钦文感谢自己的耐心和专注
  • 中国科学院院士、我国航天液体火箭技术专家朱森元逝世