こんにちは。普段からPHPを使って大規模なWebアプリケーションを設計・運用していると、「本当にこのメモリ管理は効率的に行われているのだろうか」「循環参照を作ってしまったら、いつどのタイミングでメモリが解放されるのだろうか」と、ふと裏側の挙動が気になるときはありませんか?
JavaやGo、あるいはNode.js(V8)といった他の高水準言語のメモリ管理モデルに慣れ親しんだエンジニアほど、PHPの「1リクエストが終わればプロセスごとメモリが全解放される」というシンプルさに甘えがちです。しかし、長時間のデーモンプロセス(RoadRunnerやReactPHPなど)や、巨大なオブジェクトグラフを扱うバッチ処理を書き始めると、Zend VMのメモリ管理機構、特にガベージコレクション(GC)の内部実装を理解しているかどうかが、プロダクトの生死を分ける境界線になります。
今回は、PHPの心臓部であるZend VMが、どのようにメモリを監視し、不要になった循環参照を「マークフェーズ」と「スイープフェーズ」で刈り取っているのか。その低レイヤの真実を、一緒に紐解いていきましょう。ここを理解すると、PHPの裏側がまるで美しい精密機械のように見えてきますよ。
—
1. 参照カウントの限界と「バッファ(Buffer)」の正体
PHPのメモリ管理の基本は、すべての変数コンテナ(`zval`構造体)に備わっている「参照カウント(Reference Counting)」です。変数に値を代入したり、関数に引数として渡したりするたびに、その`zval`の参照カウンター(`refcount`)がインクリメントされ、スコープを抜ければデクリメントされます。
非常にシンプルで高速な仕組みですが、これには致命的な弱点があります。それが「循環参照(Circular Reference)」です。
child = $b;
$b->child = $a;
// $a と $b のスコープを外す(unsetする)
unset($a, $b);
このコードを実行したとき、$a と $b はどこからもアクセスできなくなります(ルートからのパスが途絶える)。しかし、お互いを指し合っているため、それぞれの `refcount` は `0` にならず、`1` のまま残ります。これがメモリリークの正体です。
Zend VMは、この「参照カウントが0にならないが、孤立した領域」を検知するために、PHP 5.3以降(PHP 7, 8を通じて洗練された)独自のガベージコレクションアルゴリズムを搭載しています。それが、疑わしい変数を蓄積する「ルートバッファ(Root Buffer)」です。
Zend VMは、`zval` の参照カウントが減算された際、もしその値が `0` にならず、かつ「複合型(配列やオブジェクト)」である場合、「こいつは将来的に循環参照の孤児になるかもしれない」と判断し、その `zval` へのポインタを ルートバッファ(二重連結リスト) に登録します。
—
2. マークフェーズ(Mark Phase):疑わしいグラフの探索
ルートバッファが一定量(デフォルトでは10,000エントリ)に達するか、あるいは開発者が明示的に `gc_collect_cycles()` を叩いた瞬間、Zend VMはメモリ回収の重い処理、すなわちガベージコレクションのサイクルを開始します。
その最初のステップが、マークフェーズ(Mark Phase)です。
マークフェーズのアルゴリズムの目的は、ルートバッファに溜まった候補たちが「本当に孤立しているか」を判定することです。Zend VMは、次のような巧妙な深さ優先探索(DFS)を行います。
1. 色づけ(Coloring – 紫のマーキング):
ルートバッファに含まれるすべての `zval` を巡回し、そのGCフラグを「紫色(`GC_PURPLE`:疑わしい)」に変更します。これにより、「今からチェックする対象だ」とマークされます。
2. 参照カウントの模擬的減算(Simulated Decr):
マークされた `zval` から伸びている子要素(オブジェクトのプロパティや配列の要素)を辿り、その先にある `zval` の内部的な参照カウンターを一時的に 1 つずつ減算していきます。
ここで重要なのは、実際のPHPの実行用 `refcount` ではなく、GC用のシミュレーション領域で計算を行う点です。
3. 白と黒の判定(Black & White):
もし循環参照の中に外部からの正当な参照(まだ生きている変数からのポインタなど)が混じっている場合、外側から辿られたノードのシミュレーション `refcount` は `1` 以上に戻ります。
逆に、完全に孤立した循環参照であれば、シミュレーション後の参照カウンターはすべて `0` になります。
Zend VMはこの段階で、カウンターが `0` になったノードを「回収確定(灰色:`GC_GREY`)」とし、そうでないノード(まだ外部から使われているもの)は元の状態(`GC_OK`)に戻します。
—
3. スイープフェーズ(Sweep Phase):不要なメモリの刈り取り
マークフェーズによって「真のゴミ(孤立した循環参照)」が特定されたら、次に行われるのがスイープフェーズ(Sweep Phase)です。
スイープフェーズの役割は、ゴミと判定されたオブジェクトや配列が保持していたメモリ資源を安全に解放し、Zend Memory Manager(Zend MM)に返却することです。
Zend VMの内部では、次のようなステップでスイープが実行されます。
1. 解放フラグの設定(White Marking):
ゴミと判定されたノードに「回収対象(`GC_WHITE`)」のマークを付与します。
2. デストラクタの呼び出し(Destruction):
オブジェクトの場合、`__destruct()` メソッドが定義されていれば、このタイミングで順番に呼び出されます。循環参照の中に複数のオブジェクトがある場合、どの順番でデストラクタが走るかに依存したコードを書くのは非常に危険(予測不可能なバグの温床)になるため、アーキテクトとしては循環参照そのものを作らない設計が鉄則となります。
3. Zend MMへの返却:
デストラクタの実行が終わると、オブジェクトが抱えていたプロパティテーブルや、配列のハッシュテーブル(`HashTable`)が保持するバケット領域がメモリから切り離され、OS(またはZend MMのヒールマネージャ)へ解放されます。
—
4. 現場で活きる!GCをハックする実務的アプローチ
ここまでの低レイヤの挙動を知ると、日々のコーディングやフレームワークの設計において、いくつかの重要な示唆が得られます。
① 巨大なオブジェクトグラフは「手動で切断(`unset` / `null`)」する
循環参照が生まれる典型例は、親・子・孫・兄弟が互いに参照し合うツリー構造や、イベントリスナーの登録(オブザーバーパターン)です。
GCは自動で動きますが、マークフェーズとスイープフェーズはCPUサイクルを消費します(=リクエストのレイテンシに悪影響を及ぼします)。不要になったタイミングで、プロパティに `null` を代入して自ら参照を切断することが、最も確実で高速な最適化です。
/
private array $listeners = [];
public function register(object $listener): void {
$this->listeners[] = $listener;
}
// リクエスト終了時や処理の節目で明示的にクリアする
public function flush(): void {
$this->listeners = []; // 内部の参照を切断し、即座にメモリを解放可能にする
}
}
② 長期稼働プロセスでの `gc_collect_cycles()` の制御
SwooleやRoadRunnerなどの永続プロセス型PHPアプリケーションでは、デフォルトでGCが有効になっていますが、バッチ処理の最中など、大量のオブジェクト生成・破棄が繰り返されるポイントでは、意図的にガベージコレクションを制御した方が安全な場合があります。
まとめ
PHPのガベージコレクション(マークフェーズとスイープフェーズ)は、単なる「お掃除機能」ではありません。
参照カウントの限界を補うためにZend VMが編み出した、極めて洗練されたグラフ理論に基づくアルゴリズムです。
- 参照カウントの隙を突く循環参照は、ルートバッファに蓄積される。
- マークフェーズで一時的なシミュレーションを行い、真の孤立ノードを特定する。
- スイープフェーズでデストラクタを安全に呼び出し、メモリをZend MMへ返却する。
この一連のライフサイクルが頭の中でイメージできるようになると、あなたの書くPHPコードは、単に「動くコード」から、ハードウェアの制約とZend VMの挙動を完全に掌握した「美しくスケーラブルなコード」へと進化します。
次回のパフォーマンスチューニングの際には、ぜひこのメモリの裏側を思い出してみてください。きっと、今まで見えなかったボトルネックの原因がクリアに見えてくるはずです。