こんにちは。普段、他の高水準言語(JavaやGo、Node.jsなど)をバリバリ書きこなしているあなたなら、PHPのコードを書くときに「あれ、ガベージコレクション(GC)ってどうなってるんだっけ?」とふと気になったことがあるかもしれませんね。
多くの言語では、開発者はメモリ管理の細部を意識せず、ランタイムにすべてを委ねています。しかし、PHP(Zend Engine)の世界に足を踏み入れると、1リクエスト・1スレッド(厳密にはマルチプロセスモデル)のライフサイクルの中で、メモリがいかに効率よく、かつシビアに管理されているかを知る必要があります。
今回は、PHPのメモリ管理の根幹である「参照カウント(Refcount)」と、そこから逃れられない「循環参照」、そして「ガベージコレクタ(GC)の真の挙動」について、Zend VMの内部構造を覗き見ながら、優しく紐解いていきましょう。ここを理解すると、PHPの裏側がスッキリと美しく見えてきますよ。
—
1. Zend VMの土台:すべての変数は `zval` と参照カウントでできている
PHPの内部(C言語レベル)では、私たちが扱うすべての変数や値は `zval`(Zend Value)という構造体として表現されています。
モダンなPHP 8系では値のインライン化や最適化が進んでいますが、配列(Array)やオブジェクト(Object)といった複合データ型は、ヒープメモリ上に動的に確保され、その実体は `zval` 内のポインタを通じて管理されています。
ここで重要になるのが、「参照カウント(`refcount`)」という概念です。
/ 概念的なイメージ(Zend Engine内部のzval構造体の一部) /
typedef struct _zval_struct {
zend_value value;
union {
uint32_t vtype_info;
/ … /
} u1;
union {
uint32_t next;
uint32_t cache_slot;
uint32_t refcount; // ← ここで参照数を数えている!
} u2;
} zval;
PHPで変数を別の変数に代入したとき、メモリ上で何が起きているでしょうか?
基本的には、コピー・オン・ライト(Copy-on-Write: CoW)という最適化が働き、実体をすぐに複製するのではなく、同じヒープ領域を指し示しながら参照カウントを「+1」します。
$a = [‘extremely’, ‘large’, ‘array’]; // 配列がヒープに生成され、refcount = 1
$b = $a; // 実体はコピーされず、refcount = 2 にインクリメントされる
この仕組みのおかげで、PHPはメモリを無駄に消費せず、高速に動作しています。そして、変数のスコープを抜ける(関数が終了するなど)と、Zend VMは参照カウントを「-1」デクリメントします。もし参照カウントが `0` になれば、即座にその場でメモリが解放されます。これがPHPのメモリ管理の基本原則です。
—
2. なぜメモリリークが起きるのか?:参照カウントの「致命的な弱点」
「じゃあ、参照カウントが0になったら自動で消えるなら、メモリリークなんて起きないのでは?」と思いますよね。
その通り、通常のツリー構造や単方向の代入であれば、メモリリークは起きません。問題は、「自分自身を指してしまう(循環参照)」という特殊なケースです。
次のコードを見てみてください。
class Node {
public ?Node $parent = null;
public array $children = [];
}
// 1. 親と子を生成
$parent = new Node();
zeta_log_memory(); // メモリ使用量を記録する仮想関数とします
// 2. 子を生成し、親を紐付ける
$child = new Node();
$child->parent = $parent; // 親への参照を持つ
// 3. 親に子を追加する
$parent->children[] = $child; // 子への参照を持つ(ここで循環が完成!)
// 4. スコープを抜ける、あるいは変数を破棄する
unset($parent, $child);
このとき、内部のメモリ上では何が起きているでしょうか?
1. `$parent` は `$child` を保持しているため、`$child` の参照カウントは `2`(親からの参照 + 配列内の参照)になります。
2. `$child` は `$parent` を `$parent` プロパティで保持しているため、`$parent` の参照カウントも `2`(変数 `$parent` からの参照 + 子からの参照)になります。
3. ここで `unset($parent, $child)` を実行すると、変数シンボルテーブルからの参照は消えますが、お互いがお互いを指し合っているため、それぞれの参照カウントは `0` にならず、 `1` のまま残ってしまいます。
参照カウントが `1` なので、Zend VMは「まだどこかから使われているんだな」と勘違いし、メモリを解放しません。これが、PHPにおけるメモリリークの正体です。
—
3. 救世主「循環参照コレクタ」の仕組みとバッファリング
「じゃあ、循環参照が起きたらPHPは永遠にメモリを解放できないの?」
ご安心ください。PHPには、参照カウント方式の弱点を補うために、「循環参照コレクタ(Cycle Collector)」が組み込まれています。
このコレクタは、すべての変数を常に監視しているわけではありません。もしそんなことをすれば、Webアプリのパフォーマンスが致命的に低下してしまいますよね。
Zend Engineは、次のような巧妙なアルゴリズムで動作しています。
1. バッファリング(Root Buffer)
配列やオブジェクトなどの「コンテナ型(他の値を内包できる型)」の参照カウントが減算されたとき、その `zval` が「もしかしたら循環参照の輪から切り離された孤児(Root)かもしれない」と推測し、「ルートバッファ(Root Buffer)」という専用のリストに一時的に登録します。
2. バッファがいっぱいになるか、明示的に呼び出されると発動
バッファがある程度(デフォルトでは10,000エントリなど)溜まるか、`gc_collect_cycles()` が呼ばれると、コレクタが本格的な回収作業(GCの実行)を開始します。
3. 三色マーキング法による検出と回収
コレクタは、バッファに登録された変数を出発点として、グラフ探索を行います。
- 深さ優先探索(DFS)を行い、見つかった変数の参照カウントを一時的に `-1` します(「疑似減算」)。
- すべて巡回した結果、もし参照カウントが `0` になった変数があれば、それは「外部からの参照を一切失い、内部の循環だけで支え合っている孤立したグループ」であると断定されます。
- 断定されたグループに対し、今度は逆に `+1` しながらマークを戻し、最終的に一網打尽にメモリから解放します。外部から健全な参照が残っているグループであれば、カウントを元に戻してそっとしておきます。
この仕組みがあるおかげで、複雑なオブジェクトグラフや双方向リンクを持つORM(EloquentやDoctrineなど)のエンティティ同士が絡み合っても、最終的には安全に回収されるようになっています。
—
4. アーキテクトが知るべき「現場の知見」とパフォーマンスチューニング
ここまでの話をベースに、実際のWeb開発やフレームワークの設計で気をつけるべきポイントをいくつかお伝えしますね。
① 長期稼働するプロセス(SwooleやRoadRunner、ReactPHPなど)での罠
従来のPHP-FPM(FastCGI Process Manager)モデルでは、1リクエストが終わればプロセスごとメモリ空間がOSに返却されるため、多少の循環参照リークがあってもリクエスト終了時にすべて綺麗に消え去りました。
しかし、SwooleやRoadRunnerなどの常駐型(Long-running)プロセスでは、リクエストをまたいでメモリが蓄積されます。もしコード内に意図しない循環参照(イベントリスナーの登録解除忘れ、グローバルコンテナへの汚染など)があると、リクエストを処理するたびにメモリがじわじわと増え続け、やがて `Allowed memory size exhausted` でクラッシュします。
常駐型アプリケーションを書くときは、使わなくなったオブザーバーやクロージャのバインド(特に `$this` をキャプチャする無名関数)に細心の注意を払いましょう。
② 大規模なバッチ処理での明示的なGC制御
数万件のレコードをループで処理するようなバッチスクリプトを書いたとき、メモリが膨れ上がって困った経験はありませんか?
これは多くの場合、オブジェクトの循環参照というよりも、バッファに溜まった `zval` や配列の解放タイミングがGCのバッファ溢れと同期していないことが原因です。
そんなときは、ループの適切な節目(例:1,000件ごと)で、次のように明示的にコレクタを走らせるか、ガベージコレクションを制御すると効果的です。
// バッチ処理のループ内
foreach ($hugeDataSet as $chunk) {
// 重い処理…
process_records($chunk);
// 必要に応じて明示的に循環参照コレクタを強制実行し、メモリを即座に解放する
gc_collect_cycles();
}
※注意:`gc_collect_cycles()` はCPUコストをそれなりに消費するため、毎ループ回すのではなく、一定のブロックごとに呼ぶのがプロの技です。
また、不要になったタイミングで `gc_disable()` で一時的にGCを止め、処理が終わったあとに一気に回収して `gc_enable()` に戻すというアプローチも、極限のパフォーマンスを絞り出すバッチ設計では使われることがあります(※基本はデフォルトの自動実行で十分です)。
—
5. まとめ
PHPのメモリ管理とZend VMの挙動、いかがでしたでしょうか。
- PHPの変数は `zval` と参照カウントによって美しく、かつ効率的に管理されている。
- しかし、お互いを見つめ合う循環参照が発生すると、参照カウントがゼロにならずメモリリークの温床になる。
- それを救うのが循環参照コレクタ(Root Buffer と三色マーキング)であり、自動で不要なグラフを切り離してくれる。
- 近年の常駐型アーキテクチャ(Swoole等)や巨大なバッチ処理では、この裏側の仕組みを理解しておくことが、安定したシステムを構築するカギになる。
「なんとなく動く」から「内部の挙動が手に取るようにわかる」へ。この視点を持つだけで、あなたの書くPHPコードの品質はワンランクもツーランクも跳ね上がります。
さあ、今日のデバッグや設計に、この知見をさっそく活かしてみませんか?