Java GC

Garbage Collection / 基礎

Java GC の基礎

ガベージコレクタは何を「ゴミ」と呼び、どうやって片付け、その間なぜアプリケーションを止めるのか。 コレクタごとの違いを読む前に必要な前提を、到達可能性・3 つの基本操作・世代仮説・停止時間の順に。

年代記を動かす → Serial GC から Generational ZGC までをフェーズごとに可視化

何を肩代わりしているのか

メモリを手で管理する言語では、確保したものを自分で返す必要がある。 これを人間がやると、三種類の失敗が必ず起きる。 返し忘れ(メモリリーク)、二度返し(double free)、 そして返した後にまだ使う(ダングリングポインタ)。

最後のひとつがとりわけ厄介で、壊れるのは「使った瞬間」ではなく、 その領域が別の用途に再利用された後の、まったく無関係に見える場所になる。 再現しないバグの温床であり、セキュリティ上の穴にもなる。

ガベージコレクタはこの三つをまとめて消す。 「もう誰からも使われていないオブジェクトを、処理系が見つけて回収する」—— やっていることはそれだけで、以降の話はすべて「どうやって見つけるか」と 「いつ、どれだけアプリを止めて片付けるか」のバリエーションでしかない。

代償 タダではない。GC は CPU 時間を食い、メモリを余分に要求し、 ときどきアプリケーションを止める。この三つをどう配分するかがコレクタの設計思想そのもので、 年代記で見る 25 年の変遷は、配分の重心が動いていく歴史でもある。

「ゴミ」の定義は到達可能性

直感的には「もう使わないオブジェクト」がゴミだが、処理系に未来は分からない。 そこで実際に使われる定義はこうなる—— GC Roots から参照をたどって到達できないものは、二度と使われない。 プログラムがオブジェクトに触る手段は参照しかないので、辿り着けないなら触れようがない。

GC Roots は「確実に生きている」と断言できる出発点の集合で、実行中のスレッドの スタックフレームにあるローカル変数、クラスの static フィールド、JNI の参照、 同期に使われているモニタなどが入る。

GC ROOTS main() stack frame AppContext static field Thread-3 stack frame Request Session User Order Task Queue 到達不能 — 互いに参照し合っていてもゴミ Matcher Token 到達可能 — 生存として扱う
参照の数ではなく、ルートからの経路の有無で決まる。 右下の Matcher と Token は互いを指し合っているので参照カウントは 1 以上のまま。 それでもルートからの経路がないので回収される。参照カウント方式が苦手とする循環参照を、 トレース方式は特別扱いなしに片付けられる。

逆に言えば、ルートから辿れてしまうものは、使う気がなくても回収されない。 Java のメモリリークはほぼこの形で、static なコレクションやキャッシュ、 解除し忘れたリスナー、閉じ忘れた ThreadLocal が典型になる。 GC のバグではなく、参照が残っているという事実がすべて。

片付け方は 3 種類しかない

生きているものが分かった後、実際に領域を再利用可能にする操作は、 突き詰めると次の 3 つの組み合わせになる。どのコレクタも、 世代や領域ごとにこれらを選んで貼り合わせているだけと言ってよい。

マーク・スイープMark & Sweep

BEFORE A B C D AFTER A B C D 空きが飛び飛びに残る

印を付け、付かなかった領域を空きリストへ返す。生きているものは 1 バイトも動かさない。

代償:断片化。合計は空いているのに連続領域が取れず、大きな配列が入らなくなる。

マーク・コンパクトMark & Compact

BEFORE A B C D AFTER A B C D 空きが一続きになる

スイープに加えて、生き残りを端へ詰め直す。次の割り当てはポインタを進めるだけで済む。

代償:生きている全オブジェクトを移動し、それを指す参照を全部書き換える。重い。

コピーCopying

FROM A B C TO A B C FROM 側は中身を見ずに丸ごと空へ

生存者だけを別の領域へ写し、元の領域はまとめて捨てる。ゴミを一つずつ処理する手間がない。

代償:コピー先の空き領域を常に確保しておく必要がある。メモリを使う。

コピーが速い理由 コストが「生きている量」だけに比例し、ゴミの量には一切依存しない。 Eden の 95% がゴミなら、仕事は残り 5% 分で終わる。 この性質が、次の世代仮説と組み合わさって効いてくる。

ほとんどのオブジェクトは若くして死ぬ

