ガベージコレクション(GC)の仕組みと実装比較 — 停止時間・世代仮説・チューニング完全ガイド

ガベージコレクション(GC)とは

ガベージコレクション(Garbage Collection、略して GC)は、プログラムが動的に確保したメモリ領域(オブジェクトや配列など)で、もはやプログラムから参照されない「不要になったメモリ」を自動的に検出して回収(解放)する仕組みです。手動でメモリ管理を行う C/C++ のような言語と異なり、Java、C#、JavaScript、Go、Python(CPython は参照カウント+循環検出)など多くのモダン言語は GC を備え、メモリ安全性や開発者の負担軽減に寄与します。

なぜ GC が必要か

  • メモリリーク防止:参照が切れたオブジェクトを自動で解放し、長時間稼働するサーバーでのメモリ枯渇を防ぐ。

  • メモリ安全性向上:ダングリングポインタや二重解放などのバグを減らす。

  • 開発生産性:メモリ管理コード(malloc/free、new/delete)の記述とデバッグ工数を削減。

基本概念:ルート・到達性・世代仮説

GC は「どのオブジェクトが生きているか(到達可能か)」を判定して回収対象を決めます。典型的にはスタック、CPU レジスタ、静的変数などを「ルート」とし、ルートから辿れるオブジェクトは生存(reachable)と見なします。GC の多くは「世代仮説(generational hypothesis)」を利用します:大多数のオブジェクトは短命(短時間で不要になる)であり、若い世代を頻繁に回収して効率化するという考え方です。

代表的な GC アルゴリズム

  • 参照カウント(Reference Counting)
    各オブジェクトが参照されるたびカウントを増減し、0 になれば即時解放します。即時性が利点ですが、参照による循環(A が B を参照、B が A を参照)を自動で解決できない問題があります。CPython が基本的に採用している手法です。

  • トレーシング(Tracing)方式:マーク&スイープ
    ルートからトレースして到達可能なオブジェクトをマークし、マークされていないものをスイープ(回収)します。実装が比較的単純ですが、スイープ後のメモリ断片化を招くことがあります。

  • マーク&コンパクト(Mark-Compact)
    マーク後にオブジェクトを詰めて(コンパクト)断片化を解消します。移動を伴うため参照の更新が必要になりますが、断片化が減り大きな連続領域を確保しやすくなります。

  • コピー方式(Copying)
    ヒープを半分に分け、一方から他方へ生存オブジェクトだけコピーして領域を一括して再利用します。コピー中にオブジェクトが密に並ぶので断片化が少なく、高速ですがコピーコストがあります。世代 GC の若年世代によく使われます。

世代(Generational)GC とその利点

世代 GC はヒープを「若年(young)」「老年(old)」などに分割し、若年のみを頻繁に短時間で回収することで効率を上げます。多くの若年オブジェクトは短命なので、全体をスキャンするよりコストが低く済みます。大きなオブジェクトは老年に昇格し、回収頻度を下げます。

同時並行(Concurrent)・インクリメンタル GC と停止時間

従来の GC は「stop-the-world」でアプリケーションを一時停止して処理するため、応答遅延(レイテンシ)を生むことがありました。そこで生まれたのが並行(Concurrent)やインクリメンタル GC です。これらはアプリケーションスレッドと GC スレッドが競合しないように書き込みバリアや三色マーキング(tri-color invariant)等を用いて部分的に並行実行します。代表的な技術として、Java の G1、ZGC、Shenandoah、Go の並行マーク&スイープなどがあります。

三色マーキングと書き込みバリア

三色マーキングはオブジェクトを白(未検査=回収候補)、灰(発見済だが走査未了)、黒(発見かつ走査済)に分けて管理します。並行処理中にアプリケーションがポインタを書き換えると不整合が起きるため、書き込みバリア(write barrier)により新しい参照を追跡し整合性を保ちます。これにより「停止時間」の短縮を図れますが、書き込みバリアは追加コストを伴います。

ファイナライザ(finalizer)とリソース管理

