←記事一覧

ガベージコレクションの仕組み — GC 技術の系譜と言語ごとの戦略

参照カウント、トレース GC、世代別 GC、並行 GC の仕組みと改良の流れを説明し、Go・Java・Python・JavaScript・.NET・Rust・Swift のメモリー管理を比較します。三色マーク & スイープの動きはインタラクティブな図で確認できます。

GCRuntimeGoJavaPython

C/C++ では、確保したメモリーを開発者が malloc/free で管理します。多くの現代的な言語では、不要になったメモリーをガベージコレクター(GC)が自動で回収します。

GC の方式は、オブジェクトへの参照を数える参照カウントと、ルートから到達できるオブジェクトをたどるトレース方式に大きく分かれます。トレース方式には、断片化、ヒープ全体の走査、停止時間といった課題に対して、コンパクション、世代別 GC、並行 GC などの改良が重ねられてきました。各言語は、停止時間・スループット・メモリー使用量のどれを重視するかに応じて、これらの技術を組み合わせています。

動画で読む3:58合成音声ナレーション・字幕付き
文字起こしを表示

使い終わったメモリは、いつ、誰が解放するべきでしょうか。GCの基本戦略から、主要な言語の選択まで、ガベージコレクションの系譜をたどります。

手動のメモリ管理には、2つの典型的なバグがあります。解放を忘れればメモリリークになり、まだ使っているメモリを解放すれば、ダングリングポインタが生まれます。1959年、ジョン・マッカーシーは、Lispのためにこの解放を自動化する、GCを考案しました。

1つ目の系統は参照カウントです。各オブジェクトが、自分を指している参照の数を数えます。カウントが0になった瞬間に解放されるので、回収は即時で、プログラム全体を止める必要もありません。しかし、AとBが互いを指す循環参照は、外から辿れなくなってもカウントが0にならず、永遠に残ります。スレッド間で共有されるオブジェクトでは、カウンタの更新に、一般にアトミック操作が必要になるというコストもあります。

もう1つの系統がトレース方式です。スタック上の変数やグローバル変数といったルートから、参照をたどります。たどり着けたものが生存、たどり着けないものがゴミです。循環参照も、ルートから届かなければ正しく回収されます。

代表的なアルゴリズムが、三色マーク・アンド・スイープです。最初は、すべてのオブジェクトが白です。ルートから指されるものを灰色にし、灰色を1つずつ取り出して、白い参照先を灰色に、自分を黒にします。灰色がなくなればマークは完了です。白のまま残ったオブジェクトを、スイープで解放します。正しさを支えているのは、「黒は白を直接指さない」という三色不変条件です。

ただし解放した跡には隙間が残り、メモリが断片化していきます。マーク・アンド・コンパクトは、生存オブジェクトを片側に詰め直し、割り当てをポインタを進めるだけの操作にします。コピーGCはヒープを2つに分け、生きているものだけを反対側へコピーします。速い代わりに、使えるのはヒープの半分です。

さらに、ほとんどのオブジェクトは若くして死ぬ、という弱い世代仮説があります。新しいオブジェクトは小さなYoung世代に置き、頻繁で軽いマイナーGCで回収します。何度も生き延びたものはOld世代へ昇格し、頻度は低いが重い、メジャーGCで扱います。

停止時間を縮めるため、並行GCは、アプリケーションを動かしたままマークを進めます。でもマークの途中で、黒いAが白いCを指し、Cへの他の経路が切られると、生きているCが解放されてしまいます。これを防ぐのが書き込みバリアです。ヒープ上のポインタを書き換えるたびに、GCへ知らせるコードが挿入されます。GoはDijkstraとYuasaのバリアを組み合わせてスタックの再スキャンをなくし、停止を典型的にはサブミリ秒に抑えています。

言語ごとの選択はさまざまです。Goは並行マーク・アンド・スイープで、世代別でも、コンパクションでもありません。JavaはG1やZGCなどのコレクタを選べ、V8や.NETは世代別、Pythonは参照カウントに循環検出のGCを組み合わせます。RustとSwiftは、所有権やARCによって、トレースGCそのものを持ちません。

シンプルさ、正確さ、スループット、停止時間。GCは、言語ランタイムの設計を左右する、最も重要な判断のひとつです。記事では、三色マークの動きをステップごとに確かめられるデモも用意しています。

