こんにちは。普段、LaravelやSymfonyなどのモダンなフレームワークを使いこなし、「ビジネスロジックは完璧なのに、なぜか高負荷時にメモリ使用量がじわじわと跳ね上がってFPMプロセスが重くなる……」そんな不可解な壁にぶつかったことはありませんか?
他のモダンな言語、例えばGoやNode.js、Pythonあたりを経験してきた優秀なエンジニアほど、PHPの「1リクエストが終われば全メモリが綺麗に解放される」という手軽さに甘えがちです。しかし、数千件のレコードを処理するバッチ処理や、常時稼働型のデーモン(RoadRunnerやReactPHPなど)を用いたアーキテクチャ設計では、このPHPのメモリ管理機構――Zend VMの参照カウント(Refcount)と循環参照コレクタの挙動を理解しているかどうかが、システム全体の生死を分ける境界線になります。
今回は、PHPのエンジン内部でメモリがどのように扱われ、なぜメモリリークが発生するのか、その本質をコードの裏側から紐解いていきましょう。ここを理解すれば、PHPの裏側が驚くほどクリアに見えるようになりますよ。
—
1. Zend VMのメモリ管理の根幹:`zval` と `refcount`
PHPの変数やオブジェクトは、C言語レベルの構造体である `zval`(Zend Value) というコンテナに包まれて管理されています。PHP 8の時代になっても、このメモリの基本単位の設計思想は変わっていません。
変数に値を代入したり、関数に引数を渡したりするとき、PHPは裏側でその `zval` が「何箇所から参照されているか」を数えています。これが 参照カウント(`refcount`) です。
1 に減るだけでメモリは解放されない
この「コピーオンライト(Copy-on-Write)」のおかげで、PHPはメモリを無駄に複製せず、高速に動作しています。そして、変数がスコープを抜けるなどして `refcount` が `0` になった瞬間、Zendエンジンは即座にそのメモリ領域をヒープから解放します。ここまでは非常に美しく、効率的な世界です。
—
2. 悪夢の始まり:循環参照(Circular Reference)の罠
しかし、オブジェクト指向プログラミングにおいて、この美しい「`refcount` が 0 になったら即解放」というルールを完全に破壊する魔物が存在します。それが 循環参照 です。
例えば、親と子が相互に参照し合うようなドメインモデルを考えてみてください。
child = $child;
$child->parent = $parent;
// この時点で、$parent の refcount は 2($parent変数 + $child->parent)
// $child の refcount も 2($child変数 + $parent->child)
// 3. ルート変数を破棄する
unset($parent, $child);
さて、ここで何が起きるでしょうか?
`unset($parent, $child)` を実行したことで、私たちが直接触れていた変数シンボルは消滅しました。しかし、内部の `zval` から見ると、互いに相手を指し合っているため、お互いの `refcount` が「1」残ったままになってしまいます。
[ $parent zval ] <---(お互いに参照)---> [ $child zval ]
(refcount: 1) (refcount: 1)
`refcount` が 0 にならないため、Zendエンジンはこの領域を通常の方法では解放できません。これが、ロングランプロセスや大量のリクエストをさばくPHPアプリケーションで発生する「静かなるメモリリーク」の正体です。
—
3. 救世主:GC(ガベージコレクション)のバッファリングメカニズム
「じゃあ、PHPにはGCがないのか?」というと、もちろんそんなことはありません。PHPには、Zend VMのパフォーマンスを落とさないための巧妙な遅延型ガベージコレクタが備わっています。
PHPのGCは、すべての変数を常時監視しているわけではありません。もしそんなことをすれば、Webアプリケーションのレスポンスタイムは致命的に悪化してしまいます。代わりに、GCは以下の戦略をとっています。
1. 疑わしいもののバッファリング
配列やオブジェクトなどの「複合型(Compound Types)」の `refcount` が減った際、もしその値が「0」にならずに「1以上減った(=内部に別の構造体を含んでいて、自己参照の可能性が生まれた)」場合、その `zval` を 「根 buffer(RCパープルバッファ)」 にこっそり登録します。
2. バッファが溢れたら掃除の合図
このバッファが一定数(デフォルトでは10,000エントリ)に達すると、あるいは明示的に `gc_collect_cycles()` が呼ばれたときに、初めて本格的なガベージコレクションのアルゴリズムが発動します。
内部での掃除のアルゴリズム(三色マーキング法的なアプローチ)
PHPのGCエンジンは、バッファに溜まった怪しい `zval` に対して、以下のステップで「本当のゴミ」を特定します。
- ステップ1(減算フェーズ): 候補となっている `zval` からたどれるすべての下流要素の `refcount` を、一時的に「1」ずつ減らします。
- ステップ2(マークフェーズ): もしその過程で `refcount` が `0` になった要素があれば、それは「外部からの参照が一切なく、循環参照の輪の中だけで支え合っているゴミ」だと判定し、別の色でマークします。
- ステップ3(回収フェーズ): マークされたゴミの `zval` を実際に解放し、メモリをヒープに戻します。
この仕組みがあるおかげで、通常のWebリクエストのライフサイクルであれば、メモリリークは一定の閾値内で自動的に回収されます。
—
4. 現場で私たちが意識すべき設計の極意
ここまでの内部挙動を踏まえて、実務の現場で私たちが気をつけるべきポイントを整理しておきましょう。
① デーモン型・非同期PHPでのリスク管理
Swoole、RoadRunner、ReactPHP、あるいはAmpのような常時稼働型のPHP環境では、1つのプロセスが数万件ものリクエストを処理します。もし、リクエストハンドラ内で親子関係や双方向リンクを持つ巨大なオブジェクトグラフを作り、それを適切にクリーンアップしなかった場合、GCが追いつく前にメモリが枯渇(OOM)します。
対策: 処理の終わりには必ず明示的に参照を切る(`$this->parent = null;` のようなデストラクタ的処理を入れる)か、`gc_collect_cycles()` を適切なタイミングで手動コールすることを検討してください。
② デバッグには `gc_status()` を活用する
「なんだかメモリの使用量がおかしいぞ」と感じたら、闇雲にコードを書き換えるのではなく、PHPの内部状態を覗いてみてください。
4 // GCが実行された回数
:[collected] => 1250 // これまでに回収された循環参照の数
:[threshold] => 10000 // バッファのしきい値
:[buffer_size] => 320 // 現在バッファにたまっている数
…
)
/
この `buffer_size` が異常な勢いで増加している場合、あなたのアプリケーションのどこかで、意図しない循環参照の山が高速に生成されています。
—
最後に:エンジンと対話するエンジニアへ
PHPは「初心者でも簡単に書ける言語」として進化してきましたが、その内部(Zend VM)は、C言語ベースの非常に洗練された極めて高度なメモリ管理システムの上に成り立っています。
「なぜこのコードでメモリが増えるのか?」という疑問に直面したとき、変数のライフサイクルや `zval`、そして `refcount` の増減を頭の中で脳内トレースできるようになると、あなたのコードは劇的に堅牢になります。
黒魔術のように見えるメモリリークも、エンジンの仕組みさえ分かってしまえば、ただの論理的なパズルに過ぎません。ぜひ、次のパフォーマンスチューニングの際には、このZend VMの鼓動を感じながらコードに向き合ってみてくださいね。