なぜ GC は世界を止めるのか?「ほとんどのオブジェクトは若くして死ぬ」に賭けた世代別 GC
昨日の p99 レイテンシのグラフに、ほんの数十ミリ秒だけ跳ねた箇所があったはずだ。ダッシュボードが「GC」とだけ教えてくれても、それ以上を説明できるだろうか。ガベージコレクタがなぜアプリケーションを一瞬止める必要があるのか、そして「世代」で分けるという設計判断が何に賭けているのかを見ていく。
- GC がアプリケーションを一時停止させる(Stop-the-World)のは、生死判定の最中にアプリ側が参照を書き換えると誤って生きたオブジェクトを回収しかねないためで、並行化が進んだ今もこの区間自体はゼロにできない
- 世代別 GC は「オブジェクトの大半は生成直後に短命で死ぬ」という経験則(世代仮説)に賭け、新しい世代だけを頻繁・軽量に掃除し、生き残ったものだけを古い世代へ昇格させて滅多に掃除しないことで全体のコストを下げる
- Go の
GOGCや JVM G1 の-XX:MaxGCPauseMillisのような GC のチューニング値は、どれも「メモリ使用量」と「CPU コスト・停止時間」のどちらに寄せるかを調整するノブにすぎない
GC が「世界を止める」理由
GC の基本アルゴリズムは mark-and-sweep(マーク&スイープ)と呼ばれる。スタック上のローカル変数やグローバル変数など、プログラムが直接参照している起点をGC ルートと呼び、そこから辿れる(reachable な)オブジェクトすべてに印をつける(mark)。印のつかなかったオブジェクトは、もうどこからも参照されていないので回収する(sweep)。ここまでは単純な話だ。
問題は、この mark 作業がヒープ全体を舐めるのにそれなりの時間がかかることだ。その間もアプリケーション(GC の世界ではmutatorと呼ぶ)が動き続け、参照をどんどん書き換えたらどうなるか。GC がすでに「死んでいる」と判定した後に、mutator がそのオブジェクトへの参照を新しく作ってしまうと、GC はそれに気づけないまま回収してしまう。生きているオブジェクトが消えるという致命的なバグだ。これを構造的に防ぐ最も単純な方法が、mark の間だけ mutator を完全に止めてしまうこと、つまりStop-the-World(STW)である。
世代別 GC の賭け:ほとんどのオブジェクトは若くして死ぬ
ヒープ全体を毎回止めて舐めるのは重い。そこで多くの処理系が採用しているのが世代仮説(Generational Hypothesis、大半のオブジェクトは生成直後に短命で死ぬという経験則)に基づく世代別 GC だ。ヒープを「生まれたばかりのオブジェクトを置く新しい世代」と「長く生き残ったオブジェクトを置く古い世代」に分け、新しい世代だけを高頻度・低コストで掃除するマイナー GCと、古い世代まで含めて掃除する低頻度・高コストのメジャー GCを使い分ける。
┌─────────── Young ────────────┐
│ 新規オブジェクトが生まれる場所 │
└───────────────┬──────────────┘
Minor GC(頻繁・短) │ 生存2回で昇格
▼
┌────── Old ───────┐
│ 長寿命object │
└──────────────────┘
Major GC(稀・長時間)
例えば V8(Node.js や Chrome の JS エンジン)では、新しい世代のオブジェクトを回収するScavengeという処理を2回生き延びたオブジェクトだけが古い世代へ昇格する。新しい世代はほとんどのオブジェクトが数回のうちに消えていく前提で小さく作られており、だからこそ頻繁に掃除しても短時間で終わる。この非対称性(新しい世代は頻繁・軽量、古い世代は稀・重い)が世代別 GC 全体のコスト削減の核だ。
図書館の「新着コーナー」を思い浮かべるとよい。返却された本はまず新着棚に並び、そこは毎日のように棚卸しされて、借り手のつかない本はすぐ除籍される。ほとんどの本は数日のうちにここで消えていく。ごくまれに何度も借りられ続けて生き残った本だけが、奥の書庫(古い世代)へ移される。書庫は滅多に棚卸ししない。ずらりと並んだ本を毎回全部チェックするのは大仕事だが、書庫の本は長期間そこにとどまる可能性が高いとわかっているので、頻度を下げても実害が小さいからだ。世代別 GC が賭けているのは、この「新着棚はよく死に、書庫はめったに動かない」という偏りが現実のプログラムでも成り立つという経験則である。
並行化しても停止はゼロにならない:Go の場合
Go のランタイムは世代別 GC ではなく、並行(concurrent)なマーク&スイープを採用している。mark フェーズの大半はアプリケーションと並行に、少しずつ進める。これを安全に行う鍵がライトバリア(Write Barrier、mutator が参照を書き換えるたびに GC へ通知する仕組み)で、並行中に生まれた新しい参照を見逃さないようにする。それでもフェーズの切り替え(mark の開始・終了)ではルートの再スキャンなど安全に並行化できない作業が残るため、ごく短い STW がその前後に挟まる。「並行 GC にすれば停止はゼロになる」わけではなく、止めなければならない区間を最小限に削っているにすぎない。
Go で GC の頻度とメモリ使用量のトレードオフを調整するノブがGOGCだ。目標ヒープサイズは 生存ヒープ + (生存ヒープ + GC ルート) × GOGC / 100 という式で決まり、デフォルトの GOGC=100 では生存データの約2倍まで増えたところで次の GC が走る。
# GOGC を上げる = GC 頻度を下げてメモリを多めに使う GOGC=200 go run main.go # GOGC を下げる = GC 頻度を上げてメモリを詰める GOGC=50 go run main.go
GOGC を大きくすると次の GC までの猶予が増えて CPU コストは下がるが、ピークメモリ使用量も比例して増える。逆に小さくすると GC が頻繁に走って CPU を食うぶん、ピークメモリは抑えられる。何も設定しなければ GOGC=100 のまま動き、コンテナのメモリ上限が厳しい環境では「デフォルトのままだと GC が間に合わずメモリ上限に張り付く」という事態が起こりうる。
もう一つの解き方:リージョンと目標停止時間 — JVM G1 の場合
JVM の G1 GC は世代別かつ、ヒープを均等サイズの小さなリージョン(1〜32MB、概ね2048個程度になるよう自動調整される)に分割する設計を取る。Eden・Survivor・Old といった世代は、連続領域ではなくこのリージョンの集合として論理的に管理される。G1 の特徴は、単に世代を分けるだけでなく「1回の停止をこの時間以内に収める」という目標時間を先に決め、その目標に収まるようリージョンの掃除量を毎回調整する点にある。
# 1 回の GC 停止の目標を 200ms から 100ms に縮める -XX:MaxGCPauseMillis=100
MaxGCPauseMillis のデフォルトは200ミリ秒で、これは「必ず守られる上限」ではなく JVM が最善を尽くすソフトな目標値にすぎない。値を小さくすると G1 は1回あたりに掃除するリージョン数を減らして目標に近づけようとするが、その分 GC の実行回数自体は増えてスループットが落ちる。逆に大きくすれば1回で多くのリージョンをまとめて処理できてスループットは上がるが、個々の停止は長くなる。なお -Xmn などで新しい世代のサイズを固定してしまうと、この目標時間に基づく自動調整そのものが働かなくなる。
コンテナのメモリ limit ぎりぎりで動かしている JVM や Node.js アプリで、負荷が上がった途端に p99 レイテンシだけが跳ねる現象の正体はたいてい GC だ。ヒープが上限に近づくほど GC は「間に合わせるため」に頻度を上げ、場合によってはメジャー GC(あるいは G1 の mixed GC)が挟まって数十〜数百ミリ秒の停止を生む。監視ダッシュボードに GC 時間・GC 回数のメトリクスが出ていたら、それはレイテンシスパイクの原因切り分けで真っ先に見る場所になる。
Node.js の --trace-gc フラグで、若い世代を掃除する Scavenge(マイナー GC)と古い世代を掃除する Mark-sweep(メジャー GC)が別々の頻度・所要時間で走る様子を実際に見る。
node --trace-gc -e "
let a = [];
for (let i = 0; i < 5000000; i++) {
a.push({ v: i });
if (a.length > 2000) a = [];
}
"
Scavenge 行が数十〜数百回、1ミリ秒未満〜数ミリ秒というペースで大量に流れるはずだ。時おり Mark-sweep 行が混ざり、そちらは所要時間の桁が一つ大きいことが確認できる。X (Y) -> Z (W) MB の部分がその GC 前後のヒープ使用量で、頻繁に呼ばれる Scavenge の削減量が小さくても、それこそが「ほとんどが若くして死ぬ」を前提にした軽量な掃除だという証拠になる。
- GC があるからメモリリークの心配はいらない — GC が回収できるのは「どこからも参照されていない」オブジェクトだけだ。使い終わったのに参照が残ったままのキャッシュやイベントリスナーは reachable であり続けるため回収されず、いわゆる論理的なメモリリークが起こる。
- 並行 GC(concurrent GC)にすれば停止時間はゼロになる — mark 作業の大半は並行化できても、フェーズの切り替えやルートの再スキャンなど mutator を安全に止めないとできない区間は残る。並行化は STW をゼロにするのではなく、短く削るための工夫だ。
- 世代別 GC はメモリを世代ごとに物理的に区切って確保しているだけ — 本質は「生存確率の異なる領域を分けて掃除頻度を変える」ことにある。新しい世代は死亡率が高い前提で頻繁かつ軽量に、古い世代は生存が確定的な前提で稀にしか掃除しない。
- GC ルート
- スタック上のローカル変数やグローバル変数など、プログラムが直接参照する起点。ここから辿れないオブジェクトが回収対象になる。
- Stop-the-World
- GC がヒープの整合性を保つため、アプリケーションスレッドの実行を一時的に停止させる区間。
- 世代仮説
- 大半のオブジェクトは生成直後に短命で死ぬ、という経験則。世代別 GC の設計根拠になっている。
- マイナー GC / メジャー GC
- 新しい世代だけを回収する頻繁で軽量な GC と、古い世代まで含めて回収する稀で重い GC。
- ライトバリア
- 並行 GC 中にオブジェクト間の参照が書き換えられたことを GC に知らせ、誤回収を防ぐ仕組み。
- A Guide to the Go Garbage Collector — GOGC の目標ヒープサイズの計算式や GOMEMLIMIT との関係を、Go 公式が一次情報として解説する。
- Garbage-First Garbage Collector Tuning — MaxGCPauseMillis やリージョン設計、young/old GC の挙動を定義する Oracle 公式のチューニングガイド。
- Tracing garbage collection | Node.js —
--trace-gcの出力の読み方と、Scavenge が2回で old space へ昇格させる仕組みを解説する Node.js 公式ドキュメント。