なぜ GC が必要なのか

手動メモリー管理には 2 つの典型的なバグがあります。

メモリーリーク

使い終わったメモリーを解放し忘れると、そのメモリーはプログラムが終了するまで再利用されません。

void leak_example() {
    char *buf = malloc(1024);
    // buf を使って処理...
    if (error) return;  // ← free(buf) を忘れてリターン!
    free(buf);
}

この例では、error が真の場合に free が呼ばれず、1024 バイトのメモリーがリークします。この関数が呼ばれるたびにリークが積み重なるため、長時間稼働するサーバーアプリケーションではメモリー使用量が増え続け、最終的には OOM(Out of Memory)でクラッシュします。

ダングリングポインター

まだ使っているメモリーを誤って解放すると、その後のアクセスが解放済みメモリーへのアクセス(Use-After-Free)になります。

void dangling_example() {
    char *a = malloc(64);
    char *b = a;  // a と b が同じメモリを指す
    free(a);
    // b はまだ解放済みメモリを指している(ダングリングポインタ)
    strcpy(b, "hello");  // 未定義動作!クラッシュやセキュリティ脆弱性の原因
}

GC は、プログラムから到達できなくなったオブジェクトだけを自動で回収します。開発者が解放のタイミングを指定しないので、解放忘れによるリークも、使用中のメモリーを解放してしまう Use-After-Free も起きません。

GC は 1959 年に John McCarthy が Lisp のために考案しました。その後、以下で紹介する多くのアルゴリズムが生まれ、改良されてきました。

GC の基本戦略

GC のアルゴリズムは大きく参照カウント方式とトレース方式の 2 つの系統に分けられます。

ガベージコレクション参照カウント方式トレース方式マーク & スイープマーク & コンパクトコピー GC世代別 GCインクリメンタル GC並行 GC

⮳ 上図は代表的な組み合わせを簡略化したものです。世代別・インクリメンタル・並行の各方式は基本アルゴリズムとは独立した最適化で、マーク & スイープ以外とも組み合わせられます。例えば Java の Young 世代はコピー GC を使います。

参照カウント方式(Reference Counting)

各オブジェクトが「自分を何箇所から参照されているか」をカウンターとして持ちます。参照が増えるとカウントをインクリメントし、参照が外れるとデクリメントします。カウンターが 0 になった時点で、そのオブジェクトは即座に解放されます。

参照カウントの動作例

a = Object()   # Object の ref_count = 1(a が参照)
b = a           # Object の ref_count = 2(a, b が参照)
a = None        # Object の ref_count = 1(b のみ参照)
b = None        # Object の ref_count = 0 → 即座に解放!

長所

  • カウンターの増減だけで管理でき、実装がシンプルで理解しやすい
  • ガベージは参照がなくなった時点で即座に回収されるので、レイテンシーを予測しやすい
  • アプリケーション全体を停止させる Stop-The-World がない
  • 不要になったオブジェクトがすぐに解放されるため、メモリーのピーク使用量が抑えられる

短所

  • 循環参照を回収できない: 最大の弱点。A→B→A のように互いを参照するオブジェクトは、外部から使われなくなっても回収されない
  • ポインターの代入のたびにカウンターを更新するオーバーヘッドがある。特にマルチスレッド環境ではアトミック操作(atomic_increment/atomic_decrement)が必要で、キャッシュラインの競合が発生する
  • 大きなデータ構造のルートの参照カウントが 0 になると、連鎖的に多数のオブジェクトの解放が走り(カスケード解放)、一時的なレイテンシースパイクになり得る
Aref=1Bref=1外部参照なし → ref_count ≠ 0 → リーク

上図では、外部からの参照がなくなった後も、A と B の ref_count は互いからの参照で 1 のまま残ります。0 にならないので、2 つのオブジェクトは永久にリークします。

トレース方式(Tracing GC)

トレース方式では、ルートの集合(スタック上の変数、グローバル変数、レジスターなど)からオブジェクトグラフをたどり、到達可能(reachable)なオブジェクトを「生きている」とマークします。到達不可能(unreachable)なオブジェクトはガベージとして回収されます。

ルートセットとは

トレース GC における「ルート」とは、プログラムがほかのオブジェクトを経由せずに直接アクセスできるポインターのことで、次のものが含まれます。

  • スタックフレーム上のローカル変数(各 goroutine / スレッドのスタック)
  • グローバル変数(パッケージ / モジュールレベルの変数)
  • CPU レジスター(現在のスレッドが保持しているポインター)

