第5章と第6章で、私たちは malloc でヒープにメモリを確保し、free で返すことを学んだ。だが、PythonやJava、JavaScript、Goを書くとき、あなたは free を書いた記憶があるだろうか。無いはずだ。
data = [i for i in range(1000000)] # 大量のメモリを確保
data = None # もう要らない…が free は書かない
# ……いつの間にか、メモリは解放されている
free を書いていないのに、メモリは枯渇しない。誰かが、あなたの見えないところでゴミを片付けている。その正体がガベージコレクション(GC)だ。この章で、その裏方の仕事を解体する。
手動管理という地獄 — 歴史の必然
C言語では、確保したメモリは自分で free する義務がある。だがこれは、人類にとって恐ろしく難しい責務だった。三大災厄がある。
- メモリリーク:
freeを忘れる → メモリが返らず、じわじわ枯渇する - ダングリングポインタ: まだ使うのに
freeしてしまう → 解放済み領域を触って未定義動作(第6章) - 二重解放: 同じ領域を2回
free→ ヒープ管理構造が壊れる
char *p = malloc(100);
free(p);
free(p); // 💥 二重解放。ヒープが壊れる
strcpy(p, "x"); // 💥 解放済みへの書き込み(use-after-free)
これらは発見が難しく、セキュリティ脆弱性の温床にもなった(use-after-freeは今もCVEの常連だ)。「メモリの後片付けを、人間の注意力に任せるのは限界がある」——この反省から、片付けを自動化する仕組みが求められた。それがGCだ。
核心となる問いはただ一つ。「そのメモリは、まだ使われているか?」。GCの全アルゴリズムは、この問いへの答え方の違いにすぎない。
厨房のアナロジー — 使っていない皿を下げる
営業中の厨房を思い浮かべてほしい。調理台には次々と皿やボウル(オブジェクト)が置かれていく。放置すれば台は皿で埋まり、料理ができなくなる(メモリ枯渇)。
「まだ使う皿」と「もう使わない皿」をどう見分けるか。
- 参照カウント方式: 各皿に「今この皿を使っている人数」の付箋を貼る。誰も使わなくなった(0人)皿を即座に下げる。
- マーク&スイープ方式: 一旦手を止め、「今コックが手に持っている皿」から辿れる皿すべてに印をつけ、印のない皿を一斉に下げる。
私が働いた店の洗い場では、後者に近い運用だった。ピークが一段落したら、シンク周りを見渡して「もう誰も使っていない器」をまとめて洗い場へ下げる。使用中の器(誰かが持っている、コンロにかかっている)は残す。GCの「到達可能性」判定は、この「誰かが今も手に取れるか」の判定そのものだ。
参照カウント — 「使っている人数」を数える
最も直感的な方式が参照カウント(Reference Counting)だ。各オブジェクトに「自分を指している参照の数」を持たせ、増減させる。カウントが0になった瞬間、そのオブジェクトは誰からも辿れない=ゴミなので、即座に解放する。Python(CPython)の基本方式がこれだ。
import sys
a = [1, 2, 3] # リストの参照カウント = 1
b = a # 別名で参照 → カウント = 2
print(sys.getrefcount(a)) # 3 と表示(getrefcount自身の一時参照で+1)
del b # 参照を1つ削除 → カウント = 1
del a # カウント = 0 → 即座に解放される
利点は即座に回収でき、動作が予測しやすいこと。欠点は二つ。第一に、参照のたびにカウント更新のコストがかかる。第二に、循環参照を回収できない。
a = {}
b = {}
a['ref'] = b # a が b を指す
b['ref'] = a # b が a を指す(循環!)
del a
del b # 変数は消えたが、a↔b が互いを指し合い、カウントが 0 にならない
# → 誰からも使えないのに、永遠に解放されないゴミ
この循環参照のゴミを掃除するため、CPythonは参照カウントに加えて、後述の到達可能性ベースのGCを併用している。
マーク&スイープ — 「辿れるか」で判定する
循環参照に強いのがマーク&スイープ(Mark & Sweep)だ。発想はこうだ。カウントを数えるのをやめ、「今も辿り着けるか」を直接調べる。
出発点は GCルート——今まさにプログラムが直接触れる場所だ。スタック上の変数(第5章)、グローバル変数、CPUレジスタなど。ここから参照を辿れるオブジェクトはすべて「生きている(到達可能)」。辿り着けないものは、どこからも使えない「ゴミ(到達不能)」だ。
処理は2段階だ。
① Mark(印付け): GCルートから参照グラフを辿り、到達したオブジェクトに印
② Sweep(掃除): ヒープ全体を走査し、印の無いオブジェクトを解放
GCルート
│
▼
[A]──▶[B]──▶[C] A,B,C は到達可能 → 生存(印○)
[X]◀──▶[Y] X,Y は互いに指し合うが、ルートから
辿り着けない → ゴミ(印なし)→ 回収 ✓
循環参照 X↔Y も、ルートから辿れなければ問答無用でゴミと判定できる。参照カウントの弱点が、視点を変えるだけで解決する。到達可能性という一つの原理は、これほど強力だ。
Pythonで到達可能性を実感する
import gc
class Node:
def __init__(self):
self.ref = None
a = Node()
b = Node()
a.ref = b # 循環参照を作る
b.ref = a
del a
del b # 参照カウントは 0 にならない(循環)
# だが、到達可能性ベースのGCが循環ゴミを回収できる
collected = gc.collect() # 手動でGCを起動
print(f"回収した循環ゴミ: {collected} 個") # > 0
世代別GC — 「若者はすぐ死ぬ」という経験則
マーク&スイープは強力だが、毎回ヒープ全体を走査するのは重い。そこで効いてくるのが、経験的に観測された強力な法則——世代仮説(generational hypothesis)だ。
ほとんどのオブジェクトは、生成後すぐに使われなくなる(若くして死ぬ)。
ループ内の一時変数、計算途中の中間オブジェクト……大半は一瞬で用済みになる。逆に、長く生き残ったオブジェクト(設定、キャッシュ等)は、その後も生き続ける傾向がある。
この法則を利用するのが世代別GC(Generational GC)だ。オブジェクトを「年齢」で分ける。
- 新世代(Young): 生まれたばかり。ここを頻繁に、しかし狭い範囲だけGCする(Minor GC)。ほとんどがゴミなので効率が良い
- 旧世代(Old): 何度かのGCを生き延びた古株。稀にしかGCしない(Major GC / Full GC)
新世代(頻繁に掃除、大半がゴミ) 旧世代(稀に掃除、長寿)
┌───────────────────┐ ┌───────────────────┐
│ 一時オブジェクト大量 │ 昇格→ │ 設定・キャッシュ等 │
│ (すぐ死ぬ) │ 生き残り │ (長生き) │
└───────────────────┘ └───────────────────┘
Minor GC: 高頻度・低コスト Major GC: 低頻度・高コスト
若い領域だけを頻繁に掃除することで、全体走査の回数を激減させる。JavaのHotSpot VM、Go、V8(JavaScript)、.NET——現代の主要ランタイムはこの世代別GCを採用している。JVMのチューニングで「Young領域を広げる」といった話が出るのは、この構造の調整だ。
Stop the World — GCがアプリを「止める」理由
ここで、GCの最大の代償に触れる。マーク中に、アプリ本体がオブジェクトの参照を書き換えたらどうなるか。GCが「印付け中」なのに参照グラフが変わると、まだ生きているオブジェクトをゴミと誤判定しかねない。これは致命的なバグだ。
これを防ぐ最も単純な方法が、Stop the World(STW)——GCの間、アプリのスレッドを全部止めることだ。世界が静止していれば、参照グラフは動かず、安全に印付けできる。
だが、止まっている間、アプリは一切応答しない。ヒープが巨大だと、このGC停止(GCポーズ)が数百ミリ秒〜秒に達し、「時々カクッと固まる」原因になる。Webサービスのレイテンシスパイクの犯人が、実はGCポーズだった、というのはよくある話だ。
そこで現代のGCは、この停止時間を削るために激しく進化してきた。
- 並行GC(Concurrent): アプリを動かしたまま、GCも並行して印付けを進める(停止時間を最小化)
- リージョン分割: ヒープを小領域に分け、少しずつ回収する(JavaのG1GC、ZGC、ShenandoahはSTWをミリ秒以下に抑える)
「Go言語のGCは停止時間が短い」「JavaのZGCは超低レイテンシ」といった宣伝文句は、このSTWをいかに削るかの競争を指している。
そして、GCを持たない道 — Rust
GCは便利だが、実行時のコスト(CPU・停止時間)を伴う。ならばそもそもGCを不要にできないか。その答えがRustの所有権システムだ。第6章の最後で触れたように、Rustは「誰がそのメモリを所有するか」をコンパイル時に厳密に追跡し、所有者がスコープを抜けた瞬間に自動で解放する。実行時のGCなしに、free 忘れもuse-after-freeもコンパイルの段階で防ぐ。「GCの安全性」と「手動管理の速さ」を両取りしようという、もう一つの解答だ。
まとめ — 「おまじない」の消し方
- 手動管理は限界だった:リーク・ダングリング・二重解放。人間の注意力に頼るのは危険すぎた
- すべては到達可能性:「まだ使われているか」を、参照カウントで数えるか、ルートから辿れるかで判定する
- マーク&スイープは循環に強い:辿り着けないものはゴミ。視点を変えれば循環参照も回収できる
- 世代別GCは経験則の活用:「若者はすぐ死ぬ」を利用し、若い領域を頻繁・狭く掃除して効率化
- STWがレイテンシの敵:安全のためアプリを止める。現代のGCは並行化・領域分割で停止時間を削る競争をしている
「なぜ free を書かなくてよいのか」「なぜアプリが時々カクつくのか」——その答えは、あなたの見えないところで働く裏方の仕事にあった。これでPart 4——大量の要求・永続化・ランタイムの世界——を一巡した。
次のPart 5では、視点を1台のマシンからマシンとマシンの間へ移す。あなたのリクエストは、どうやって地球の裏側のサーバーまで、壊れず届くのか。次章、TCP/IPの物理へ。
「片付けとは、記憶の技術だ。何を捨ててよいかを知るには、何がまだ必要かを知らねばならない。GCが解いているのは、掃除の問題ではなく、『まだ生きているものは何か』という問いなのだ。」