実際のプログラムのオブジェクトの寿命を測ると、極端に偏った分布になる。 ループの中間結果、文字列連結の一時オブジェクト、イテレータ、ボクシングされた数値—— 大半は生まれてすぐ誰からも参照されなくなる。一方で、一度長生きしたものは その後も長生きしやすい(設定オブジェクト、キャッシュ、コネクションプール)。

これを弱世代仮説(weak generational hypothesis)と呼び、 Java の GC は 20 年以上この観測に賭けて設計されてきた。

100% 75% 50% 25% 0% 最初の Minor GC を越えるのは 1 割ほど = Eden の大半は「コピーせずに捨てられる」 ここまで生き延びたものは、その後も生き延びる 0 3 6 9 12 15 Minor GC を生き延びた回数(オブジェクトの年齢)
概念図。正確な比率はアプリケーションによって大きく変わるが、 「最初の一回で大半が消え、残りは粘る」という形はよく再現する。 縦軸は割り当てられたオブジェクトのうち、その年齢まで生存している割合。

この形が分かっているなら、ヒープ全体を毎回調べるのは無駄が多い。 そこでヒープを若い世代(Young)と古い世代(Old)に分け、 若い世代だけを高い頻度で、コピー方式で回収する。ほとんどがゴミなので、 コピーすべき生存者はごくわずかで済む。

若い世代はさらに Eden(新規割り当て先)と 2 つの Survivor に分かれる。 Eden が埋まると Minor GC が走り、生存者は Survivor へコピーされて年齢が 1 増える。 規定の年齢(-XX:MaxTenuringThreshold、既定 15)を超えたものは Old 世代へ昇格する。