互いを参照し合うオブジェクトでも、ルートからたどれなければ回収されます。参照カウント方式では回収できない循環参照を扱えることが、トレース方式の大きな利点です。

三色マーク & スイープ(Tri-color Mark & Sweep)

三色マーク & スイープは、基本的で広く使われているトレース GC アルゴリズムです。Dijkstra らが 1978 年の論文 "On-the-Fly Garbage Collection: An Exercise in Cooperation" で提唱しました。

三色の意味

色意味状態
白まだ GC に発見されていない候補(ガベージかもしれない)
灰色発見されたが、参照先のスキャンがまだ完了していないスキャン待ち
黒自身も参照先もすべてスキャン済み確実に到達可能(生存)

三色不変条件(Tri-color Invariant)

アルゴリズムの正しさは、次の不変条件に依存します。

黒色オブジェクトが白色オブジェクトを直接参照することはない

GC は黒色のオブジェクトを再びスキャンしません。白オブジェクトを参照しているのが黒オブジェクトだけだと、その白オブジェクトはスキャンされず、生きているのにガベージとして回収されてしまいます。白オブジェクトとの間に灰色オブジェクトがある限り、その灰色オブジェクトのスキャンを通じて白オブジェクトも必ず発見されます。

アルゴリズムの流れ

  1. すべてのオブジェクトを白色にする
  2. GC ルートが指すオブジェクトを灰色にし、灰色スタック(ワークリスト)に追加する
  3. 灰色スタックからオブジェクトを 1 つ取り出してスキャンする:
    • それが参照するオブジェクトのうち白色のものを灰色にし、灰色スタックに追加
    • 自身を黒色にする
  4. 灰色スタックが空になるまで 3 を繰り返す(マーク完了)
  5. ヒープを走査し、白色のまま残ったオブジェクトのメモリーを解放する(スイープ)

次のデモでは、この手順を 1 ステップずつ進めながら、各オブジェクトの色と灰色スタックの変化を確認できます。

三色マーク & スイープの動き

三色マーク & スイープ GC が到達可能なオブジェクトをたどり、ガベージを回収する過程を 1 ステップずつ確認できます。

GC 開始前
白(未発見)灰色(発見済み)黒(スキャン済み)解放済み
Root1root
Root2root
A
B
C
D
E
F
G
H
I
灰色スタック
空
初期状態: ヒープ上に 11 個のオブジェクトが存在します。すべてのオブジェクトは白色(未発見)です。Root1 からは A, B へ、Root2 からは E への参照があります。G, H, I はどのルートからも到達不可能です。
1 / 13

マーク & スイープの課題

基本的なマーク & スイープには次の課題があります。

  • 断片化: 生き残ったオブジェクトの間に、解放された領域が隙間として残る。大きなオブジェクトの割り当て時に連続領域が確保できないことがある
  • ヒープ全体の走査: スイープ時にヒープ全体を走査する必要があり、ヒープサイズに比例してコストが増大する
  • Stop-The-World: マークとスイープの間、すべてのアプリケーションスレッドが停止する

次節で扱う改良は、これらの課題に対応しています。断片化にはマーク & コンパクトとコピー GC、ヒープ全体の走査には世代別 GC、停止時間にはインクリメンタル GC と並行 GC が対処します。

トレース GC の発展

マーク & コンパクト(Mark-Compact)

マーク & コンパクトは、生存オブジェクトをヒープの一端に詰め直してメモリーの断片化(fragmentation)を解消します。

前後ABCDABCD空き

上図のように、スイープ後に生じた隙間をコンパクション(圧縮)で詰めると、ヒープ末尾に連続した空き領域ができます。

オブジェクトをアドレス順に詰め、相対的な順序を保つスライディング方式の代表的なアルゴリズムは次のとおりです。

  • Lisp 2 algorithm: ヒープを 3 回走査する。1 回目で新アドレスを計算、2 回目でポインターを更新、3 回目でオブジェクトを移動
  • Threaded compaction: 参照をリンクリストに変換して一時情報を保存する。追加メモリー不要だが複雑

