基礎●●●○○

ガベージコレクション

Garbage Collection ・ ガベージコレクション

どこからもたどれなくなったヒープ上のデータを自動で回収する仕組み。安全と引き換えに停止時間を払う。

✦ 30秒で試す — 航海ゴミ出しは自動なのに、そのたびにプログラムが一瞬止まるプログラムがAを指すのをやめると、AとBは?予想してみる

概要

ガベージコレクション(GC)は、プログラムが確保したメモリのうち「もう誰からも使われなくなった領域」を自動で見つけ出し、回収して再利用可能にする仕組みです。プログラムは動作中に、オブジェクトや配列といったデータをヒープと呼ばれる領域に次々と確保します。使い終わった領域を放置すればメモリはいずれ尽きるため、「いつ・誰が・どうやって返却するか」はプログラミングの根源的な問題です。GC はこの返却作業をランタイム(言語の実行環境)に肩代わりさせる、という答えです。

Java・C#・Python・Go・JavaScript など、現代の主要な言語の多くは GC を標準で備えています。開発者は free や delete のような明示的な解放を書かず、「参照が届かなくなったものはいずれ回収される」と信じてロジックに集中できます。一方で、回収のためにプログラムを一時停止させる「stop-the-world」や、回収のタイミングを制御しにくいことによるレイテンシの揺らぎなど、GC 固有のコストと付き合う必要も生まれます。

なぜ生まれたか

C 言語のように手動でメモリを管理する世界では、確保した領域は必ず自分で解放しなければなりません。ここには事故のパターンが3つあります。解放し忘れればメモリリーク(使わない領域が積み上がりメモリを食い尽くす)、同じ領域を二度解放すれば二重解放(ヒープの管理構造が壊れてクラッシュや脆弱性の温床になる)、解放済みの領域を指すポインタを使い続ければダングリングポインタ(別のデータを上書きし、再現困難なバグやセキュリティホールを生む)です。厄介なのは、これらのバグが「解放を書いた場所」ではなく、遠く離れた無関係な場所で発症することです。大規模なプログラムでは「この領域をもう誰も使っていない」ことを人間が確実に判断すること自体が難しく、メモリ管理はバグの最大の発生源のひとつでした。

GC の発想は「人間に判断させるから間違える。ならば、参照をたどれば機械的に判定できるようにしよう」というものです。起源は古く、1959年、LISP のために John McCarthy が考案しました。リスト構造を大量に生成・破棄する LISP では手動管理が非現実的だったためです。以後半世紀をかけて回収のアルゴリズムが洗練され、1995年の Java の普及によって「GC があるのが普通」という感覚が業務開発の主流になりました。

詳細

生きているかどうかは「届くかどうか」で決める

GC の判定基準は「今後使われる可能性があるか」ではなく、「ルートから参照をたどって届くか(到達可能か)」です。ルートとは、スタック上のローカル変数、グローバル変数、CPU レジスタなど、プログラムが直接触れる起点のことです。ルートから参照の鎖をたどって届くオブジェクトは「生きている」、どこからも届かないものは「ゴミ」とみなして回収します。この単純明快な基準こそが、人間の曖昧な判断を機械的な処理に置き換えた核心です。

参照カウントとその弱点 — 循環参照

最も直感的な方式が参照カウントです。各オブジェクトに「自分を指している参照の数」を持たせ、参照が増えたら +1、減ったら -1 し、0 になった瞬間に即座に回収します。回収コストが分散されて停止時間が短く、Python や Swift が採用しています。しかし致命的な弱点があります。A が B を指し、B が A を指し返す「循環参照」では、外部から誰も届かなくなってもカウントは 1 のまま残り、永遠に回収されません。このため Python は循環参照を検出する補助 GC を別途備えています。

ルートスタック・グローバル変数オブジェクトC参照が届く = 生存✕外部からの参照は消えた= ルートから到達不能オブジェクトAオブジェクトB参照カウント 1参照カウント 1カウントが 0 にならず解放されないゴミ
循環参照 — ルートから到達不能なのに、互いを指し合うためカウントが 0 にならず回収されない

マークアンドスイープと世代別GC

もうひとつの主流がマークアンドスイープです。ルートから参照を再帰的にたどって生きているオブジェクトすべてに印を付け(マーク)、印のないものをまとめて回収します(スイープ)。参照の鎖を実際にたどるので、循環参照も正しくゴミと判定できます。派生として、生きているオブジェクトを別領域に詰め直して断片化を解消する「コピーGC」「コンパクション」もよく使われます。

次の部品で、2つの方式を並べて確かめてください。左は参照カウント、右はマーク&スイープで、同じ参照グラフが置かれています。矢印を切ると両方から同じ参照が消え、左では数が 0 になった箱がその場で消えていきます。ルートから循環ペア E・F への道を断つと、左には E・F が残り続け、右では「GC を実行」したときにまとめて回収されます。

⚡ 体験: 参照を切る — 参照カウントとマーク&スイープを並べて
参照カウント数が 0 になった瞬間に回収するルートab✂✂✂✂✂✂✂✂A1B1C1D2E2F1回収 0 個 / たどれないのに残る 0 個マーク&スイープGC のときにルートからたどるGC を実行ルートab✂✂✂✂✂✂✂✂ABCDEF回収 0 個 / 灰色(次の GC を待つ) 0 個

左右は同じ参照グラフです。矢印の ✂ を押して参照を切ると、両方の図から同じ矢印が消えます。まず b → B を切ってみてください。

