こんにちは。普段、LaravelやSymfonyといったモダンなフレームワークを使いこなし、「PHPって、リクエストが終わればメモリは綺麗に全解放されるんでしょ?」と安心してコードを書いているあなたなら、もしかしたら一度はこう思ったことがあるかもしれません。
「なぜ、何百万件ものバッチ処理や、長寿命なデーモンプロセス(SwooleやRoadRunnerなど)を動かすと、徐々にメモリがじわじわと食いつぶされていくんだろう?」と。
現代のPHP(PHP 8以降)は、OPcacheとJIT(Just-In-Time)コンパイラを手に入れ、C言語やRustに迫るほどの驚異的な実行速度を叩き出すようになりました。しかし、どれほどエンジンが高速化しようとも、PHPの根底にある「メモリ管理とライフサイクルの哲学」を理解していなければ、高負荷なプロダクション環境で突然のOOM(Out of Memory) Killerの餌食になってしまいます。
今回は、PHPの裏側で最も神秘的かつシビアな領域である「循環参照がJITコードとガベージコレクション(GC)に与える影響」について、Zend VMの低レイヤの挙動まで降りて、一緒に解き明かしていきましょう。
ここを理解すれば、PHPの裏側がまるでスケートリンクの上のように美しく見通せるようになりますよ。
—
1. そもそもPHPのメモリは、リクエスト終了時に本当に「全解放」されるのか?
多くの開発者は、「Webサーバー(Nginx + PHP-FPM)の1リクエストが終われば、プロセスが保持していたメモリはOSに一括返却されるから大丈夫」と考えています。半分は正解ですが、半分は危険な誤解です。
現代のPHPアーキテクチャ、特にJITコンパイラや長期稼働するプロセス(CLI、Swoole、ReactPHPなど)において、メモリの解放メカニズムはそれほど単純ではありません。
Zendエンジンにおける変数の実体(Zval)
PHPのすべての変数は、C言語の構造体である `zval`(Zend Value)として表現され、ヒープメモリ上に展開されます。そして、配列やオブジェクトといった複合データ構造は、`HashTable`というハッシュテーブルを使ってキーと値を管理しています。
ここで問題になるのが、オブジェクト同士が互いを指し示す「循環参照(Circular Reference)」です。
class Node {
public ?Node $child = null;
}
$a = new Node();
$b = new Node();
// 循環参照の発生
$a->child = $b;
$b->child = $a;
// $a と $b のスコープを外れる(unset または変数の寿命切れ)
通常の変数であれば、参照カウント(Reference Count)が `0` になった瞬間に `zval` は即座に解放されます。しかし、上記の循環参照の場合、$a と $b の参照カウントはそれぞれ「1」残ったままになります。スコープが消滅しても、お互いを指し合っているため、参照カウントが `0` にならず、即座には解放できない「孤立した島(Root)」が生まれてしまうのです。
—
2. ガベージコレクション(GC)の裏側の働き
PHPには、この循環参照を掃除するための「循環参照ガベージコレクタ(Concurrent Cycle Collector)」が備わっています。
PHPのGCは、すべての `zval` を常時監視しているわけではありません。パフォーマンス低下を防ぐため、以下のようなアルゴリズムで動いています。
1. バッファへの蓄積(ルーツバッファ):
参照カウントが「減ったけれど 0 にならなかった」コンテナ型(配列やオブジェクト)の `zval` は、GCの「ルーツバッファ(候補リスト)」に一旦登録されます。
2. バッファがいっぱいになると発動:
このバッファがデフォルトのサイズ(通常は10,000エントリ)に達すると、GCエンジンがフル稼働します。
3. 三色マーキング法による回収:
GCはバッファ内のオブジェクトを辿り、意図的に参照カウントを一時的に増減させることで、「本当にお互い閉じられた世界で孤立しているか」を判定し、孤立していればメモリを解放します。
ここにJITが絡むと、何が起きるのか?
PHP 8で導入されたJITコンパイラは、バイトコード(OPcache)をネイティブの機械語(x86/ARMのCPU命令)に翻訳し、専用のメモリ領域(JIT Buffer)に配置します。
ここで重要なのは、「JITが生成した機械語コードやメタデータもまた、PHPプロセス全体のヒープ/共有メモリ領域を消費し続ける」という点です。
もし、循環参照を含む複雑なオブジェクト構造や、動的なメタコード生成を伴う処理が長時間(あるいは数百万リクエスト)にわたってGCの回収漏れを起こし続けるとどうなるでしょうか?
- OPcache/JITバッファの圧迫: JITが生成したホットコードのキャッシュが追い出され、コンパイルのオーバーヘッド(JIT Thrashing)が増加する。
- メモリフラグメンテーション(断片化): Zendメモリマネージャー(ZendMM)が細切れのメモリ確保・解放を繰り返すことで、物理メモリ上の空きはあるのに確保できない状態に陥る。
—
3. 実践:JIT環境下でのメモリリークを脳内トレースする
では、実際にメモリリークを引き起こしやすいコードの構造と、それを回避するモダンな設計を見てみましょう。
以下のコードは、長寿命プロセスや重いバッチ処理でやりがちな、循環参照とJITの相性が最悪なパターンです。
registry[$key] = $service;
}
}
class Service {
public function __construct(
private Context $context
) {}
}
// — メモリリークを引き起こすシミュレーション —
function runHeavyProcess(): void {
$context = new Context();
// 循環参照の構築: Context -> Service -> Context
$service = new Service($context);
$context->register(‘main_service’, $service);
// JITがこの関数を「ホットスポット」と判定し、ネイティブコードにコンパイルする
// この中で大量のオブジェクト生成と循環参照が繰り返されると…
}
// デーモンループや長寿命スクリプトを想定
for ($i = 0; $i < 100000; $i++) {
runHeavyProcess();
// 明示的なGCの呼び出しがない、あるいは循環参照のせいで
// ZendMMが即座にメモリを解放しきれない領域が発生する
}
このコードでは、`Context` と `Service` が強固な循環参照を結んでいます。PHP-FPMの通常の短命なリクエストであれば、リクエスト終了時にプロセスごとOSへメモリが返還されるため表面化しません。
しかし、これがSwooleやRoadRunnerを用いた常駐型アプリケーション、あるいは数時間動き続ける巨大なCLIバッチであった場合、GCが回収するまでの間、あるいは回収しきれなかった微小なメタデータがJITの実行コンテキスト周辺に蓄積し、着実にメモリを食いつぶしていきます。
—
4. アーキテクトが教える、JITとGCを守るための極意
PHPのパフォーマンスを極限まで引き出しつつ、メモリリークの悪夢から逃れるためには、以下の設計原則を徹底してください。
① 弱参照(WeakReference)を積極的に採用する(PHP 7.4+)
親が子を所有するのではなく、「参照したいけれど、ライフサイクルには責任を持ちたくない」という場合は、PHP 7.4で導入された `WeakReference` を使いましょう。これによって、循環参照の連鎖を断ち切ることができます。
contextRef = WeakReference::create($context);
}
public function getContext(): ?Context {
return $this->contextRef?->get();
}
}
このように、`WeakReference` を挟むことで、オブジェクトが不要になった瞬間に参照カウントが正しく `0` になり、GCのルーツバッファを汚さず、JIT環境下でもクリーンなメモリ状態を維持できます。
② 長寿命プロセスでは明示的なGC制御を行う
Swooleなどの非同期・常駐フレームワークを書く場合、フレームワークのイベントループの合間に、強制的にガベージコレクションを走らせる設計を組み込むことが有効です。
// 定期的な、あるいはリクエストの節目でのGC強制実行
$collected = gc_collect_cycles();
// 必要であれば、ZendMMのキャッシュをクリーンアップ
ただし、毎回 `gc_collect_cycles()` を呼ぶのはCPUコストがかかるため、「1000リクエストに1回」などのスロットリングを設けるのがシニアアーキテクトの知恵です。
—
5. まとめ:PHPの裏側を支配する者
今回は、PHPのガベージコレクションとJITコンパイラのメモリ配置、そして循環参照が引き起こす隠れたリスクについて深く潜ってみました。
- 循環参照は `zval` の参照カウントをバグらせ、GCのルーツバッファを圧迫する。
- JITコンパイラが有効な現代のPHPにおいて、メモリの断片化やリークは、単なる変数バグを超えてCPUキャッシュ効率の低下やJITスネーク(JITスラッシング)を誘発する。
- WeakReferenceや適切なライフサイクル設計によって、エンジンに無駄な負荷をかけない美しいコードを書くことができる。
「動けばいい」の先の領域――PHPのZend VMがメモリ上でどう息づいているのかを感じ取れるようになると、あなたの書くコードは驚くほど堅牢で、かつ無駄のない洗練されたものに変わります。
さあ、次のデプロイでは、裏側のエンジンまで見通した美しいアーキテクチャを奏でてみませんか?