コンパクションはオブジェクトの移動と参照の書き換えを伴う重い処理ですが、空き領域が 1 か所にまとまるので、割り当てにバンプポインター(ポインターを進めるだけの方式)を使えます。バンプポインター割り当ては alloc(size) = ptr; ptr += size で済むため、空きブロックを探すフリーリスト方式より高速です。

コピー GC(Copying / Semispace)

コピー GC は、ヒープを 2 つの領域(From 空間 / To 空間)に分割します。オブジェクトの割り当ては From 空間で行い、GC 時に生存オブジェクトを To 空間にコピーします。コピー後、From と To を交換し、古い From を一括で解放します。

代表的な実装は Cheney のアルゴリズム(1970 年)で、BFS(幅優先探索)でオブジェクトをコピーします。

scan = free = To空間の先頭
GCルートから参照されるオブジェクトをTo空間にコピー, freeを進める
while scan < free:
    scanが指すオブジェクトの各フィールドについて:
        参照先がFrom空間にあり、まだforwardingされていなければ:
            To空間にコピーし、旧位置にforwarding pointerを設定し、freeを進める
        フィールドを参照先のTo空間アドレスに更新する
    scanを次のオブジェクトに進める
  • 長所: コピー先に詰めて配置するので別途コンパクションしなくても断片化しない、バンプポインターで高速に割り当てられる、ヒープ全体のスイープが不要
  • 短所: ヒープの半分しか使えない(メモリー効率が 50%)、大きなオブジェクトはコピーのコストが高い

世代別 GC(Generational GC)

世代別 GC は、次の弱い世代仮説(Weak Generational Hypothesis)に基づく最適化です。

ほとんどのオブジェクトは生成直後に不要になる(若くして死ぬ)

この仮説は実際の観測に基づいています。1992 年の Wilson のサーベイによると、新しく割り当てられたオブジェクトのうち、数百万命令を実行する間に(あるいはさらに 1MB が割り当てられる前に)不要になる割合は、言語やプログラムによって異なるものの、通常 80〜98% です。

Young 世代EdenSurvivor 0Survivor 1Minor GC昇格Old 世代Tenured(長寿命オブジェクト)Minor GC: Young 世代のみ(高頻度・高速)/ Major GC: ヒープ全体(低頻度・低速)
  • Young 世代(新世代): 新規オブジェクトを割り当てる小さな領域。Minor GC が頻繁に走るが、ほとんどのオブジェクトが死んでいるため数ミリ秒で終わる。Eden 領域で新規割り当てを行い、Minor GC で生存したオブジェクトは Survivor 空間にコピーされる
  • Old 世代(旧世代): 何度も Minor GC を生き延びたオブジェクトが昇格(promotion)する領域。GC の頻度は低いが、処理は重い(Major GC / Full GC で数十〜数百ミリ秒)

Minor GC では Young 世代だけを調べますが、Old 世代から Young 世代への参照も生存の根拠として扱う必要があります。この参照を Old 世代全体をスキャンせずに見つけるために、世代別 GC はリメンバードセット(Remembered Set)で世代間の参照を記録します。実装にはカードテーブル方式が一般的です。Old 世代のメモリーを 512 バイト程度のカード単位に分割し、Young 世代への参照を含むカードだけを「ダーティー」とマークしてスキャン対象にします。

インクリメンタル GC / 並行 GC

インクリメンタル GC と並行 GC は、GC 処理をアプリケーションの実行と交互に、または同時に進めて、Stop-The-World(STW)の時間を短くします。

Stop-The-Worldヒープ全体の GCインクリメンタル GCマークアプリマークアプリマークスイープ並行 GCSTWアプリ(並行マーク)STWアプリ(並行スイープ)時間 →
  • Stop-The-World GC: マークとスイープの間、アプリケーションを完全に停止。実装は最もシンプルだが、停止時間が長い(ヒープサイズに比例)
  • インクリメンタル GC: マーク処理を小さなステップに分割し、アプリケーションの実行と交互に進める。各ステップの停止は短いが、合計の GC 時間は長くなる(書き込みバリアのオーバーヘッド)
  • 並行 GC(Concurrent GC): マーク処理を別スレッドで並行実行。アプリケーションが停止するのはマーク開始時と最後の再マーク時だけで、どちらも短い

並行 GC では、アプリケーション(ミューテーター)と GC(コレクター)が同時に動くため、マーク中にオブジェクトの参照関係が変わることがあります。これに対処するのが書き込みバリア(Write Barrier)です。

