こんにちは。日々のWebアプリケーション開発、本当にお疲れ様です。
Java、Go、あるいはPythonといった他のモダンな高水準言語を深く経験されたあなたなら、「PHPはリクエストが終わればメモリが全開放されるから、メモリリークなんて無縁だよね」という神話を、どこかで疑ったことがあるはずです。
その直感は完全に正しい。しかし、もしあなたが「長寿命なデーモンプロセス(SwooleやRoadRunner、あるいはReactPHPなど)」を用いた非同期・常駐型のPHPアプリケーションの設計や、数百万件のレコードを処理する巨大なバッチ処理に足を踏み入れた途端、その「安心感」は静かな悪夢へと変わります。
今日は、PHPの心臓部であるZendエンジンが、メモリとどう向き合い、どのようにガベージコレクション(GC)を行っているのか。その知られざる裏側を、一緒に紐解いていきましょう。ここを理解すると、PHPの挙動がまるで透き透るように美しく見えてきますよ。
—
1. Zendエンジンにおけるメモリ管理の基本:参照カウントの光と影
PHPの変数やオブジェクトは、すべて裏側で `zval`(Zend Value)というC言語の構造体としてメモリ上に存在しています。この `zval` の中には、その値が今いくつ変数から参照されているかを示す「参照カウント(`refcount`)」という小さな整数値が常に保持されています。
例えば、次のようなコードを考えてみましょう。
「参照カウント法(Reference Counting)」をメモリ管理の主軸に置いています。
リクエストのライフサイクルが短い一般的なWebサーバ(Nginx + PHP-FPM)のモデルであれば、この参照カウント方式だけでも大きな破綻は起きませんでした。なぜなら、リクエストが終了した瞬間に、OSやSAPI層がプロセスごとメモリをごっそり回収してしまうからです。
しかし、ここで一つの致命的な問題が立ち塞がります。それが「循環参照(Circular Reference)」です。
—
2. 参照カウントの限界:なぜ循環参照は「自力で」消えないのか?
オブジェクト同士が互いをプロパティとして持ち合うことで、次のような美しい(しかし危険な)ループが生まれることがあります。
child = $child;
$child->child = $parent; // 親が子を持ち、子が親を持つ
// ここで変数を破棄(あるいはスコープアウト)
unset($parent, $child);
このコードを実行したとき、Zendエンジンの内部で何が起きているでしょうか?
`unset($parent, $child)` を行うと、それぞれの `zval` の参照カウントは `2` から `1` にデクリメントされます。
しかし、カウントは「0」にはなりません。 お互いが互いを指し合っているため、メモリ上には「誰も外部からアクセスできないけれど、お互いの参照カウントが1残っている孤島(メモリリーク)」が取り残されてしまいます。これが、参照カウント方式が抱える宿命の限界です。
—
3. PHP 7以降の救世主:「バッファ式ガベージコレクション」のメカニズム
「じゃあ、PHPのGCは何をやっているんだ?」という話になりますよね。
PHP 7、そして近年のPHP 8に至るまで、ZendエンジンのGCアルゴリズムは非常に洗練されて進化を遂げました。
PHPのGCは、すべての変数を常時監視しているわけではありません。もしそんなことをすれば、Webアプリケーションのパフォーマンスは地に落ちてしまいます。代わりに、Zendエンジンは次のような巧妙な戦略をとっています。
1. ルートバッファ(Root Buffer)への登録
配列やオブジェクトなどの「複合型(Compound Types)」の参照カウントが減ったとき、もしそのカウントが0にならずに「減った」だけであれば、「こいつは将来的に循環参照の孤島になるかもしれない候補だ」とみなされ、ルートバッファと呼ばれる専用のリンクリストにプッシュされます。
2. バッファが溢れたら収集開始(Lazy Evaluation)
このルートバッファが一定数(デフォルトでは10,000エントリー)に達すると、初めてGCのアルゴリズム(Concurrent Cycle Collection Algorithm)が発動します。
3. 推測的削除と深さ優先探索
GCが走ると、エンジンはバッファ内のルートからグラフ構造を辿り、次のような巧妙なステップを踏みます。
- 一時的に、見つかったノードの参照カウントを「1つ減らしたもの」と仮定してシミュレーションする(推測的デクリメント)。
- もし、その結果として参照カウントが真に0になるノードがあれば、「これは循環参照のループ内だけで閉じている孤島だ」と断定する。
- 孤島と判定されたメモリ領域は、マークされて一網打尽に解放される。
この仕組みにより、従来のPHPで頭を悩ませて常駐プロセスを崩壊させていたメモリリークの大部分が、自動的にクリーンアップされるようになりました。
—
4. それでも防げない「検出漏れ」が発生するシナリオ
「おっ、じゃあPHP 8のGCに任せておけば、循環参照は完全に怖くないんだな!」と思われたかもしれませんが、ここからがエンジニアの腕の見せ所です。実は、特定の条件下では、この優秀なGCすらも循環参照を見逃してしまう(検出漏れを起こす)ケースが存在します。
その代表例が、「シリアライズデータやグローバルな状態、あるいはマジックメソッド(`__destruct`)が絡む複雑な参照グラフ」です。
例えば、オブジェクトが独自のデストラクタ(`__destruct()`)を実装している場合、Zendエンジンはそのオブジェクトの解放順序に極めて慎重になります。もし循環参照の中に `__destruct` を持つオブジェクトが混ざっていると、エンジンは「どの順番でデストラクタを走らせるべきか」を安全に決定できず、GCの対象から一時的、あるいは永久的に除外せざるを得なくなるケースがあります。
また、C言語レベルの拡張モジュール(例えば、一部の古いデータベースドライバや、不適切に書かれた独自拡張)の中で、Zendのメモリ管理機構をバイパスして直接 `emalloc` や `efree` を行っている場合も、参照カウントとGCの整合性が狂い、メモリリークの温床となります。
実務で遭遇するアンチパターンの例
常駐型フレームワーク(Swooleなど)で、リクエストをまたぐシングルトンや静的プロパティに、以下のような構造を持たせてしまったと想像してください。
class SessionContext {
public static ?self $instance = null;
public mixed $userData = null;
public function __destruct() {
// デストラクタ内で何かを行っている、あるいは複雑な参照を持つ
}
}
// リクエストごとに重いオブジェクトをグローバル(静的プロパティ)に保持し続ける
もし、この `userData` の中にリクエスト固有のコンテキスト(リクエストオブジェクト、レスポンス、ロガー、そしてそれらをラップしたDIコンテナへの逆参照など)が含まれており、それが巡り巡って `SessionContext` 自身を指すような循環を作ってしまった場合……。
リクエストが終了しても、静的プロパティの寿命は「プロセスの生存期間」に依存するため、GCはこれを「まだ生きている有効なルート」と誤認し、ループ全体を回収できずにメモリリークを蓄積させていきます。数万リクエストをこなした頃に、ジワジワとメモリを食いつぶしてプロセスがOOM (Out of Memory) でクラッシュする、お馴染みの悪夢の完成です。
—
5. アーキテクトとして知っておくべき「守りの設計」
こうしたPHPのメモリ管理とGCの限界を踏まえた上で、私たちはコードをどう設計すべきでしょうか。
1. 常駐型アプリケーションでは「循環」を作らない
SwooleやRoadRunnerを使った環境では、オブジェクトのプロパティに「親への逆参照(`$parent` や `$container` への参照)」を持たせる設計(いわゆる双方向バインディング)を極力避けてください。必要であれば、弱参照(`WeakReference`)を活用しましょう。PHP 7.4以降であれば、`WeakReference` を使うことで参照カウントを増やさずにオブジェクトを指すことができます。これは循環参照を防ぐための最強の武器です。
2. 必要に応じた手動GCの強制(`gc_collect_cycles()`)
大量のデータを一括処理するバッチスクリプトのループ内などでは、メモリが膨れ上がる前に意図的に `gc_collect_cycles()` を呼び出し、バッファを強制清掃するアプローチが極めて有効です(※ただし、毎ループ呼び出すとCPUコストが跳ね上がるため、数千件ごとのバッチ単位で実行するのがコツです)。
—
おわりに
PHPのガベージコレクションと参照カウントの仕組みは、一見すると黒魔術のように複雑に見えますが、その裏側にあるのは「限られたリクエスト時間の中で、いかに高速にメモリを使い捨てるか」という徹底的な実用主義の思想です。
「ここをこう書くと、Zendエンジンの `zval` はこういう挙動をして、こういう参照グラフを描くはずだ」——そうやって脳内でコードの実行エンジンをトレースできるようになると、あなたの書くPHPコードは、ただ動くだけのコードから、極限まで洗練された堅牢なシステムへと生まれ変わります。
ぜひ、次の設計の際には、メモリの向こう側にあるZendエンジンの息吹を感じ取ってみてくださいね。