Garbage Collection / 基礎
ガベージコレクタは何を「ゴミ」と呼び、どうやって片付け、その間なぜアプリケーションを止めるのか。 コレクタごとの違いを読む前に必要な前提を、到達可能性・3 つの基本操作・世代仮説・停止時間の順に。
メモリを手で管理する言語では、確保したものを自分で返す必要がある。 これを人間がやると、三種類の失敗が必ず起きる。 返し忘れ(メモリリーク)、二度返し(double free)、 そして返した後にまだ使う(ダングリングポインタ)。
最後のひとつがとりわけ厄介で、壊れるのは「使った瞬間」ではなく、 その領域が別の用途に再利用された後の、まったく無関係に見える場所になる。 再現しないバグの温床であり、セキュリティ上の穴にもなる。
ガベージコレクタはこの三つをまとめて消す。 「もう誰からも使われていないオブジェクトを、処理系が見つけて回収する」—— やっていることはそれだけで、以降の話はすべて「どうやって見つけるか」と 「いつ、どれだけアプリを止めて片付けるか」のバリエーションでしかない。
直感的には「もう使わないオブジェクト」がゴミだが、処理系に未来は分からない。 そこで実際に使われる定義はこうなる—— GC Roots から参照をたどって到達できないものは、二度と使われない。 プログラムがオブジェクトに触る手段は参照しかないので、辿り着けないなら触れようがない。
GC Roots は「確実に生きている」と断言できる出発点の集合で、実行中のスレッドの スタックフレームにあるローカル変数、クラスの static フィールド、JNI の参照、 同期に使われているモニタなどが入る。
Matcher と Token は互いを指し合っているので参照カウントは 1 以上のまま。
それでもルートからの経路がないので回収される。参照カウント方式が苦手とする循環参照を、
トレース方式は特別扱いなしに片付けられる。
逆に言えば、ルートから辿れてしまうものは、使う気がなくても回収されない。
Java のメモリリークはほぼこの形で、static なコレクションやキャッシュ、
解除し忘れたリスナー、閉じ忘れた ThreadLocal が典型になる。
GC のバグではなく、参照が残っているという事実がすべて。
生きているものが分かった後、実際に領域を再利用可能にする操作は、 突き詰めると次の 3 つの組み合わせになる。どのコレクタも、 世代や領域ごとにこれらを選んで貼り合わせているだけと言ってよい。
印を付け、付かなかった領域を空きリストへ返す。生きているものは 1 バイトも動かさない。
代償:断片化。合計は空いているのに連続領域が取れず、大きな配列が入らなくなる。
スイープに加えて、生き残りを端へ詰め直す。次の割り当てはポインタを進めるだけで済む。
代償:生きている全オブジェクトを移動し、それを指す参照を全部書き換える。重い。
生存者だけを別の領域へ写し、元の領域はまとめて捨てる。ゴミを一つずつ処理する手間がない。
代償:コピー先の空き領域を常に確保しておく必要がある。メモリを使う。
実際のプログラムのオブジェクトの寿命を測ると、極端に偏った分布になる。 ループの中間結果、文字列連結の一時オブジェクト、イテレータ、ボクシングされた数値—— 大半は生まれてすぐ誰からも参照されなくなる。一方で、一度長生きしたものは その後も長生きしやすい(設定オブジェクト、キャッシュ、コネクションプール)。
これを弱世代仮説(weak generational hypothesis)と呼び、 Java の GC は 20 年以上この観測に賭けて設計されてきた。
この形が分かっているなら、ヒープ全体を毎回調べるのは無駄が多い。 そこでヒープを若い世代(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
0.412sJVM 起動からの経過時間GC(3)通算 4 回目の GC(0 始まり)Pause Young停止を伴う若い世代の回収246M->24M(512M)回収前 → 回収後の使用量(ヒープ全体 512M)8.431msアプリが止まっていた時間
-Xlog:gc(JDK 9 以降)を付けると、この 1 行が GC のたびに出る。
222M 減って 8.4ms——これが実際に読みたい数字で、
推測より先に、まずこのログを出すのが定石になる。
若い世代だけを回収したい。しかし「Old 世代のオブジェクトが Young のオブジェクトを指している」 場合、Young だけを見ると生存者を見落としてしまう。 かといって、それを探すために Old 世代を全部走査したら、世代を分けた意味がない。
そこで、参照を書き換える瞬間に記録しておく。 フィールドへの代入に短いコードを差し込む仕組みを書き込みバリア(write barrier)と呼び、 「Old → Young の参照が作られた」という事実を、カードテーブル(ヒープを一定サイズの カードに区切ったビットマップ)や Remembered Set に残す。 Minor GC はルートに加えて、この記録だけを追加で見ればよくなる。
同じ発想は世代以外にも効く。G1 はリージョンごとに Remembered Set(自分を指しているのは誰か)を持つので、ヒープの一部だけを独立して回収できる。 ZGC と Shenandoah は読み込み側にバリアを置き、 参照を読んだ瞬間に「このオブジェクトは移動済みか」を判定して、必要なら参照をその場で直す。 コピー中でもアプリを動かし続けられるのは、この読み込みバリアのおかげ。
オブジェクトグラフを辿っている最中に、アプリケーションが参照を書き換えたらどうなるか。 すでに「ゴミ」と判定した先に新しい参照が生まれると、生きているオブジェクトを回収してしまう—— これは即座にクラッシュを意味する。
いちばん単純な解決は、全部止めてから作業すること。これが Stop-the-World(STW)で、すべてのアプリケーションスレッドを 安全点(safepoint)——スタックの状態を正確に読み取れる決まった場所——まで走らせてから停止させる。
int の単純なループなど)があると、
GC 本体は速いのに停止だけが伸びる。これを Time To Safe Point と呼び、
-Xlog:safepoint で確認できる。
コレクタを選ぶときに見る軸は 3 つある。 スループット(総実行時間のうちアプリの仕事に使えた割合)、 レイテンシ(最悪の停止時間)、 フットプリント(GC のために余分に必要なメモリと CPU)。 どれかを上げると、ほぼ必ず他のどれかが下がる。
実際の選択は、多くの場合そう複雑ではない。 既定の 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 の ARC は weak / unowned で開発者に切らせる。
軽量プロセスごとに独立したヒープを持ち、それぞれが自分の中だけで世代別コピー GC を走らせる。 止まるのはそのプロセス 1 つだけで、システム全体の STW が存在しない。
代償:プロセス間はコピーで受け渡す。共有メモリ前提の言語には持ち込めない設計。
どこが参照かの正確な情報を持たず、スタック上の値を「ポインタかもしれない」と見なして走査する。 処理系を選ばず後付けできるのが強み。
代償:参照か整数か確定できないのでオブジェクトを動かせない。 コンパクションが原理的に不可能になる。
GC.compact(手動)、
3.0 で自動コンパクションを導入した。
CMS の断片化に悩んで G1 へ移った Java の歴史と、ほぼ同じ順序をたどっている。
面白いのは書き込みバリアの入れ方で、既存の C 拡張との互換のために
「バリアで保護されたオブジェクト」と「されていないオブジェクト」を共存させながら移行した。
ここまでの概念が、実際のコレクタでどう組み合わさっているのか。 Serial GC から Generational ZGC までの 8 つを、ヒープの可視化と Stop-the-World のタイムラインで 1 フェーズずつ動かして確かめる。
年代記を開く →