書き込みバリア(Write Barrier)

書き込みバリアは、並行 GC やインクリメンタル GC で三色不変条件を維持するための仕組みです。ミューテーターがポインターを書き換える箇所に、変更を GC に伝えるコードが挿入されます。

なぜ書き込みバリアが必要か

書き込みバリアがないと、次の順に処理が進んだときに生きているオブジェクトが解放されます。

1. GC がオブジェクト A を黒色にスキャン済み
2. アプリケーションが A.field = C(白色)を実行
3. アプリケーションが B.field = nil(B→C の参照を削除。B は灰色/白色)
4. GC は B をスキャンするが、もう C への参照がない
5. C は白色のまま → ガベージとして解放される → 生きているのに解放!

この問題をロストオブジェクト問題と呼びます。黒→白の参照が作られ(条件①)、かつその白オブジェクトへの他の経路がすべて切断された(条件②)場合に発生します。

主要な書き込みバリア

① Dijkstra バリア(挿入バリア)

ポインターに新しい参照先を書き込むときに、その参照先を灰色にします。

// A.field = C を実行する際に挿入されるバリア
writeBarrier(A, field, C):
    shade(C)      // まず C を灰色にする(黒→白の参照を防ぐ)
    A.field = C   // その後にストアを実行

shade はストアの前に実行します。順序が逆だと、ストアと shade の間に「黒オブジェクト A が白オブジェクト C を参照している」状態が生まれ、並行して動く GC がその瞬間に C をガベージと判断するおそれがあります。

新しく参照されたオブジェクトは必ず灰色になるので、確実にスキャンされます。ただし、保守的に多くのオブジェクトを灰色にするため、浮遊ガベージ(本当はガベージだが今回は回収されない)が増える傾向があります。

② Yuasa バリア(削除バリア / snapshot-at-the-beginning)

参照が外される側のオブジェクト(上書きされる古い参照先)を灰色にします。GC 開始時点のオブジェクトグラフを「スナップショット」として保つ考え方です。

// A.field が old → new に変わる前に挿入されるバリア
writeBarrier(A, field, new):
    old = A.field
    if old is white:
        shade(old)  // old を灰色にする
    A.field = new

これにより、GC 開始時に到達可能だったオブジェクトはすべてマークされます。

③ ハイブリッドバリア(Go 1.8+)

Go は Dijkstra バリアと Yuasa バリアを組み合わせたハイブリッド書き込みバリアを採用しています。

writeBarrier(slot, new):
    shade(*slot)  // 削除バリア: 上書きされる古い値を灰色にする
    if current stack is gray:
        shade(new) // 現在のスタックがまだスキャン対象の間だけ挿入バリア
    *slot = new

この擬似コードは Go の mbarrier.go のコメントに沿っています。削除バリアは上書きされる古い値を常に shade しますが、挿入バリアで新しいポインターを shade するのは、現在の goroutine のスタックが灰色の間だけです。スタックが黒になれば shade(new) は不要になります。

ハイブリッド方式の最大の利点は、スタックの再スキャンが不要になることです。Dijkstra バリアだけを使っていた Go 1.5〜1.7 では、マーク終了時にすべての goroutine のスタックを再スキャンする必要があり、これが STW 時間の大きな原因でした。ハイブリッドバリアにより、Go の STW の停止時間は典型的にはサブミリ秒に収まり、ワークロードやプラットフォームによってはさらに短くなります。

メモリーアロケーション戦略

GC のアルゴリズムによって、使える割り当て方式が変わります。オブジェクトを移動しない GC では空きブロックを探すフリーリストを使い、コンパクションやコピーで空き領域をまとめる GC ではバンプポインターを使えます。

フリーリスト(Free List)

フリーリスト方式は、解放されたメモリーブロックをリンクリストで管理し、割り当て時に要求サイズに合うブロックを探します。探し方には次の種類があります。

  • First-fit: 最初に見つかった十分なサイズのブロックを使う
  • Best-fit: 最もサイズが近いブロックを使う(断片化が少ない)
  • Segregated-fit: サイズクラスごとに別のリストを持つ(Go や tcmalloc が採用)

バンプポインター(Bump Pointer)

