为什么 Arrays.fill 在 G1GC 上慢了 265 倍?
摘要
本文探讨了为什么 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 变更
JDK 27 引入了对 HotSpot 的 stop-the-world 垃圾收集器的显著更改,最重要的是 JEP 523 使 G1 在所有环境中成为默认 GC,同时还包含各种改进、重构和错误修复。
观察 Go 的新垃圾回收器在堆中的移动
Go 1.26 将 Green Tea 设为默认垃圾回收器,提升了缓存友好性。本文通过 Go 和 C# 可视化堆分配,并讨论了非移动回收器和稀疏页面带来的挑战。
垃圾回收的实际成本
一篇技术文章解释了垃圾回收的真实性能成本,对比了 Go、Java、Rust、Swift 和 Python 等语言中的跟踪式 GC、引用计数和编译期内存管理。
加速Plush垃圾收集器
作者详细介绍了为其玩具编程语言Plush的垃圾收集器进行的性能改进,专注于优化复制算法以减少收集时间。
llama.cpp 中的流水线并行可能浪费你的显存
测试表明,llama.cpp 默认的流水线并行浪费显存且无速度提升;通过编译时设置 GGML_SCHED_MAX_COPIES=1 可节省大量显存,同时保持相同推理速度。