実用的な GC の多くは、さらに「世代別GC」という最適化を重ねています(Java・.NET・V8 など。Go の GC は世代を分けず、アプリケーションと並行して動くマーク&スイープで停止時間を短くする道を選んでいます)。根拠は「世代仮説」— ほとんどのオブジェクトは生成直後に死ぬ、という経験則です。リクエスト処理の一時オブジェクトを思い浮かべると分かる通り、生まれたてのオブジェクトの大半は一瞬で不要になり、逆にしばらく生き延びたもの(設定、キャッシュなど)は長生きする傾向があります。そこでヒープを「新世代」と「旧世代」に分け、新世代だけを高頻度・高速に回収し(マイナーGC)、そこを生き延びたオブジェクトを旧世代へ昇格させ、ヒープ全体の回収(メジャーGC / フルGC)は低頻度に抑えます。

ヒープ新世代高頻度・高速に回収するマイナーGC生成直後に死ぬ大多数生存世代仮説: ほとんどのオブジェクトは若くして死ぬ旧世代低頻度に回収するメジャーGC設定・キャッシュなど長生きするオブジェクト昇格数回の GC を生き延びたものが昇格マイナーGCで即回収・領域を再利用
世代別GCのヒープ構成 — 大半のオブジェクトは新世代で死に、生き残りだけが旧世代へ昇格する

stop-the-world とレイテンシ

GC の代表的なコストが stop-the-world、つまり回収作業のためにアプリケーションの全スレッドを一時停止させることです。マーク中にオブジェクトの参照関係が書き換わると判定が壊れるため、素朴な実装では世界を止めるしかありません。バッチ処理なら停止も気になりませんが、Web サービスでは数百ミリ秒の停止がそのままレスポンスの遅延スパイクになります。そこで現代の GC は、アプリケーションと並行してマークを進める並行GC や、作業を小刻みに分けるインクリメンタルGC を発達させてきました。Go の GC や Java の ZGC は停止時間をミリ秒以下に抑える設計で、並行GC はアプリケーションが書き換えた参照を追いかけるために、書き込みのたびに小さな追加処理(ライトバリア)を払います。「スループット(総処理量)を取るか、レイテンシ(停止の短さ)を取るか」は GC チューニングの中心的なトレードオフです。

GC があってもメモリリークは起きる

見落とされがちですが、GC はメモリリークを根絶しません。GC が回収できるのは「届かなくなった」ものだけであり、「もう使わないのに参照が残っている」ものは生きていると判定され続けるからです。典型例は、グローバルなキャッシュや Map に登録したまま消し忘れたオブジェクト、解除し忘れたイベントリスナー、JavaScript のクロージャが意図せず大きなデータをつかみ続けるケースです。GC のある言語でメモリリークを調べるときは、ヒープダンプを取って「何が・どこからの参照で生き残っているか」をたどるのが定石です。

「参照はしたいが、生かし続けたくはない」という場面のために、多くの言語は弱参照を用意しています。弱参照は GC の到達可能性の判定に数えられない参照で、ほかに強い参照がなくなれば、弱参照が残っていても回収されます。JavaScript の WeakMap は、キーのオブジェクトが回収されると対応する値も一緒に消える表で、DOM 要素やオブジェクトに付随するデータのキャッシュに向いています。WeakRef は回収されたかもしれない対象を後から確かめる参照、FinalizationRegistry は回収されたあとに後始末を登録する仕組みです(呼ばれる時期は保証されないので、必須の後始末には使わない)。Java の WeakReference、Python の weakref も同じ考え方です。

別解 — Rust の所有権

GC のコストを避けつつ手動管理の事故も防ぐ第三の道として、Rust の所有権システムがあります。「各値には所有者がただ1つ存在し、所有者がスコープを抜けたら解放される」というルールをコンパイラが静的に検査することで、実行時の GC なしにメモリ安全を保証します。解放タイミングがコードから決定的に読め、停止時間もゼロですが、その代わり所有権と借用のルールを開発者が学び、コンパイラと交渉しながら書く必要があります。「実行時に機械が回収する(GC)」「コンパイル時に機械が検証する(所有権)」「人間が管理する(手動)」という3つの選択肢の並びで捉えると、GC の位置付けがはっきり見えてきます。

試問

左は参照カウント(自分を指す矢印の数が 0 になった瞬間に回収する)、右はマーク&スイープ(GC のときにルートの変数から矢印をたどり、届かない箱をまとめて回収する)で、同じ参照グラフが置かれています。矢印の ✂ を押すと、両方の図から同じ参照が消えます。右の「GC を実行」で、マーク&スイープの回収が走ります。

✦ 命題挑戦中

参照を1本だけ切って、参照カウントでは1つも回収されないのに、マーク&スイープの GC では回収される箱を作れ

  • 切った参照は 1本だけ
  • 参照カウント: 回収は 0 個なのに、ルートから届かない箱が残る
  • マーク&スイープ: GC を実行して、その箱を回収した
⚡ 体験: 参照カウントだけが見逃す箱を作る
参照カウント数が 0 になった瞬間に回収するルートab✂✂✂✂✂✂✂✂A1B1C1D2E2F1回収 0 個 / たどれないのに残る 0 個マーク&スイープGC のときにルートからたどるGC を実行ルートab✂✂✂✂✂✂✂✂ABCDEF回収 0 個 / 灰色(次の GC を待つ) 0 個

左右は同じ参照グラフです。矢印の ✂ を押して参照を切ると、両方の図から同じ矢印が消えます。