バンプポインターは、コンパクション済みのヒープやコピー GC で使われる方式です。空きメモリーの先頭にポインターを置き、割り当てのたびにポインターを進めるだけです。

alloc(size):
    result = freePtr
    freePtr += size
    return result

極めて高速(実質 1 命令)ですが、連続した空き領域が必要です。

スレッドローカル / プロセッサーローカルな割り当てバッファー

実行コンテキストごとにヒープの一部を事前に確保し、マルチスレッド環境での割り当て競合を避ける仕組みです。Java HotSpot は TLAB(Thread-Local Allocation Buffer)を使い、各 Java スレッドがロックなしのバンプポインターで割り当てます。Go のアロケーターはこれとは別の設計で、TLAB の代わりに、論理プロセッサー(P)ごとの mcache とサイズクラス別のスパンを使います。

言語ごとの GC 戦略

Go — 並行三色マーク & スイープ

Go の GC は、シンプルさとレイテンシーの低さを最優先にした設計です。

項目内容
アルゴリズム並行三色マーク & スイープ
世代別なし(非世代別)
コンパクションなし
STWマーク開始・終了時のみ(典型的にはサブミリ秒。ワークロード依存)
書き込みバリアハイブリッド(Dijkstra + Yuasa)
チューニングGOGC / GOMEMLIMIT

なぜ Go は世代別 GC ではないのか?Go のコンパイラーはエスケープ解析(escape analysis)を行い、関数外に参照が漏れないオブジェクトをスタックに割り当てます。そのため短命なオブジェクトの多くはヒープに到達せず、世代別 GC の前提(「多くのヒープオブジェクトが短命」)が弱くなります。Go チームは ISMM 2018 の基調講演で、世代別にしても得られる効果が小さく、常に有効にしておく必要がある書き込みバリアのコストに見合わないと説明しています。

GC ペーサー: 次の GC サイクルをいつ開始するかは「ペーサー」が決めます。GOGC=100(デフォルト)では、前回の GC 後のライブヒープサイズに対してヒープが 100% 成長した時点で次の GC を開始します。Go 1.19 で追加された GOMEMLIMIT では、メモリー制約のある環境で OOM を防ぐために、メモリー使用量の上限をソフトリミットとして設定できます。

Green Tea GC(Go 1.26 で既定化): 従来の Go のマーカーはオブジェクト単位でポインターをたどるため、小さなオブジェクトが大量にあるとメモリーアクセスがランダムになり、キャッシュ効率が落ちます。Green Tea GC はマーク対象をスパン(同じサイズクラスのページ)単位でまとめて処理し、局所性と CPU スケーラビリティーを改善しました。Go 1.25 で実験的に導入され、Go 1.26 で既定有効になっています。公式リリースノートの見込みでは、GC を多用する実プログラムで GC オーバーヘッドが 10〜40% 減り、Ice Lake / Zen 4 以降の amd64 ではベクター命令を使ってさらに約 10% 改善します。アルゴリズムの骨格(並行三色マーク & スイープ、非世代別、非コンパクション)は変わっておらず、変わったのはマーク時にメモリーをどう走査するかという実装の部分です。

GC のソースコードは runtime/mgc.go にあります。

Java (HotSpot) — 世代別 + 複数のコレクター

Java(HotSpot)には、用途に応じて選べる複数のコレクターがあります。表の STW 列の「長い」「中程度」「短い」は、これらのコレクター同士を比べた相対的な目安です。

コレクター特徴STWユースケース
Serial GCシングルスレッド長い小規模アプリ、クライアント
Parallel GCマルチスレッドマーク中程度スループット重視のバッチ処理
G1 GCリージョンベース、予測可能な停止短い(目標指定可)汎用(JDK 9+ デフォルト)
ZGC着色ポインター、TB 級ヒープ< 1ms低レイテンシー要求
Shenandoah並行コンパクション< 10ms低レイテンシー(Red Hat が開発)

G1 GC の仕組み: ヒープを同じサイズのリージョン(通常 1〜32MB)に分割し、各リージョンを Young と Old のどちらに使うかを動的に決めます。G1 は「ガベージの多いリージョン」を優先的に回収(Garbage-First)し、限られた停止時間内で回収できるメモリーを最大にします。-XX:MaxGCPauseMillis=200 のように停止時間の目標を設定できます。

