プログラミング
G1GCでArrays.fillが265倍遅くなる理由
Why is Arrays.fill 265 times slower on G1GC? (krzysztofslusarski.github.io)
要約
この記事では、JavaのArrays.fillメソッドがG1GC(Garbage-First Garbage Collector)でParallelGCと比較して著しく遅くなる現象を調査しています。ベンチマークの結果、G1GCではParallelGCの265倍の時間がかかることが判明しました。この遅延の原因をアセンブリレベルで掘り下げ、ARM64アーキテクチャにおけるストアロードバリア(StoreLoad barrier)のコストが、このパフォーマンス低下の主な要因であることを突き止めています。
全文翻訳
[Java][JVM][Tuning][Profiling][G1][JIT] G1GCでArrays.fillが265倍遅くなる理由
大きな警告
この記事はJVMチューニングをJVMフラグを使用して示しています。結果が何をもたらすかを知らずにJVMフラグを使用しないでください。ここで使用されているフラグのほとんどは診断用であり、何が起こっているかを理解するために使用されます。本番環境で検討する価値のあるフラグは1つだけで、それは最後に説明します。
ベンチマーク
退屈になると予想していたベンチマークから始まりました。2つの配列をリファレンスで埋めます。一度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無効(これらは両方ともデフォルトです)。
xctraceasmプロファイラを使用したJMH(Linuxではperfasmを使用します。動作は同じです)。
時間はどこへ行くのか?
最初の質問は常に同じです。「どのコードがホットか?」GCもアロケーションも発生しないため、アセンブリレベルのプロファイラでベンチマークを実行しました。
`java -jar target/benchmarks.jar -prof xctraceasm`
両方のGCで答えは同じです。サンプルの99.8%はJITコンパイルされたArrays.fillのコードにあります。VMオーバーヘッドはなく、CPUを奪うGCスレッドもなく、セーフポイントもありません。ミューテータースレッド自体が単に遅いコードを実行しているだけです。したがって、違いは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 (XOR)
ldr x1, [x2]
x1 = memory[x2] - 8バイトをロード
l drb w1, [x2]
w1 = memory[x2] - 1バイトをロード (b = byte)
str w1, [x2]
memory[x2] = w1 - 4バイトをストア
strb wzr, [x2]
memory[x2] = 0 - 1ゼロバイトをストア
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
フルメモリフェンス - すべての保留中の書き込みがすべてのコアで可視になるまで停止
2つのアドレス指定ショートカット:
[x5, x14] は memory[x5 + x14] を意味し、add x14, x1, w10, sxtw #2 は x14 = x1 + ((long) w10) * 4 を意味します。これは単一命令での配列インデックスです。
これがボキャブラリー全体です。コードを読んでみましょう。
1つの注意点:これらはすべてARM64です。
この記事のすべてのリストはaarch64ですが、私がプロファイルしたのはそれです。x86-64では、同じバリアは完全に異なります。レジスタ、ニーモニックが異なり、メモリフェンスはdmb ishではありません。HotSpotはスタック上のロックされたno-opからx86のStoreLoadバリアを構築します。
`lock addl $0x0,-0x40(%rsp)`
x86バージョンの命令ごとのデコードはここでは範囲外です。アーキテクチャに依存しないのはロジックです。
g1BarrierSetAssembler_x86.cpp と g1BarrierSetAssembler_aarch64.cpp は互いに鏡像です。同じ3つの早期終了、同じヤングカードチェック、そしてスローパスの先頭にある同じStoreLoad membarです。したがって、この記事で説明されている問題は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;` はそれぞれ次のようになりました。
```assembly
lsr x14, x11, #9 ; x14 = address(table[i]) >>> 9
strb wzr, [x5, x14, lsl #0] ; cardTable[x14] = 0
str w16, [x11] ; table[i] = mark
```
実際の作業を行う命令は1つで、追加の2つの命令はカードテーブルと呼ばれるものに関連する処理を行います。それがライトバリアであり、それが何のためにあるのかを説明するために短い脱線をしても価値があります。
ライトバリアとは?
すべての世代別GCは同じ問題を抱えています。若い世代のみを収集する場合、古いオブジェクトによって保持されている参照を含む、若い世代にポインタを指すすべての参照を見つける必要があります。それらを見つけるために古い世代全体をスキャンすることは、若い世代のみの収集の目的を損なうでしょう。解決策は、アプリケーションにブックキーピングを行わせることです。ヒープは512バイトのカードに分割され、JVMはカードごとに1バイトを持つバイト配列(カードテーブル)を保持します。コードがオブジェクトにリファレンスを書き込むたびに、JITはカードをダーティとしてマークする追加の命令をいくつか発行します。GC時には、コレクタは古い世代全体をスキャンするのではなく、ダーティカードのみをスキャンします。すべてのリファレンスストアの後に続くこれらの少数の追加命令がライトバリアです。あなたはそれを書いたことはなく、コードで見ることができず、アプリケーションが実行するすべてのリファレンス代入で実行されます。
これでParallelGCのリストはプレーンなJavaのように読めます。
`table[i] = mark;` // `str w16, [x11]`
`cardTable[address(table[i]) >>> 9] = 0;` // 0は「ダーティ」を意味します
`>>> 9` は512(カードサイズ)で割っています。レジスタx5はカードテーブルのベースアドレスを保持しており、ループの前に一度ロードされます。ループ内で再計算されている場所は見つかりません。分岐も条件もなく、予測するものもありません。ParallelGCは、オブジェクトが若いか古いか、値がnullか、どこを指しているかに関係ありません。カードをマークして先に進みます。
そのリストには、後で覚えておくべきもう1つの詳細があります。ループカウンターは1ではなく8ずつインクリメントされています。JITはループを8回展開したため、1回のイテレーションで8つの要素が埋められます。
G1GCが生成したもの
次に、G1での同じJavaステートメントです。