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つだけで、記事の最後に記述します。
ベンチマーク
退屈になると予想していたベンチマークから始まりました。G1GCとParallelGCで、それぞれ1つの参照を2つの配列にフィルします。
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はGCが遅い」ということはありえません。それにもかかわらず:
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
- デフォルトヒープ: max 16 GB。これによりG1は8 MBのリージョンを選択します(これは後で重要になります)。
- Compressed oops有効、
UseCompactObjectHeaders無効(これらは両方ともデフォルトです)。 - JMHと
xctraceasmプロファイラー(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バイトをロード |
ldrb 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を意味します。これは単一命令での配列インデックスです。
これがすべての語彙です。コードを読んでみましょう。
注意点:これらすべてはARM64です
この記事のすべてのリストはaarch64です。なぜなら、私がプロファイルしたのはそれだからです。x86-64では、同じバリアは完全に異なるように見えます - レジスタ、ニーモニックが異なり、メモリフェンスはdmb ishではありません。HotSpotはx86でスタック上のロックされたno-opからStoreLoadバリアを構築します。
lock addl $0x0,-0x40(%rsp)
x86バージョンを命令ごとにデコードすることはここでは範囲外です。
アーキテクチャに依存しないのはロジックです。g1BarrierSetAssembler_x86.cppとg1BarrierSetAssembler_aarch64.cppは互いに鏡像です - 同じ3つの早期終了、同じyoung-cardチェック、そしてスローパスの先頭にある同じStoreLoad membar。したがって、この記事で説明されている問題は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 100Dpsv6/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
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つの詳細があります:ループカウンターは8ずつ増加し、1ずつではありません。JITはループを8回展開したため、1回のイテレーションで8つの要素がフィルされます。
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 = table[i]のアドレス
0x...bff8: add w10, w10, #1 ; i++
0x...bffc: ldrb w16, [x28, #0x48] ; 現在のスレッドからバイトをロード
0x...c000: cbnz w16, #0x...c0a0 ; 0でなければ->ジャンプ
0x...c004: subs x17, x2, x27
0x...c008: csel x17, x17, xzr, hs
0x...c00c: lsr x17, x17, #3 ; 参照を4バイトに圧縮
0x...c010: str w17, [x14] ; table[i] = mark <-- 実際のストア
0x...c014: eor x16, x14, x14 ; x16 = address(table[i]) ^ mark
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 = カードのアドレス
0x...c030: ldrb w15, [x16] ; カードバイトをロード
0x...c034: cmp w15, #2
0x...c038: b.ne #0x...c108 ; exit 3 -> スローパス
0x...c03c: cmp w10, w13
0x...c040: b.lt #0x...bff0 ; ループバック
そしてさらに下の方、メインの命令ストリームの外側:
0x...c108: dmb ish ; 完全なメモリフェンス
0x...c10c: ldrb w15, [x16] ; カードバイトを再ロード
0x...c110: cbz w15, #0x...c03c ; すでにダーティ -> ループに戻る
0x...c114: strb wzr, [x16] ; カードをダーティとしてマーク
0x...c118: ldr x15, [x28, #0x50] ; \
0x...c11c: cbz x15, #0x...c134 ; |
0x...c120: sub x15, x15, #8 ; | このスレッドの
0x...c124: str x15, [x28, #0x50] ; | ダーティカードキューにカードをプッシュ
0x...c128: ldr x8, [x28, #0x58] ; |
0x...c12c: str x16, [x8, x15] ; /
0x...c130: b #0x...c03c
3つの命令ではなく20個、4つの条件分岐、そして最後に隠された完全なメモリフェンス。Javaに翻訳すると、Arrays.fillの1回のイテレーションは次のようになります。
// ---- 事前バリア:現在、同時マーキングが実行中か? ----
if (currentThread.satbMarkQueueActive != 0) {
slowPathForConcurrentMarking(); // めったに取られない
}
// ---- 実際のストア ----
table[i] = mark;
// ---- 事後バリア ----
if (((address(table[i]) ^ mark) >>> 23) == 0) { goto done; } // exit 1: 同じリージョンか?
if (mark == null) { goto done; } // exit 2: nullをストアしているか?
cardAddr = cardTableBase + (address(table[i]) >>> 9);
if (cardTable[cardAddr] == 2) { goto done; } // exit 3: ヤングカードか?
// ---- スローパス ----
fullMemoryFence(); // dmb ish
if (cardTable[cardAddr] == 0) { goto done; } // すでにダーティ
cardTable[cardAddr] = 0; // それをダーティとしてマーク
enqueueIntoDirtyCardQueue(cardAddr);
done:
G1はParallelGCよりもはるかに多くの作業を行っていますが、より懸命に何もしようとしています。3つの独立したエスケープハッチがあり、通常のコードではそのうちの1つがほぼ常に発火するため、バリアは数個の安価な命令とよく予測されたブランチのコストで済みます。それが設計です。
興味深い質問は、なぜ私のベンチマークではどれも発火しないのか?
アセンブリをJDKソースコードにマッピングする方法
その質問に答える前に、そのテクニックを示します。なぜなら、それは再利用可能であり、人々がスキップする部分だと私が思うからです。
3つのマジックナンバー - 23、9、2 - は任意ではありません。上記のすべての命令は、HotSpot C++の特定の行から来ており、機械的に1つから別のものへとたどることができます。4つのレイヤーがあります。
.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()は、ソース順に正確に1つの命令を発行します。
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
}
// リージョン横断、非null oop、カードが若いか?
__ 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
// リージョン横断、非null oop、カードはクリーン。
// カードをダーティにし、ログに記録する。
STATIC_ASSERT(CardTable::dirty_card_val() == 0);
__ strb(zr, Address(tmp1)); // *(card address) := dirty_card_val
generate_queue_test_and_insertion(masm, ...);
__ b(done);
}
それを上のアセンブリリストと並べて読んでください。それは同じコードが2回あるようなものです。
比較のために、完全なParallelGCバリアをcardTableBarrierSetAssembler_aarch64.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ブランチを取ります:シフトして、ゼロバイトをストアします。それがすべてです。
レイヤー3:マジックナンバー
認識できない定数はすべてJVM定数です。そのうち2つは-XX:+PrintFlagsFinalから来ています。
java -XX:+UseG1GC -XX:+PrintFlagsFinal -version | grep G1HeapRegionSize
size_t G1HeapRegionSize = 8388608 {product} {ergonomic}
8388608は223であり、lsr x16, x16, #0x17は23ビットのシフトです。アドレスを23ビット右シフトするとリージョン番号になります。したがって、eor、lsr、cbzは「これら2つのアドレスは同じ8MBリージョンにあるか?」と尋ねています。もしそうなら、G1はこのストアをまったく気にしません。リージョンを離れない参照はブックキーピングを必要としません。
3番目の定数は、g1CardTable.hppのenumから来ています。
enum G1CardValues {
g1_young_gen = CT_MR_BS_last_reserved << 1, // = 2
...
};
そしてcardTable.hppから:
enum CardValues {
clean_card = (CardValue)-1,
dirty_card = 0,
CT_MR_BS_last_reserved = 1
};
したがって、cmp w15, #2は「このカードは若いリージョンにあるか?」を意味します。G1は若い世代にポイントする参照を追跡しません( anyway、若い世代全体を毎回収集するため)、したがって若いカードは即座に終了します。
レイヤー4:コードではなく、決定
一部の違いはバリア自体にはありません。それらは、バリアが存在するためにC2が異なる決定を下したことです。それらは最後にに戻ります。
なぜ3つの終了条件が発火しないのか
これで質問に答えることができます。ベンチマークが何をアロケートしているかを見てみましょう。
Object[] table = new Object[1024 * 1024];
Compressed oopsを使用すると、16バイトのヘッダーと要素あたり4バイトになります。
16 + 4 * 1 048 576 = 4 194 320 バイト
G1の巨大オブジェクトの閾値はリージョンの半分です(g1CollectedHeap.hppから)。
static size_t humongous_threshold_for(size_t region_size) {
return (region_size / 2);
}
8MBリージョンでは、これは4 194 304バイトです。配列はこの制限を16バイト超過しています。2ワードです。
巨大オブジェクトが何であるか知らない場合、それについてはこちらで書きました。今日重要な部分:巨大オブジェクトは若い世代を完全にスキップし、古い世代リージョンに直接アロケートされます。
算術を信頼するのではなく、検証してみましょう。
public class Hum {
static Object[] t;
public static void main(String[] a) {
t = new Object[1024 * 1024];
System.gc();
}
}
java -XX:+UseG1GC -Xlog:gc+heap=info Hum.java
[0,179s][info][gc,heap] GC(0) Humongous regions: 1->1
1つの巨大オブジェクトリージョン。配列をnew Object[1024 * 1024 - 8]に変更すると、Humongous regions: 0->0と報告されます。
したがって、3つの終了条件をこれを持って歩くと:
- 配列は古いリージョンに配置され、
markはエデンに配置されます。リージョンが異なるため、XORテストは失敗します。 markはnullではないため、nullテストは失敗します。- 配列のカードは古いリージョンに属しているため、決して
2にはなりません。若いテストは失敗します。
200万回のストアのそれぞれがスローパスにフォールスルーし、その最初の命令はdmb ish、つまり完全なメモリフェンスです。
証明
説明だけではあまり価値がありません。ここにプレーンなmainがあります - JMHなしで、シングルファイルソースランチャーのおかげで、.javaファイルから直接、2秒で実行できます。
コードの前に説明が必要なこと、なぜならメソッドの名前が有名に誤解を招くからです:Integer.getInteger("n", 1024*1024)は文字列"n"を解析するのではなく、システムプロパティnを検索し、そのプロパティが設定されていない場合に1024*1024を返します。したがって、このプログラムの2つのノブは通常の-Dフラグです。
-Dn=<length>- 両方の配列の長さ-Diters=<count>- 測定された繰り返し回数
public class Fill {
static Object[] table = new Object[Integer.getInteger("n", 1024*1024)];
static Object[] table2 = new Object[Integer.getInteger("n", 1024*1024)];
static Object mark = new Object();
static void work() {
java.util.Arrays.fill(table, mark);
java.util.Arrays.fill(table2, mark);
}
public static void main(String[] a) {
int iters = Integer.getInteger("iters", 40);
for (int i = 0; i < 3; i++) {
work();
}
long t0 = System.nanoTime();
for (int i = 0; i < iters; i++) {
work();
}
System.out.printf("len=%d %.3f ms/op%n", table.length,
(System.nanoTime() - t0) / 1e6 / iters);
}
}
以下のテーブルの4行は、これらの4回の実行です。
java -XX:+UseG1GC -Dn=1048576 -Diters=10 Fill.java # 1024*1024, humongous
java -XX:+UseG1GC -Dn=1048568 -Diters=200 Fill.java # 8要素少ない
java -XX:+UseG1GC -XX:G1HeapRegionSize=16m -Dn=1048576 -Diters=200 Fill.java
java -XX:+UseParallelGC -Dn=1048576 -Diters=200 Fill.java
| 設定 | 結果 |
|---|---|
G1, new Object[1024*1024] (humongous) | 124.651 ms/op |
G1, new Object[1024*1024 - 8] (8要素少ない) | 1.124 ms/op |
G1, humongous array + -XX:G1HeapRegionSize=16m | 0.982 ms/op |
ParallelGC, new Object[1024*1024] | 0.501 ms/op |
8つの配列要素を削除すると110倍の高速化。リージョンサイズを上げると、巨大オブジェクトの閾値が8MBに上がるため、同じことが起こります。
3つの終了条件のうちどれが重要かをさらに確認するために、さらに2回の実行を行います。まったく同じ巨大オブジェクト配列を異なる値でフィルします。
| 格納される値 | 結果 |
|---|---|
null | 1.022 ms/op |
若いObjectへの参照 | 109.480 ms/op |
| 別の巨大な、古いオブジェクトへの参照 | 145.766 ms/op |
nullを格納すると終了条件2に入り、高速です。古い世代のオブジェクトへの参照を格納することは、若いオブジェクトへの参照を格納することと同じくらい遅いです。これは、明白な代替説明を除外します。これは若い世代、想起セット、またはG1のリフレッシュメントスレッドとは関係ありません。それは単に「カードが若いとマークされていないので、スローパスを取る」ということです。
巨大オブジェクトの問題ではない
この時点で、「巨大オブジェクトのアロケーションが再び発生した」として片付けるのは簡単です。それは間違いでしょう。バリアには巨大オブジェクトに関するものは何もありません。それは1つの質問をします:このカードは若いか? 巨大オブジェクトであることは、オブジェクトを古い世代に入れるための非常に高速な方法に過ぎません。
通常のオブジェクトもそこに到達します。数回のGCサイクルしかかかりません。同じ配列、意図的に巨大オブジェクトの閾値を8要素下回るようにし、昇格される前と後を測定します。
public class Promote {
static final int N = 1024*1024 - 8; // humongousではない
static Object[] table;
static Object mark;
static void work() { java.util.Arrays.fill(table, mark); }
static void bench(String label, int iters) {
for (int i = 0; i < 5; i++) { // ウォームアップ、下の注を参照
work();
}
long t0 = System.nanoTime();
for (int i = 0; i < iters; i++) {
work();
}
System.out.printf("%40s %9.3f ms/op%n", label,
(System.nanoTime() - t0) / 1e6 / iters);
}
public static void main(String[] a) {
table = new Object[N];
mark = new Object();
bench("array young (fresh eden allocation)", 200);
for (int i = 0; i < 4; i++) { // 古い世代に昇格させる
System.gc();
}
mark = new Object(); // 新しい若いマーク
bench("same array, after 4x System.gc()", 30);
}
}
このウォームアップループに関する注意点。5回の呼び出しは、JITベンチマークとしては少なく見えます。
ここでArrays.fillがコンパイルされるのは、呼び出し回数ではなく、その中のループの繰り返し回数です。C2はバックエッジカウンターを介して到達し、Tier4BackEdgeThresholdは40000です。100万要素の配列に対する単一の呼び出しは、それを26回以上通過するため、メソッドは最初の呼び出し中にすでにコンパイルされています。-XX:+PrintCompilationはそれが起こるのを示します。
187 1664 4 java.util.Arrays::fill (21 bytes)
187 1665 % 4 java.util.Arrays::fill @ 5 (21 bytes)
4はC2であり、%はオンスタック置換コンパイルを示します。ループが実行中にJITがメソッドを置き換えました。呼び出し回数に依存していた場合、Tier4InvocationThresholdが5000であるため、数千回の呼び出しが必要だったでしょう。
追加のウォームアップ呼び出しは別の理由で存在し、再現する場合に知っておくと役立ちます。新しく作成された配列の最初のフィルは、約2 msで実行され、定常状態よりも約40倍高速であり、タイミングが安定するまでさらに2〜3回の呼び出しがかかります。これはJIT効果ではありません。巨大オブジェクト配列でも発生し、-XX:G1ConcRefinementThreads=0でも持続します。それを底まで追いかけたわけではありません。さらに、スロー状態に入ると、呼び出しごとの時間は約54 msから100 msの間で変動するため、5回のイテレーション測定では40%低い数値を喜んで報告します。したがって、5回のウォームアップ呼び出しと30回の測定呼び出しが必要です。
array young (fresh eden allocation) 0,501 ms/op
same array, after 4x System.gc() 85,023 ms/op
100倍以上遅い - 実行ごとに130倍から160倍を測定します。
そして今回は、配列がどこに行ったかについて私の言葉を信じる必要はありません。なぜなら、それを移動させる様子を見ることができるからです。同じ実行に-Xlog:gc,gc+heap=infoを追加しました。
[0,292s][info][gc,heap] GC(0) Eden regions: 5->0(6)
[0,292s][info][gc,heap] GC(0) Survivor regions: 0->0(0)
[0,292s][info][gc,heap] GC(0) Old regions: 1->6
[0,292s][info][gc,heap] GC(0) Humongous regions: 0->0
[0,292s][info][gc ] GC(0) Pause Full (System.gc()) 38M->7M(160M) 3,769ms
[0,296s][info][gc,heap] GC(1) Old regions: 6->2
[0,303s][info][gc,heap] GC(2) Old regions: 2->1
[0,308s][info][gc,heap] GC(3) Eden regions: 0->0(1)
[0,308s][info][gc,heap] GC(3) Survivor regions: 0->0(0)
[0,308s][info][gc,heap] GC(3) Old regions: 1->1
[0,308s][info][gc,heap] GC(3) Humongous regions: 0->0
[0,308s][info][gc ] GC(3) Pause Full (System.gc()) 7M->7M(32M) 5,608ms
GC(1)とGC(2)を単一行にトリミングしました。そして、<type> regions: <from>-><to>(<max>)表記が初めての場合は、humongous記事で読み方を説明しました。
最初のサイクルが作業を行います。Eden regions: 5->0とOld regions: 1->6 - エデンにあったものすべて、私たちの配列を含めて、古い世代に退避されました。残りのサイクルは残りを圧縮するだけで、GC(3)までにヒープはOld regions: 1->1でエデンが空の状態に落ち着きました。その単一の8MB古いリージョンが、私たちの4MB配列が現在配置されている場所であり、2番目の測定が最初の測定より100倍遅い理由です。
また、すべてのサイクルでHumongous regions: 0->0に注意してください。ここには巨大オブジェクトは一切ありません。配列が配置されている世代が変わっただけです。アプリケーション内のすべての長期間生存する参照配列は、すでにこの状態になっています。
サイズによってどれだけ悪いかが決まる
これが私を驚かせた部分であり、この記事が長くなった理由です。
配列サイズを範囲で比較して、要素あたりのナノ秒を測定しました。
| 要素数 | 配列サイズ | 若い | 古い | ペナルティ |
|---|---|---|---|---|
| 256 | 1 KB | 0.50 ns | 0.92 ns | 1.8x |
| 1 024 | 4 KB | 0.50 ns | 1.08 ns | 2.2x |
| 4 096 | 16 KB | 0.51 ns | 2.23 ns | 4.4x |
| 16 384 | 64 KB | 0.50 ns | 7.24 ns | 14x |
| 65 536 | 256 KB | 0.48 ns | 29.00 ns | 60x |
| 262 144 | 1 MB | 0.48 ns | 50.56 ns | 105x |
| 1 048 568 | 4 MB | 0.48 ns | 66.53 ns | 139x |
実行されているコードはすべての行で同一です。同じ命令、同じブランチ、同じフェンス、同じ128要素あたり1カードのブックキーピング比率。配列サイズだけが変更され、ペナルティは70倍のファクターで移動します。
数十個の参照の長期間生存する配列は、実質的に何もコストがかかりません。数メガバイトの配列は、139倍のコストがかかります。
サイズが重要な理由
それを説明するために、バリアを他のすべてから分離するベースラインが必要でした。Arrays.fill(table, null)はこの目的に最適です。同じ配列、同じループ、同じメモリトラフィックですが、終了条件2を通過し、フェンスに到達しません。
これは、このマシンの16MB L2キャッシュをはるかに超えて、すべてのサイズで一定です。これは、生のストアループがサイズに関係なくメモリバウンドではないことを示しています。CPUは一度に数十個のストアを保留し、それらをキャッシュライン全体にマージし、メモリにパイプライン化します。128MBのフィルは、1KBのフィルと同じ要素あたりのコストがかかります。
それがフェンスが破壊するものなのです。
dmb ishには固定価格がありません。保留中のすべての書き込みが他のすべてのコアから見えるようになるまでコアを停止します。各ストアの後に1つのフェンスがある場合、一度に1つのストアしか保留できません。その並列性はすべて失われ、ストアあたりのコストは、そのラインを所有するキャッシュのレベルへの単一ストアの完了レイテンシになります。
- 配列がL1(このCPUでは128KB)に収まる場合 - ストアは数サイクルで完了し、フェンスはほとんどコストがかかりません:1.8x
- L2の配列 - ストアあたり数十ナノ秒:60-105x
- キャッシュを超えた配列 - そのレイテンシはすでに最大です。
したがって、曲線はメモリ階層の形状をしています。これもどこかで停止すると予測されます。そしてそれは停止します。
非常に大きな配列:飽和する
4MBを超えて続行します(これらはすべて巨大オブジェクトなので、誕生から古い世代にあります。GCログはそれぞれ1、2、4、16の巨大オブジェクトリージョンをそれぞれ確認します)。
| 要素数 | 配列サイズ | fill(mark) | fill(null) | arraycopy |
|---|---|---|---|---|
| 2 097 136 | 8 MB | 61.95 ns | 0.48 ns | 0.10 ns |
| 4 194 272 | 16 MB | 67.02 ns | 0.49 ns | 0.10 ns |
| 8 388 544 | 32 MB | 61.69 ns | 0.49 ns | 0.11 ns |
| 33 554 176 | 128 MB | 48.63 ns | 0.50 ns | 0.11 ns |
ペナルティは約50-70 ns/storeで飽和し、それ以上進みません。配列を32倍大きくしても、個々のストアはそれ以上悪化しません。128MBは、大きなサイズのなかで最も穏やかなものです。コストの合計はもちろん配列とともに増加し続けますが、線形にのみ増加します。
そして、同じサイズでのParallelGCの制御:
ParallelGC 32 MB fill(mark) 0,24 ns/store fill(null) 0,24 ns/store
ParallelGC 128 MB fill(mark) 0,24 ns/store fill(null) 0,24 ns/store
完全に平坦で、参照が実際にストアされているかどうかにかかわらず同一です。2つの無条件命令、ブランチなし、フェンスなし、遅くなるものなし - 128MBでも。
2つのボーナス効果
生成されたコードを読んでいる間に、フェンスとは関係のない2つの追加の違いに気づきました。それらはすべてのG1アプリケーションに適用されるため、知っておくと役立ちます。この病的なものだけではありません。
バリアはループ展開のコストをあなたに課す
ParallelGCのループはカウンターを8ずつインクリメントし、G1ループは1ずつインクリメントしたことを覚えていますか?それは偶然ではありません。src/hotspot/share/opto/loopTransform.cppでは、C2はループが展開されるのに十分小さいかどうかを決定します。
uint body_size = _body.size();
for (uint k = 0; k < _body.size(); k++) {
Node* n = _body.at(k);
if (MemNode::barrier_data(n) != 0) {
body_size += BarrierSet::barrier_set()->barrier_set_c2()->estimated_barrier_size(n);
}
...
}
...
if (body_size > (uint)_local_loop_unroll_limit) {
return false; // Loop too big.
}
C2は、バリアが展開された後にループ本体がどれだけ大きくなるかを推定します。G1の場合、その推定値は(g1BarrierSetC2.cpp)です。
uint G1BarrierSetC2::estimated_barrier_size(const Node* node) const {
uint8_t barrier_data = MemNode::barrier_data(node);
uint nodes = 0;
if ((barrier_data & G1C2BarrierPre) != 0) {
nodes += 6;
}
if ((barrier_data & G1C2BarrierPost) != 0) {
nodes += 60;
}
return nodes;
}
6 + 60 = 66、そしてLoopUnrollLimitはデフォルトで60です。G1参照ストアを含むループでさえ、他のすべてをカウントする前に予算を超えているため、決して展開できません。確認済みです。
G1, default LoopUnrollLimit=60 1,077 ms/op
G1, -XX:LoopUnrollLimit=200 0,826 ms/op
バリアはインライン化のコストをあなたに課す
プロファイラー出力では、ParallelGCはArrays.fillをベンチマークメソッドにインライン化しましたが、G1はそれを別のメソッドとしてコンパイルしました。理由はsrc/hotspot/share/opto/bytecodeInfo.cppにあります。
if (callee_method->has_compiled_code() &&
callee_method->inline_instructions_size() > InlineSmallCode) {
set_msg("already compiled into a big method");
return true;
}
InlineSmallCodeは2500バイトです。バリアコードはArrays.fillのスタンドアロンコンパイルをその制限を超えて肥大化させるため、C2はそれをどこにもインライン化することを拒否します。-XX:+PrintInliningは正確なメッセージを出力します。
これら2つを合わせると、配列が若い場合のG1とParallelGCの1.12 ms/op対0.50 ms/opの違い、つまり約2倍のコストがかかります。これは、G1バリアの正直で避けられないコストです。それ以上のものは古い世代の事故から来ています。
何ができるか?
このセクションのすべてはJDK 25以前に適用されます。すでにJDK 26を使用している場合、バリア自体が書き直されており、問題はほぼ消えています。記事の最後の後記を参照してください。
nullをストアする - 無料です
OLD array, Arrays.fill(mark) 55.20 ns/store
OLD array, Arrays.fill(null) 0.49 ns/store
長期間生存する配列のクリア(Arrays.fill(a, null)、ArrayList.clear()、キャッシュの無効化)は、終了条件2のため、スローパスに入りません。これは無料であり、おそらくすでに実行しています。
バルクコピーを使用する
参照配列に対するSystem.arraycopyとArrays.copyOfは、要素ごとのバリアをまったく使用しません。それらは、コピー完了後に影響を受けるカード範囲全体を一度だけダーティにするG1BarrierSetAssembler::gen_write_ref_array_post_barrierを使用します。要素ごとのバリアではなく、配列全体に対する1つのバリアです。
古典的な倍増トリックは、フィルをコピーのシーケンスに変換します。
static void fill(Object[] a, Object v) {
int len = a.length;
if (len == 0) {
return;
}
a[0] = v;
for (int i = 1; i < len; i += i) {
System.arraycopy(a, 0, a, i, Math.min(i, len - i));
}
}
4MBの古い世代配列で:
OLD Arrays.fill 55,20 ns/store
OLD arraycopy doubling 0,06 ns/store
900倍高速。同じ結果、同じ配列、同じGC。
巨大オブジェクト配列を作成しない
humongous記事で書いたすべてがまだ適用されており、これは気にするもう一つの理由です。配列が巨大オブジェクトの場合、誕生から古い世代にあり、どれだけ短命であっても、安価なバリアパスを取得することはありません。
フラグのチューニング - 注意深く
1つのフラグがこの動作を実際に変更します。いつもの警告が伴います。
-XX:G1HeapRegionSize=<size>は巨大オブジェクトの閾値を上げ、配列を古い世代から移動させることができます。また、G1がヒープ全体を管理する方法も変更するため、触る前に巨大オブジェクトの記事を読むことを忘れないでください。最大は32MBです。
そして、試したときのように、うまくいかないものがあります。-XX:+UseCompactObjectHeadersは、配列ヘッダーを16バイトから12バイトに縮小します。これは、16バイトで逃した閾値を下回るのに十分なように聞こえます。そうではありません。オブジェクトは8バイトにアラインされるため、保存された4バイトはパディングとしてそのまま返されます。
| ヘッダー | 配列サイズ | アラインメント後 | 巨大オブジェクトか? |
|---|---|---|---|
| 16 バイト | 16 + 4 194 304 = 4 194 320 | 4 194 320 | はい |
| 12 バイト | 12 + 4 194 304 = 4 194 316 | 4 194 320 | はい |
両方ともHumongous regions: 1->1と報告され、両方とも109 ms/opで実行されます。閾値がこれほど近い場合、推論ではなく測定する必要があることを思い出させてくれます。
したがって、本当に1つのフラグしかなく、私もそれには手を伸ばしません。上記のアプリケーションレベルの修正の方が優れています。humongous記事の最後に挙げた理由と同じです。コードを制御し、G1の内部を5年間理解しなくても、コードを機能させ続けることができます。
要約
- Javaのすべての参照代入は、あなたが書かず、見ることができない書き込みバリアを実行します。
- ParallelGCのバリアは2つの無条件命令です。G1のバリアは約20個で、4つのブランチとスローパスでの完全なメモリフェンスを備えています。
- ここでのすべてのリストはaarch64です。x86-64では、命令は完全に異なりますが、ロジックは同一です。同じ3つの終了条件とStoreLoad membar。したがって、これはARMのみの問題ではありません。そこでの大きさは測定していません。
- G1のバリアには3つのエスケープハッチがあります。通常のコードでは、そのうちの1つが発火します。ターゲットオブジェクトが古い世代にあり、非null参照を異なるリージョンにストアする場合、すべてが失敗し、すべてのストアでフェンスに到達します。
- 巨大オブジェクトであることは、古い世代に入るための最も速い方法に過ぎません。通常の昇格もまったく同じことを行います。長期間生存する参照配列はすでにそこにあります。
- ペナルティのサイズは、配列がキャッシュに収まるかどうかに依存します:約64KB未満では数パーセントから数倍。約4MB以上では100-140倍になり、その後飽和します。
- それに加えて、バリアはG1に参照ストアを含むループを展開する能力を永久に失わせ、インライン化も失わせる可能性があります。これはこのベンチマークでフラットに約2倍になり、どこにでも適用されます。
Arrays.fill(a, null)とSystem.arraycopyは、両方とも高コストパスを完全にバイパスします。
もう一つ:JDK 26がこれを修正します
上記のすべてはJDK 25で測定されました。JDK 26では、JEP 522: G1 GC: 同期を減らしてスループットを改善のおかげで、状況は完全に変わります。
G1は2番目のカードテーブルを取得します。アプリケーションスレッドは、GCスレッドと同期することなく独自のカードテーブルをダーティにします。GCスレッドは別のテーブルで作業します。これにより、StoreLoadフェンスが存在した理由が取り除かれ、それとともにダーティカードキューの挿入、ランタイム呼び出し、およびオフラインのスローパス全体がなくなります。バリアは直線コードに縮小されます。
ldrb w14, [x28, #0x48] ; SATB 事前バリアフラグ、変更なし
cbnz w14, <stub>
subs x15, x19, x27
csel x15, x15, xzr, hs
lsr x15, x15, #3
str w15, [x12] ; ストア
eor x14, x12, x19 ; リージョン横断か?
lsr x14, x14, #0x17
cbz x14, done
cbz x19, done ; nullをストアしているか?
lsr x14, x12, #9
ldr x13, [x28, #0x50] ; カードテーブルベース - スレッドから取得
ldrb w8, [x14, x13]
tbz w8, #0, done ; すでにダーティか?
strb wzr, [x14, x13] ; ダーティにする
done:
スタブへのジャンプなし、キューなし、そしてこの記事のポイントであるdmbなし。JDK 25では1つ、JDK 26では0個でした。コンパイルされたArrays.fillで数えました。
この記事が始まったベンチマーク:
| JDK 25 | JDK 26 | |
|---|---|---|
| G1, humongous array | 108,811 ms/op | 1,121 ms/op |
| G1, array promoted to old generation | 56,711 ms/op | 0,527 ms/op |
| ParallelGC | 0,532 ms/op | 0,520 ms/op |
そして、1.8xから139xに上昇したサイズ依存のペナルティ曲線は単純に消えました。JDK 26では、古い世代の配列は、試したすべてのサイズでストアあたりのコストが同じです(このテーブルのために、JDK 25の数値は記事の早い段階での数値と少し異なります)。
| 配列サイズ | JDK 25, old | JDK 26, old |
|---|---|---|
| 4 KB | 0.96 ns/store | 0.49 ns/store |
| 64 KB | 12.72 ns/store | 0.50 ns/store |
| 1 MB | 47.57 ns/store | 0.51 ns/store |
| 4 MB | 51.65 ns/store | 0.52 ns/store |
したがって、もしあなたが「私たちはG1を使っていて、まさにそれを行っている」と考えてこれを読んだなら、あなたの修正はコード変更ではなくアップグレードかもしれません。デフォルトで有効になっており、オンにするフラグはありません。
JEP 522を教えてくれたFrancesco Nigroに感謝します。
そしてメタレッスン、私が実際に気にかけているものです:JMHの数値はG1が265倍遅いことを私に伝えました。アセンブリはどの命令かを私に伝えました。しかし、JDKソースだけがなぜその命令があったのかを私に伝え、それから初めて私が最初に仮定したものではない本当の答えを示す実験を設計することができました。3つのレイヤーすべてが必要であり、HotSpotソースはその評判が示唆するよりもはるかに読みやすいです。バリアアセンブラは文字通りマシン命令あたり1行のC++です。