ZGC の着色ポインター(Colored Pointers): ZGC はオブジェクトポインターの未使用ビットに GC のメタデータ(マーク状態、リロケーション状態)を埋め込むので、オブジェクトヘッダーにマーク情報を書き込む必要がありません。非世代別 ZGC は着色ポインターとロードバリアで並行マーク / リロケーションを実現し、世代別 ZGC はさらに世代間参照を追跡するストアバリアも使います。これらの技術により、ZGC は TB 級のヒープでも STW を 1ms 以下に抑えます。

Python (CPython) — 参照カウント + 世代別トレース GC

CPython は、参照カウントとトレース GC を組み合わせたハイブリッド方式です。

参照カウントref == 0?Yes即座に解放No循環参照の可能性世代別トレース GC循環ガベージを定期的に検出・回収
  • 参照カウント(Primary): メインのメモリー回収方式。オブジェクトの ob_refcnt が 0 になると即座に tp_dealloc が呼ばれて解放される
  • 世代別トレース GC(Auxiliary): 参照カウントでは回収できない循環参照を検出・回収するために、割り当て数に応じて世代別のトレース GC を定期的に実行

3 つの世代の閾値:

世代閾値(デフォルト)意味
Generation 02000(3.12 以前は 700)割り当て数 − 解放数 が閾値を超えたら Gen 0 コレクションを実行
Generation 110Gen 0 コレクションが 10 回走るたびに Gen 1 コレクションも実行
Generation 210Gen 1 コレクションが 10 回走るたびに Gen 2 コレクションも実行

閾値が決めるのは各世代をコレクションする頻度です。オブジェクトは Gen N のコレクションを 1 回生き延びるだけで Gen N+1 に昇格します。閾値は gc.get_threshold() で確認でき、gc.set_threshold() で変更できます。

CPython では GIL(Global Interpreter Lock)があるため、参照カウントの更新にアトミック操作は不要です。GIL を持たない free-threaded ビルド(--disable-gil)では、バイアス参照カウント(biased reference counting)を使います。オブジェクトを所有するスレッドは通常のインクリメント/デクリメントでカウンターを更新し、他のスレッドだけがアトミック操作を使うことで、GIL なしでもスレッドセーフな参照カウントを実現します。free-threaded ビルドは Python 3.13 で実験的に導入され、PEP 779 により Python 3.14 で正式サポート(ただし依然としてオプションのビルド)となりました。

3.14 で導入され、撤回されたインクリメンタル GC: CPython 3.14.0〜3.14.4 は、大きなヒープでの最大停止時間を桁違いに短縮することを狙って、世代を young / old の 2 つに絞ったインクリメンタル GC を採用していました。しかし本番環境でメモリー圧迫の報告が相次いだため、3.14.5 で 3.13 相当の世代別 GC に巻き戻されました。上で説明した 3 世代方式は、この巻き戻し後の GC です。この事例が示すように、GC の設計ではベンチマーク上の停止時間の短さと、実際のワークロードでのピークメモリー使用量とを比べて判断する必要があります。

JavaScript (V8) — 世代別 + 並行マーク

V8 エンジン(Chrome、Node.js、Deno)は世代別 GC を採用しています。

領域名称アルゴリズム
Young 世代Nursery (Scavenger)Semispace コピー GC
Old 世代Major GC並行マーク & スイープ/コンパクト

Scavenger の仕組み: Young 世代は 2 つの semispace に分けられます。新規オブジェクトは「From」空間に割り当てられ、Minor GC(Scavenge)時に生存オブジェクトが「To」空間にコピーされます。2 回生存したオブジェクトは Old 世代に昇格します。

Orinoco: V8 の GC 最適化プロジェクトの名前です。メインスレッドと並行・並列に GC 処理を進め、STW 時間を短くしています。主な手法は次のとおりです。

  • Concurrent Marking: ワーカースレッドがメインスレッドと並行してオブジェクトグラフを走査
  • Parallel Scavenging: 複数のワーカースレッドが Young 世代の GC を並列実行
  • Incremental Marking: マーク処理を細切れに進め、アイドル時間やタスク間の短い隙間で実行
  • Lazy Sweeping: スイープ処理をオブジェクトの割り当て時に遅延実行

.NET (CLR) — 世代別マーク & コンパクト

.NET の GC には、クライアントアプリ向けの Workstation GC と、サーバーワークロード向けの Server GC の 2 つのモードがあります。