オブジェクトの破棄時にリソース(ファイルやソケット)を解放するためのファイナライザは存在しますが、呼び出しタイミングが GC 次第で不確定・遅延するため推奨されません。Java では try-with-resources、C# では IDisposable パターン(using)など明示的にリソースを解放する手法を使うべきです。多くの実装でファイナライザは重く、非推奨になってきています。

言語・ランタイム別の実装例

  • Java:JVM には複数の GC 実装(Serial、Parallel、CMS(旧)、G1(デフォルト Java 9 以降)、ZGC、Shenandoah など)があります。用途に応じてスループット重視・レイテンシ重視を選択できます。オプションで -Xmx/-Xms、-XX:+UseG1GC、-XX:MaxGCPauseMillis などを設定します。

  • .NET(C#):Server/Workstation モード、世代 GC(Gen0/Gen1/Gen2)、Large Object Heap(LOH)を持ち、.NET Core/.NET 5+ では LOH コンパクションや背景 GC が改善されています。

  • JavaScript(V8 等):多くは世代 GC とマーク・スイープ/マーク・コンパクトを組み合わせ、短い停止時間を実現するための工夫がされています。

  • Go:並行・トレース型 GC を採用し、低レイテンシを目指して設計されています。Go の GC はアプリケーションスループットと停止時間のバランスを段階的に改善してきました。

  • Python(CPython):基本は参照カウントで、循環参照を検出する世代別の補助 GC(module gc)を持ちます。参照カウントによりリソースは即時解放されやすい一方、循環は補助 GC に依存します。

  • Rust / C++:Rust は所有権・借用の静的解析でほとんど GC を不要にする設計、C++ は手動管理が基本ですが、必要ならスマートポインタや独自 GC を組み込めます。

GC に伴う課題とトラブルシューティング

  • 停止時間(pause):リアルタイム性が重要なアプリケーションでは GC の停止が問題となります。低レイテンシ向け GC(ZGC、Shenandoah、Go の concurrent GC など)を検討します。

  • メモリ使用量の増加:GC は通常ヒープをある程度大きく確保し、オブジェクトの短命性を利用するため、必要以上にヒープを増やすとメモリ使用量が増加します(トレードオフ)。

  • 断片化:長期稼働時にメモリが細切れになると大きな連続領域が確保しにくくなり、マークコンパクト等が必要になります。

  • 誤設定:ヒープサイズや GC オプションを不適切に設定するとパフォーマンス劣化を招きます。プロファイリングとログ解析が重要です。

チューニングとプロファイリング

GC の問題を診断するにはログとプロファイラを活用します。Java なら GC ログ(-Xlog:gc*)、jstat、jmap、jcmd、VisualVM、Flight Recorder、.NET なら dotnet-trace、dotnet-gcdump、Go なら pprof、Python なら tracemalloc や gc モジュールなどがあります。ツールでアロケーションホットスポット、オブジェクト寿命分布、メモリリーク(到達可能なのに不要となったオブジェクト)を調べ、対策(オブジェクト再利用、データ構造の見直し、適切な GC の選択)を行います。

開発者向けベストプラクティス

  • 不要なオブジェクト生成を避ける:短時間で大量の一時オブジェクトを生成すると GC 負荷が増える。

  • リソースは明示的に解放する:ファイナライザに頼らず try-with-resources / using を使う。

  • キャッシュは弱参照やソフト参照(言語が提供する場合)を検討する。

  • オブジェクトプールを使う際は適切に設計する(過剰なプーリングは逆効果)。

  • プロファイラで実際の挙動を確認してからチューニングする。

まとめ

ガベージコレクションはメモリ管理を自動化してバグを減らし開発生産性を上げますが、アプリケーション特性によっては停止時間やメモリ使用量といったトレードオフがあります。現代のランタイムは多様な GC アルゴリズム(世代、並行、低遅延)を提供しているため、用途に応じた GC の選択と適切なチューニング、プロファイリングが重要です。また、言語固有の実装(参照カウント+循環検出、所有権モデルなど)も理解しておくと、より効果的なメモリ管理が行えます。

参考文献