JITとGC — 実行しながら速くする・掃除する
実行時コンパイラ(JIT)とガベージコレクタ(GC)は、どちらもアプリを止めずに裏で働く仕事です。ホットスポット検出・段階コンパイル・インライン化・投機と脱最適化・世代別GC・三色マーキング・ライトバリアを前提知識ゼロから積み上げ、最後はGCログとJITログを自分で読めるところまで。
開店したまま厨房を組み替える
人気の定食屋を想像してください。開店前に全メニューを均等に仕込むのが事前コンパイル(AOT)です。ところが営業を始めると、注文の八割が三品に集中していると分かります。
JIT(Just-In-Time コンパイラ)は、営業中にその三品だけを専用ラインへ組み替える仕事。GC(ガベージコレクタ)は、営業を止めずに皿を下げて席を空け続ける仕事です。教科書では別々の章に置かれますが、悩みは同じです。どちらも店を閉めずにやらなければならない。だから両方とも「いつ・どれだけ手をつけるか」という予算配分の問題になります。
ソースコードに書いていない情報
コンパイラを1からで見たとおり、コンパイラは全体を先に見渡せる立場にいます。それでも手に入らない情報があります。
- どの関数が、何回呼ばれるのか
ifのどちら側が、実際にはほぼ通るのか- この変数に実際に入るクラスは、何種類あるのか
- このオブジェクトは、関数の外へ漏れるのか
どれも入力データ次第で決まるので、ソースを何度読んでも書いてありません。実行時コンパイラは走らせながらこれを実測できる代わりに、コンパイルにかかった時間がそのままアプリの実行時間に混ざる重荷を負います。
ホットスポット — 時間はごく一部に偏る
実行時間はメソッドに均等には散りません。数千のメソッドを持つアプリでも、CPU時間の大半はごく少数のループに落ちます。この偏りこそがJITの前提です。あるメソッドをコンパイルすべきかは、一本の不等式で書けます。
は今後そのメソッドが実行される回数、 はインタプリタで1回動かす時間、 はコンパイル後の速度倍率、 はコンパイル自体に一度だけかかる費用です。要するに「これから節約できる合計が、いま払うコンパイル代を上回るならやる価値がある」と言っているだけです。
実行時に安く測れるのは だけです。だからランタイムはメソッドごとに呼び出し回数カウンタを持ち、閾値を超えたものを待ち行列に積みます。HotSpot JVM の名前は、この「熱い場所を探す」やり方から来ています。
段階を分ける — Tiered Compilation
閾値がひとつだと困ります。低くすればコンパイル待ちで起動が重くなり、高くすればいつまでも遅いままです。答えは段階を刻むことでした。HotSpot はレベル0がインタプリタ、1〜3がC1、4がC2です。C1は速く出す代わりに控えめに最適化し、C2は時間をかけて攻めます。C1コードにはプロファイル計測が仕込んであるので、C2の番が回るころには「この分岐は99%こっち」という統計が溜まっています。V8 も Ignition→Sparkplug→Maglev→TurboFan と段を積んでいます。
閾値だけでは取りこぼす形もあります。一度しか呼ばれない巨大なループです。呼び出し回数は1のまま動きません。そこでループの後ろ向き分岐にもカウンタを置き、回っている最中にフレームごとコンパイル済みコードへ乗り換えます。これが On-Stack Replacement(OSR)です。
インライン化が、すべての最適化の親
インライン化は「呼び出し先の中身を呼び出し元に埋め込む」変換です。数ナノ秒の呼び出し費用を節約するためだと思われがちですが、本体は埋め込んだ後に何ができるようになるかのほうです。定数の畳み込み、通らない分岐の削除、ループ不変式の巻き上げ、後述するエスケープ解析が、関数の境界をまたいで効くようになります。
Javaのメソッドは既定で仮想呼び出しなので、そのままでは呼び先が決まりません。ここで実行時プロファイルが効きます。「この地点に来たのは全部 ArrayList だった」と分かっていれば、クラスチェック1回のガードを置いて一つに賭け、インライン化できます。見えた型が1種類なら単形(monomorphic)、3種類以上になると多形(megamorphic)として諦め、普通の仮想呼び出しに戻ります。
ただし無制限には展開できません。深さまで各段個を展開すると、コード量はおおよそで膨らみます。
だからJITには -XX:MaxInlineSize(小さいメソッドを無条件に展開する上限)や -XX:FreqInlineSize(熱いメソッドに許す上限)という予算のつまみがあります。キャッシュに収まるかどうかで何倍も差がつく話はキャッシュに優しいコードにあります。
コメント
コメントにはログインが必要です