为什么 Arrays.fill 在 G1GC 上慢了 265 倍?

Hacker News Top 新闻

摘要

本文探讨了为什么 Arrays.fill 在 G1GC 上比在 ParallelGC 上慢了 265 倍,通过详细的性能分析和汇编代码分析,将问题追溯到 JIT 生成的代码和内存屏障。

暂无内容
查看原文
查看缓存全文

缓存时间: 2026/09/04 15:06

# [Java][JVM][调优][性能分析][G1][JIT] 为什么在G1GC上 Arrays.fill 慢了265倍? 来源:https://krzysztofslusarski.github.io/2026/08/19/g1barrier.html ## 重要警告 本文展示了一些使用 JVM 标志进行的 JVM 调优。**在不了解可能产生的后果的情况下,绝不应使用任何 JVM 标志。** 此处使用的大多数标志是诊断性的,用于理解系统行为。只有一个标志值得在生产环境中考虑,我将在文末详细说明。 ## 基准测试 这一切始于一个我认为会很无聊的基准测试。在 G1GC 和 ParallelGC 上分别用引用填充两个数组: ```java package pl.ks.jmh; import org.openjdk.jmh.annotations.*; import java.util.Arrays; import java.util.concurrent.TimeUnit; @State(Scope.Benchmark) public class MyBenchmark { Object[] table = new Object[1024 * 1024]; Object[] table2 = new Object[1024 * 1024]; Object mark = new Object(); @Benchmark @Fork(value = 1, warmups = 1, jvmArgsAppend = "-XX:+UseParallelGC") @OutputTimeUnit(TimeUnit.MICROSECONDS) @Warmup(iterations = 1) @Measurement(iterations = 2) @BenchmarkMode(Mode.AverageTime) public void parallelGC() { Arrays.fill(table, mark); Arrays.fill(table2, mark); } @Benchmark @Fork(value = 1, warmups = 1, jvmArgsAppend = "-XX:+UseG1GC") @OutputTimeUnit(TimeUnit.MICROSECONDS) @Warmup(iterations = 1) @Measurement(iterations = 2) @BenchmarkMode(Mode.AverageTime) public void g1GC() { Arrays.fill(table, mark); Arrays.fill(table2, mark); } } ``` 这些方法中**没有分配**,因此测量期间**完全没有 GC 周期**。无论差异是什么,它都不可能是“G1 收集垃圾更慢”。然而,结果是: ``` Benchmark Mode Cnt Score Units MyBenchmark.g1GC avgt 2 139019.174 us/op MyBenchmark.parallelGC avgt 2 525.537 us/op ``` **139毫秒对比0.5毫秒。** 相同的 Java 代码,相同的 JDK,相同的机器。G1 **慢了265倍**。本文讲述了将这个数字追溯到单条机器指令,然后发现该指令只解释了部分答案的故事。 ## 环境 - **JDK 25.0.3**(来自 Amazon Corretto)用于总结之前的所有部分,以及**JDK 26.0.2.1**用于后记。 - **Apple M4 Max**,64 GB RAM,macOS。 - 默认堆:最大**16 GB**,这使得 G1 选择 **8 MB 的区域**(这在后面非常重要)。 - 启用了压缩指针(Compressed oops),禁用了 `UseCompactObjectHeaders`(两者在此均为默认值)。 - 使用 JMH 和 `xctraceasm` 分析器(在 Linux 上你会使用 `perfasm`,工作原理相同)。 ## 时间花在哪里了? 第一个问题总是相同的:哪段代码是热点?既然没有 GC 也没有分配,我使用汇编级分析器运行了基准测试: ``` java -jar target/benchmarks.jar -prof xctraceasm ``` 在两种 GC 上,答案都是一样的:99.8% 的采样落在 `Arrays.fill` 的 JIT 编译代码中。没有 VM 开销,没有 GC 线程占用 CPU,没有安全点(safepoints)。变异器线程本身只是在运行缓慢的代码。因此差异必然在于 **JIT 生成的内容**,而分析器恰恰给了我们这个。 ## 最小汇编入门 这是一台 ARM64 机器,因此下面的清单都是 ARM64 的。如果你从未读过汇编代码,以下是本文所需的所有内容。这是一个非常简短的清单。 **寄存器** 是 CPU 的工作变量。可以将它们视为约 30 个在所有方法间共享的预声明局部变量: | 符号 | 含义 | |------|------| | `x0`...`x30` | 用作完整 64 位值的寄存器 | | `w0`...`w30` | **同一个**寄存器,但仅使用其低 32 位 | | `wzr`/`xzr` | “零寄存器”,读取它总是得到 `0` | | `x28` | HotSpot 保留此寄存器用于**当前 `Thread*`** | | `x27` | HotSpot 保留此寄存器用于**Java 堆的基地址** | **指令**,仅列出本文中出现的: | 指令 | Java 术语 | |------|-----------| | `mov x1, #5` | `x1 = 5` | | `add x1, x2, #16` | `x1 = x2 + 16` | | `lsr x1, x2, #9` | `x1 = x2 >>> 9` | | `eor x1, x2, x3` | `x1 = x2 ^ x3`(异或) | | `ldr x1, [x2]` | `x1 = memory[x2]` - 加载 8 字节 | | `ldrb w1, [x2]` | `w1 = memory[x2]` - 加载**一个字节**(`b` 代表 byte) | | `str w1, [x2]` | `memory[x2] = w1` - 存储 4 字节 | | `strb wzr, [x2]` | `memory[x2] = 0` - 存储一个零字节 | | `cbz x1, LABEL` | `if (x1 == 0) goto LABEL` | | `cbnz x1, LABEL` | `if (x1 != 0) goto LABEL` | | `cmp w1, #2` + `b.ne LABEL` | `if (w1 != 2) goto LABEL` | | `csel x1, x2, xzr, hs` | `x1 = cond ? x2 : 0` - 无分支的三元运算符 | | `dmb ish` | **完整内存屏障** - 停顿直到所有挂起的写操作对所有核心可见 | 两个寻址简写: `[x5, x14]` 表示 `memory[x5 + x14]`, `add x14, x1, w10, sxtw #2` 表示 `x14 = x1 + ((long) w10) * 4`,这在单条指令中实现了数组索引。 这就是全部词汇了。让我们来读一些代码。 ### 一个注意事项:所有这些都是 ARM64 本文中的每个清单都是 **aarch64** 的,因为这是我的分析环境。在 **x86-64** 上,相同的屏障看起来完全不同 - 不同的寄存器、不同的助记符,内存屏障根本不是 `dmb ish`。HotSpot 在 x86 上通过栈上的一个锁定空操作来构建 `StoreLoad` 屏障: `lock addl $0x0,-0x40(%rsp)` 在此逐条解码 x86 版本超出了本文范围。**不是**特定于架构的是其逻辑。`g1BarrierSetAssembler_x86.cpp` 和 `g1BarrierSetAssembler_aarch64.cpp` 是彼此的镜像 - 相同的三个提前退出、相同的年轻卡检查,以及慢速路径顶部的相同 `StoreLoad` 内存屏障。**因此,本文描述的问题并非 ARM 特有问题。** 仅清单是 ARM 的。我没有测量 x86 上的惩罚有多大,也不会假设数字相同 - 屏障的开销是非常特定于微架构的。如果你想精确重现这些清单,你需要一台 aarch64 机器。这类机器已经不再稀有: - **AWS** - Graviton2/3/4:`m6g/c6g/r6g`、`m7g/c7g/r7g` 和 `m8g/c8g/r8g` 系列 - **Google Cloud** - `T2A`(Ampere Altra)和 `C4A`(Google Axion) - **Azure** - Ampere Altra `Dpsv5/Epsv5`,以及 Cobalt 100 `Dpsv6/Epsv6` - **Oracle Cloud** - Ampere `A1` 和 `A2` 规格 - 任何 Apple Silicon Mac - 而这正是产生下面所有清单的环境 ## ParallelGC 生成了什么 以下是 ParallelGC 运行的热点循环。每个 Java 层的 `table[i] = mark` 变成了这样: ``` lsr x14, x11, #9 ; x14 = address(table[i]) >>> 9 strb wzr, [x5, x14, lsl #0] ; cardTable[x14] = 0 str w16, [x11] ; table[i] = mark ``` 一条指令执行实际工作,两条额外指令操作一个称为*卡表*的东西。这就是**写屏障**,值得简单解释一下它的作用。 ### 什么是写屏障? 每个分代 GC 都面临相同的问题。当它只收集年轻代时,必须找到所有**指向**年轻代的引用,包括老年代对象持有的引用。扫描整个老年代来查找它们会违背只收集年轻代的意义。解决方案是让应用程序负责簿记。 堆被划分为 512 字节的**卡**,JVM 维护一个字节数组,每个卡一个字节 - 即**卡表**。每次你的代码向对象写入一个引用时,JIT 会生成几条额外指令,将该对象的卡标记为*脏*。在 GC 时,收集器只扫描脏卡,而不是整个老年代。每次引用存储后的那几条额外指令就是**写屏障**。你从未编写过它,无法在代码中看到它,它在**应用程序执行的每一次引用赋值上**运行。 现在 ParallelGC 清单读起来就像普通的 Java: ```java table[i] = mark; // str w16, [x11] cardTable[address(table[i]) >>> 9] = 0; // 0 表示 "脏" ``` `>>> 9` 是除以 512(卡的大小)。寄存器 `x5` 持有卡表的基地址,并且它**在循环开始前加载一次** - 你不会在循环内部任何地方找到它重新计算。**没有分支**,没有条件,没有什么需要预测。ParallelGC 不关心对象是年轻还是年老,值是否为空,或者它指向哪里。它标记卡然后继续。 该清单中还有一个值得记住的细节:循环计数器递增 **8**,而不是 1。JIT **展开**了循环,因此每次迭代填充八个元素。 ## G1GC 生成了什么 现在看 G1 上相同的 Java 语句。相同的 JDK,相同的机器,相同的 `Arrays.fill`: ``` 0x...bff0: add x14, x1, w10, sxtw #2 ; x14 = arrayBase + i*4 0x...bff4: add x14, x14, #0x10 ; x14 = address of table[i] 0x...bff8: add w10, w10, #1 ; i++ 0x...bffc: ldrb w16, [x28, #0x48] ; load a byte from the current Thread 0x...c000: cbnz w16, #0x...c0a0 ; if it is not 0 -> jump away 0x...c004: subs x17, x2, x27 0x...c008: csel x17, x17, xzr, hs 0x...c00c: lsr x17, x17, #3 ; compress the reference into 4 bytes 0x...c010: str w17, [x14] ; table[i] = mark <-- the actual store 0x...c014: eor x16, x14, x2 0x...c018: lsr x16, x16, #0x17 0x...c01c: cbz x16, #0x...c03c ; exit 1 0x...c020: cbz x2, #0x...c03c ; exit 2 0x...c024: lsr x16, x14, #9 0x...c028: mov x15, #0xdf310000 0x...c02c: add x16, x16, x15 ; x16 = address of the card 0x...c030: ldrb w15, [x16] ; load the card byte 0x...c034: cmp w15, #2 0x...c038: b.ne #0x...c108 ; exit 3 -> the slow path 0x...c03c: cmp w10, w13 0x...c040: b.lt #0x...bff0 ; loop back ``` 以及在更下方,主指令流之外: ``` 0x...c108: dmb ish ; FULL MEMORY FENCE 0x...c10c: ldrb w15, [x16] ; re-load the card byte 0x...c110: cbz w15, #0x...c03c ; already dirty -> back to the loop 0x...c114: strb wzr, [x16] ; mark the card dirty 0x...c118: ldr x15, [x28, #0x50] ; \ 0x...c11c: cbz x15, #0x...c134 ; | 0x...c120: sub x15, x15, #8 ; | push the card into this thread's 0x...c124: str x15, [x28, #0x50] ; | dirty card queue 0x...c128: ldr x8, [x28, #0x58] ; | 0x...c12c: str x16, [x8, x15] ; / 0x...c130: b #0x...c03c ``` 而不是三条指令,这里是二十条指令,四个条件分支,以及隐藏在末尾的**完整内存屏障**。转换为 Java,G1 上 `Arrays.fill` 的一次迭代是: ```java // ---- 前置屏障:并发标记当前是否在运行? ---- if (currentThread.satbMarkQueueActive != 0) { slowPathForConcurrentMarking(); // 很少执行 } // ---- 实际的存储操作 ---- table[i] = mark; // ---- 后置屏障 ---- if (((address(table[i]) ^ mark) >>> 23) == 0) { goto done; // 退出条件 1:同一个区域? } if (mark == null) { goto done; // 退出条件 2:存储 null? } cardAddr = cardTableBase + (address(table[i]) >>> 9); if (cardTable[cardAddr] == 2) { goto done; // 退出条件 3:年轻卡? } // ---- 慢速路径 ---- fullMemoryFence(); // dmb ish if (cardTable[cardAddr] == 0) { goto done; // 已经是脏卡 } cardTable[cardAddr] = 0; // 标记为脏 enqueueIntoDirtyCardQueue(cardAddr); done: ``` G1 做了比 ParallelGC 多得多的工作,但它也**更努力地尝试什么都不做**。它有三个单独的逃生通道,在正常代码中,其中一个几乎总是会触发,因此屏障的代价只是一小部分廉价指令和一个预测良好的分支。这就是设计。有趣的问题是:为什么在我的基准测试中,它们一个都没有触发? ## 如何将汇编映射回 JDK 源代码 在回答这个问题之前,让我展示一下这个技巧,因为它是可复用的,也是我发现人们经常跳过的部分。那三个神奇的数字 - `23`、`9`、`2` - 并非随意选择。上面的每一条指令都来自 HotSpot C++ 中的特定行,你可以机械地从一个追溯到另一个。这里有四个层次。 `.ad` 文件是 HotSpot 的指令选择规则:模式匹配,表示“当在编译器图中看到此形状时,生成此机器代码”。JDK 的**调试版本**可以打印出触发的规则: ``` java -XX:+UnlockDiagnosticVMOptions \ -XX:CompileCommand=PrintOptoAssembly,java.util.Arrays::fill \ ... ``` ``` 090 B7: # Loop( B7-B7 inner strip mined) Freq: 120,936 090 add R14, R1, R10, I2L #2 094 + add R14, R14, #16 098 + addw R10, R10, #1 09c encode_heap_oop R17, R2 strw R17, [R14] # compressed ptr 0e0 + cmpw R10, R13 0e4 blt B7 // counted loop end ``` 这些字符串是从 `src/hotspot/cpu/aarch64/gc/g1/g1_aarch64.ad` 中的一个 `format` 块原样复制的,属于一个名为 **`g1EncodePAndStoreN`** 的规则。这个名称是进入源代码的入口点。 也值得阅读的是左列 - 那些是字节偏移。清单从 `09c` 跳到 `0e0`,这是规则未打印的 **68 字节机器代码**。这个间隙*就是* G1 屏障。它在此清单生成后插入,这正是它在此级别不可见但在真实代码中存在的原因。 ``` instruct g1EncodePAndStoreN(...) match(Set mem (StoreN mem (EncodeP src))); ins_encode %{ write_barrier_pre(masm, ...); // 存储之前的所有操作 __ encode_heap_oop($tmp1, $src); __ strw($tmp1$$Register, $mem$$Register); write_barrier_post(masm, ...); // 存储之后的所有操作 %} ``` `write_barrier_pre` 和 `write_barrier_post` 最终位于 `g1BarrierSetAssembler_aarch64.cpp` 中。这就是让整个练习变得容易的关键:**那个文件是逐行指令的逐字抄录**。在该 C++ 和机器代码之间没有优化器。每个 `__ something()` 恰好发出一条指令,按源顺序: ```cpp static void generate_post_barrier_fast_path(MacroAssembler* masm, ...) { // 存储是否跨堆区域? __ eor(tmp1, store_addr, new_val); // eor x16, x14, x2 __ lsr(tmp1, tmp1, G1HeapRegion::LogOfHRGrainBytes); // lsr x16, x16, #0x17 __ cbz(tmp1, done); // cbz x16, done // 跨区域,存储 null? if (new_val_may_be_null) { __ cbz(new_val, done); // cbz x2, done } // 存储跨区域非空引用,卡是否年轻? __ lsr(tmp1, store_addr, CardTable::card_shift()); // lsr x16, x14, #9 __ load_byte_map_base(tmp2); // mov x15, #0xdf310000 __ add(tmp1, tmp1, tmp2); // add x16, x16, x15 __ ldrb(tmp2, Address(tmp1)); // ldrb w15, [x16] __ cmpw(tmp2, (int)G1CardTable::g1_young_card_val()); // cmp w15, #2 } static void generate_post_barrier_slow_path(MacroAssembler* masm, ...) { __ membar(Assembler::StoreLoad); // StoreLoad membar // dmb ish __ ldrb(tmp2, Address(tmp1)); // tmp2 := card // ldrb w15, [x16] __ cbzw(tmp2, done); // cbz w15, done // 存储跨区域、非空对象引用,卡是干净的。 // 标记卡为脏并记录。STATIC_ASSERT(CardTable::dirty_card_val() == 0); __ strb(zr, Address(tmp1)); // *(card address) := dirty_card_val generate_queue_test_and_insertion(masm, ...); __ b(done); } ``` 与上面的反汇编代码并排阅读。它是相同的代码。作为对比,以下是来自 `cardTableBarrierSetAssembler_aarch64.cpp` 的**完整** ParallelGC 屏障: ```cpp void CardTableBarrierSetAssembler::store_check(MacroAssembler* masm, Register obj, Address dst) { __ lsr(obj, obj, CardTable::card_shift()); assert(CardTable::dirty_card_val() == 0, "must be"); __ load_byte_map_base(rscratch1); if (UseCondCardMark) { Label L_already_dirty; __ ldrb(rscratch2, Address(obj, rscratch1)); __ cbz(rscratch2, L_already_dirty); __ strb(zr, Address(obj, rscratch1)); __ bind(L_already_dirty); } else { __ strb(zr, Address(obj, rscratch1)); } } ``` `UseCondCardMark` 默认为 `false`,因此我们采用 `else` 分支:移位,然后存储一个零字节。仅此而已。 ### 第三层:神奇的数字 你不认识的每个常数都是 JVM 常数。其中两个来自 `-XX:+PrintFlagsFinal`: ``` java -XX:+UseG1GC -XX:+PrintFlagsFinal -version | grep G1HeapRegionSize size_t G1HeapRegionSize = 8388608 {product} {ergonomic} ``` 8388608 是 2^23,而 `lsr x16, x16, #0x17` 是右移 23 位。将地址右移 23 位会将其转换为**区域号**,因此 `eor` 后跟 `lsr` 再跟 `cbz` 就是在问*“这两个地址是否在同一个 8 MB 区域内?”* 如果它们是,G1 根本不关心这个存储操作 - 一个未离开其区域的引用不需要簿记。 第三个

相似文章

JDK 27 G1/Parallel/Serial GC 变更

Hacker News Top

JDK 27 引入了对 HotSpot 的 stop-the-world 垃圾收集器的显著更改,最重要的是 JEP 523 使 G1 在所有环境中成为默认 GC,同时还包含各种改进、重构和错误修复。

观察 Go 的新垃圾回收器在堆中的移动

Lobsters Hottest

Go 1.26 将 Green Tea 设为默认垃圾回收器,提升了缓存友好性。本文通过 Go 和 C# 可视化堆分配,并讨论了非移动回收器和稀疏页面带来的挑战。

垃圾回收的实际成本

Hacker News Top

一篇技术文章解释了垃圾回收的真实性能成本,对比了 Go、Java、Rust、Swift 和 Python 等语言中的跟踪式 GC、引用计数和编译期内存管理。

加速Plush垃圾收集器

Hacker News Top

作者详细介绍了为其玩具编程语言Plush的垃圾收集器进行的性能改进,专注于优化复制算法以减少收集时间。