[0.412s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 246M->24M(512M) 8.431ms
  1. 0.412sJVM 起動からの経過時間
  2. GC(3)通算 4 回目の GC(0 始まり)
  3. Pause Young停止を伴う若い世代の回収
  4. 246M->24M(512M)回収前 → 回収後の使用量(ヒープ全体 512M)
  5. 8.431msアプリが止まっていた時間

-Xlog:gc(JDK 9 以降)を付けると、この 1 行が GC のたびに出る。 222M 減って 8.4ms——これが実際に読みたい数字で、 推測より先に、まずこのログを出すのが定石になる。

Minor / Major / Full の区別 Minor GC(Young GC)は若い世代だけ。Major GC は Old 世代の回収を指す。 Full GC はヒープ全体+メタスペースを対象にした、最も重い停止。 どのコレクタでも Full GC は「避けたいもの」で、頻発しているなら設定かアプリ側に原因がある。

世代を分けると新しい問題が出る

若い世代だけを回収したい。しかし「Old 世代のオブジェクトが Young のオブジェクトを指している」 場合、Young だけを見ると生存者を見落としてしまう。 かといって、それを探すために Old 世代を全部走査したら、世代を分けた意味がない。

そこで、参照を書き換える瞬間に記録しておく。 フィールドへの代入に短いコードを差し込む仕組みを書き込みバリア(write barrier)と呼び、 「Old → Young の参照が作られた」という事実を、カードテーブル(ヒープを一定サイズの カードに区切ったビットマップ)や Remembered Set に残す。 Minor GC はルートに加えて、この記録だけを追加で見ればよくなる。

同じ発想は世代以外にも効く。G1 はリージョンごとに Remembered Set(自分を指しているのは誰か)を持つので、ヒープの一部だけを独立して回収できる。 ZGC と Shenandoah は読み込み側にバリアを置き、 参照を読んだ瞬間に「このオブジェクトは移動済みか」を判定して、必要なら参照をその場で直す。 コピー中でもアプリを動かし続けられるのは、この読み込みバリアのおかげ。

バリアはタダではない すべての参照の書き込み(あるいは読み出し)に数命令が乗る。 停止時間の短いコレクタは、この定常的なコストを払って、停止という一括払いを避けている。 スループットを最優先するなら、むしろ停止を受け入れた方が速い——という選択が成立するのはこのため。

なぜ止める必要があるのか

オブジェクトグラフを辿っている最中に、アプリケーションが参照を書き換えたらどうなるか。 すでに「ゴミ」と判定した先に新しい参照が生まれると、生きているオブジェクトを回収してしまう—— これは即座にクラッシュを意味する。

いちばん単純な解決は、全部止めてから作業すること。これが Stop-the-World(STW)で、すべてのアプリケーションスレッドを 安全点(safepoint)——スタックの状態を正確に読み取れる決まった場所——まで走らせてから停止させる。

止めてから回収する(Serial / Parallel) アプリ GC この間、アプリは 1 命令も進まない 動かしたまま回収する(ZGC / Shenandoah) アプリ GC
赤ハッチ=アプリが止まっている区間、青=アプリと同時に GC が動いている区間。 並行方式でも停止はゼロにならない。ルートを触る一瞬だけは止める必要があり、 そこを極小に抑え込めるかどうかがコレクタの世代交代の焦点だった。 年代記では、この帯をコレクタごとに実寸感で比較できる。
停止要求から実際に止まるまで(TTSP) 止めると決めても、各スレッドが次の安全点に着くまでは止まれない。 安全点チェックを省略された長いループ(カウンタが int の単純なループなど)があると、 GC 本体は速いのに停止だけが伸びる。これを Time To Safe Point と呼び、 -Xlog:safepoint で確認できる。

3 つは同時に最大化できない

コレクタを選ぶときに見る軸は 3 つある。 スループット(総実行時間のうちアプリの仕事に使えた割合)、 レイテンシ(最悪の停止時間)、 フットプリント(GC のために余分に必要なメモリと CPU)。 どれかを上げると、ほぼ必ず他のどれかが下がる。

スループット . 停止時間の短さ 省メモリ・省 CPU Parallel GC Serial GC G1 GC ZGC / Shenandoah
頂点に近いほど、その軸に振っている。 Parallel GC は停止を受け入れる代わりに総スループットが高く、 ZGC は停止を 1ms 未満に抑える代わりにバリアの実行コストと追加メモリを払う。 Serial GC は GC スレッドもデータ構造も最小限で、小さなヒープでは実質いちばん軽い。 「いちばん良いコレクタ」は存在せず、どの軸を諦めるかの選択しかない。

実際の選択は、多くの場合そう複雑ではない。 既定の G1 のままで要件を満たすならそれでよく、 バッチ処理でスループットが欲しいなら Parallel、 応答時間の裾(p99)が問題になっていて、かつヒープが大きいなら ZGC。 コンテナに小さなヒープを詰め込むなら Serial が妥当なこともある。

そして、その前にまず -Xlog:gc を出すこと。 止まっているのが本当に GC なのか、それとも別の何かなのかは、測らないと分からない。

他の言語はどう解いたか

ここまでの戦略は Java に固有のものではない。どの処理系も同じ問題を解いていて、 違うのは「どの軸を諦めたか」だけ。Java のコレクタを地図の目印にすると、 他の言語の GC も同じ平面の上に置ける。

戦略の核Java同じ道を選んだ処理系
世代別+停止コピー若い世代は止めてコピー、古い世代は詰め直す Serial GC
Parallel GC
V8(Young は from / to 二面式のスカベンジャー、Old は Mark-Compact)、Dart、JavaScriptCore。 最も広く採用されている構成で、Java の Eden / Survivor はこの semi-space を 3 分割した変種にあたる。
並行マーク+非移動スイープ停止は短いが、動かさないので断片化する CMSJDK 14 で削除 Go(三色マーキング+ハイブリッド書き込みバリア。ただし非世代別)、 Ruby(世代別+インクリメンタルマーク)、 GHC の --nonmoving-gc、Lua。 Java では消えた方式が、他の処理系では現役で使われている。
リージョン分割+部分回収ヒープを区画に割り、回収する区画を選ぶ G1 GC .NET(7 以降、従来の segment からリージョン方式へ移行)。 Remembered Set の実装コストが高く、採用しているランタイムは意外と少ない。
並行退避+ロードバリアコピー中もアプリケーションを止めない ZGC
Shenandoah
Azul C4(同じく JVM)がほぼ唯一の先行事例。 「移動しながら走らせる」までやり切った処理系は他にほとんどなく、 この領域は事実上 Java が最先端。
GC を持たない解放位置をコンパイル時か人手で決める Epsilon GC計測用 Rust(所有権とライフタイム)、C / C++、Zig。 Epsilon は「GC が無いとどうなるか」を測るための基準線だが、 これらの言語にとっては最初からそれが日常。

表に収まらないのが、Java が最初から採らなかった道を選んだ処理系たち。 トレース方式そのものを使わない、あるいはヒープの持ち方から変えてしまう設計になる。

参照カウントCPython / PHP / Swift

参照が増減するたびにカウンタを更新し、ゼロになった時点で即座に解放する。 停止時間は原理的に発生せず、オブジェクトの死が決定的なタイミングで起きる。

代償:循環参照が漏れる。CPython と PHP は循環専用のトレーサを別に持ち、 Swift の ARC は weak / unowned で開発者に切らせる。

ヒープを共有しないErlang / BEAM

軽量プロセスごとに独立したヒープを持ち、それぞれが自分の中だけで世代別コピー GC を走らせる。 止まるのはそのプロセス 1 つだけで、システム全体の STW が存在しない。

代償:プロセス間はコピーで受け渡す。共有メモリ前提の言語には持ち込めない設計。

保守的 GCBoehm / Unity

どこが参照かの正確な情報を持たず、スタック上の値を「ポインタかもしれない」と見なして走査する。 処理系を選ばず後付けできるのが強み。

代償:参照か整数か確定できないのでオブジェクトを動かせない。 コンパクションが原理的に不可能になる。

Ruby は Java と同じ道を歩いた 世代別+非移動で始め、断片化に向き合って 2.7 で GC.compact(手動)、 3.0 で自動コンパクションを導入した。 CMS の断片化に悩んで G1 へ移った Java の歴史と、ほぼ同じ順序をたどっている。 面白いのは書き込みバリアの入れ方で、既存の C 拡張との互換のために 「バリアで保護されたオブジェクト」と「されていないオブジェクト」を共存させながら移行した。
Go は世代別を試して降りた Go は非世代別・非移動を貫いている。エスケープ解析でスタックに載る値が多く、 世代別にしても得られる利得が書き込みバリアのコストに見合わなかった、というのが大きい (リクエスト単位の世代別 GC も試作されたが本流にはならなかった)。 断片化には、サイズクラスごとに区画を分ける割り当て方式で対処している。 「世代別は常に正しい」わけではないという、Java 側から見ると示唆的な例。

用語

mutatorミューテータ
アプリケーションスレッドのこと。GC から見ると「オブジェクトグラフを書き換えてくる相手」なのでこう呼ぶ。
GC Roots
到達可能性を判定する出発点。スタックのローカル変数、static フィールド、JNI 参照、モニタなど。
Eden
新しいオブジェクトが最初に置かれる領域。ここが埋まると Minor GC が起きる。
SurvivorS0 / S1
Minor GC を生き延びたオブジェクトが一時的に移る 2 つの領域。交互に from / to の役を入れ替える。
昇格promotion
年齢が閾値を超えたオブジェクトを Old 世代へ移すこと。Survivor が溢れた場合は年齢に関係なく昇格する。
TLABThread Local Allocation Buffer
各スレッドが Eden から切り出す専用の割り当て区画。ロックなしで割り当てられる。
断片化fragmentation
空き領域が細切れに散らばり、合計は足りているのに連続領域が確保できない状態。
コンパクションcompaction
生存オブジェクトを詰め直して空きを一続きにすること。断片化の唯一の根本解決。
safepoint安全点
スレッドの状態を正確に読み取れる実行位置。STW はすべてのスレッドがここに着いてから始まる。
書き込みバリアwrite barrier
参照の代入時に差し込まれる短いコード。世代間参照やマーク中の変更を記録する。
読み込みバリアload barrier
参照の読み出し時に差し込まれる処理。移動済みオブジェクトの参照をその場で修正でき、並行退避を可能にする。
カードテーブルcard table
ヒープを一定サイズのカードに区切り、参照が書き換えられたカードに印を付けるビットマップ。
Remembered SetRSet
「自分を指している参照はどこにあるか」をリージョンごとに覚えておく構造。部分回収の前提になる。
SATBSnapshot-At-The-Beginning
マーク開始時点のグラフを論理的なスナップショットとして扱う方式。途中で切れた参照も保守的に生存とみなす。
回収集合CSet / Collection Set
その回で実際に回収する対象として選ばれたリージョンの集合。
リージョンregion
G1 以降が採用する、ヒープを分割した等サイズの区画。世代は物理的な境界ではなく区画の役割になる。
Humongous
リージョン 1 個に収まらない巨大なオブジェクト。連続したリージョンをまたいで確保される。
Full GC
ヒープ全体を対象とする最も重い回収。多くのコレクタでは「失敗したときの最後の手段」に位置付けられている。

Java GC 年代記

ここまでの概念が、実際のコレクタでどう組み合わさっているのか。 Serial GC から Generational ZGC までの 8 つを、ヒープの可視化と Stop-the-World のタイムラインで 1 フェーズずつ動かして確かめる。

年代記を開く →