項目内容
アルゴリズム世代別マーク & コンパクト
世代数3(Gen 0, 1, 2)+ LOH (Large Object Heap)
モードWorkstation GC / Server GC
並行Background GC(Gen 2 の並行コレクション)

Large Object Heap (LOH): 85,000 バイト以上のオブジェクトは LOH に直接割り当てられます。LOH はデフォルトではコンパクションされませんが、.NET Framework 4.5.1 以降では GCSettings.LargeObjectHeapCompactionMode で明示的にコンパクションを要求できます。

ピン留め(Pinning): ネイティブコードとの相互運用時に、GC がオブジェクトを移動することを防ぎます。fixed ステートメントや GCHandle.Alloc(obj, GCHandleType.Pinned) で実現しますが、多用すると断片化の原因になります。

Rust — GC なし(所有権システム)

Rust は GC に頼らず、所有権(ownership)、借用(borrowing)、ライフタイムのコンパイル時チェックでメモリー安全を保証します。

所有権システム → コンパイル時にメモリ解放のタイミングが決定
             → GC ランタイムがなく、GC による停止も起きない

RAII(Resource Acquisition Is Initialization)パターンにより、変数がスコープを抜けると自動的に Drop トレイトの実装が呼ばれてリソースが解放されます。呼び出し位置はコンパイル時に決まりますが、デストラクターや解放処理そのものは実行時に走ります。Rust が避けているのは、トレース GC のランタイムと GC 停止です。

循環参照が必要な場合は Rc<T> / Arc<T> + Weak<T> を使い、参照カウントで管理します。Weak<T> は所有権を持たない参照で、Rc の強参照カウントに数えられないため、循環の一部を Weak<T> にすれば循環を断ち切れます。

Swift — ARC(Automatic Reference Counting)

Swift はコンパイル時に参照カウントの増減コードを自動挿入する ARC を採用しています。

  • ランタイムでのトレース GC は不要
  • コンパイル時にすべての retain/release が挿入されるため、実行時のオーバーヘッドが予測しやすい
  • 循環参照は weak(Optional になる)/ unowned(非 Optional。参照先の寿命が参照元と同じか長い場合)参照で開発者が明示的に解決する必要がある

Swift のコンパイラーは ARC 最適化として、不要な retain/release のペアを除去するパス(Copy-on-Write 最適化を含む)を持っています。

GC 技術の年表

1959Mark & Sweep (McCarthy, Lisp)1960Reference Counting (Collins)1969Copying GC (Fenichel & Yochelson)1978Tri-color Marking (Dijkstra et al.)1983Generational GC (Lieberman & Hewitt)1992Train Algorithm (Hudson & Moss)2004G1 GC 研究論文 (Detlefs et al.)2015Go 1.5 並行 GC(STW は典型的には 10ms 未満)2017Go 1.8 ハイブリッドバリア(STW は典型的には 1ms 未満)2018ZGC experimental (JDK 11)2020Shenandoah GA (JDK 15)2023世代別 ZGC (JDK 21)理論実装

まとめ

表の STW 列の「短い」は、並行処理やインクリメンタル処理によって停止を短くしていることを表します。

言語GC 方式世代別並行STW割り当て方式
Go三色 M&SNoYes典型的にはサブミリ秒Segregated-fit
Java選択可(G1/ZGC/Shenandoah)YesYesコレクター次第(ZGC は < 1ms)TLAB + バンプポインター
Python参照カウント + トレースYes(3 世代)Noありpymalloc arena
JavaScript (V8)コピー + M&S/CompactYesYes短いSemispace + Free list
.NET世代別 M&CYes(3 世代 + LOH)Yes短いバンプポインター
Rustなし(所有権)——なしシステムアロケーター
SwiftARC——なしARC + malloc

GC の方式は、言語ランタイムの停止時間、スループット、メモリー使用量を左右する設計判断です。参照カウントは単純で即座に回収できますが循環参照を扱えず、トレース GC は循環参照も回収できる代わりに停止やヒープ走査のコストを伴います。世代別 GC は短命なオブジェクトが多いという仮説を使ってこのコストを減らし、並行 GC は書き込みバリアのオーバーヘッドと引き換えに停止時間を短くします。上の表の違いは、各言語がワークロードと要件に合わせて、これらのトレードオフのどれを選んだかによるものです。

参考文献