Zend VMの参照カウントと循環参照コレクタの挙動解析:メモリリークの根本原因究明
PHPは、ガベージコレクション(GC)言語である。この一言によって、多くのWebアプリケーション開発者はメモリ管理の苦痛から解放されてきた。数万件のレコードを処理するバッチスクリプトであれ、数千のリクエストをさばくFPM(FastCGI Process Manager)のワーーカープロセスであれ、スクリプトの終了とともにメモリは綺麗にOSへと返却される――正常系においては、だが。
しかし、長期稼働するデーモンプロセス、あるいは巨大なオブジェクトグラフを頻繁に生成・破棄するエンタープライズWebアプリケーションにおいて、突如としてメモリ使用量が右肩上がりになり、OOM(Out of Memory) Killerの餌食になる現象に直面したことはないだろうか。
本稿では、Zend Engineの深淵に潜り、`zval`の物理構造、参照カウント(Refcount)のインクリメント/デクリメントのメカニズム、そしてPHP 7以降で劇的な進化を遂げた循環参照コレクタ(Cycle Collector)のアルゴリズムを完全解剖する。なぜ循環参照は通常の参照カウントでは回収不能なのか、そしていかにしてメモリリークの時限爆弾が仕掛けられるのか。そのすべての真相を、コードとメモリレイアウトの観点から解き明かす。
—
1. Zend Engineにおけるメモリの基本単位:`zval` と参照カウント
PHPの変数(値)は、C言語レベルではすべて `zval`(Zend Value)という構造体として表現されている。PHP 7および8における `zval` のサイズは正確に16バイトであり、CPUキャッシュラインを効率的に利用するように設計されている。
`zval` は、データ本体(Scalar値、あるいは`zend_string`、`zend_array`、`zend_object`などのヒープ上のポインタ)と、型情報、そしてメタデータを保持する。このメタデータの中に、メモリ管理の要である 参照カウント(`refcount`) が存在する。
// Zend Engine (Zend/zend.h) における概念的な zval 構造体
typedef struct _zval_struct {
zend_value val; // 8バイト: 実際の値またはポインタ
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar type, // 型情報 (IS_STRING, IS_OBJECT, IS_ARRAY 等)
zend_uchar type_flags, // フラグ (GC関連情報含む)
zend_uchar const_flags,
zend_uchar reserved
)
} v;
uint32_t type_info;
} u1;
union {
uint32_t next; // ハッシュ衝突時の次エントリ (HashTable用)
uint32_t cache_slot; // キャッシュスロット
uint32_t oas_offset;
uint32_t gc_info; // ガベージコレクション用情報
} u2;
} zval;
コピー・オン・write(CoW)と参照カウントの連動
PHPでは、変数を別の変数に代入しても、即座にメモリ上の実体が複製されるわけではない。
$a = str_repeat(“A”, 1024 1024); // 1MBの文字列を生成
$b = $a; // 代入
この瞬間、`$a` と `$b` は同一の `zend_string` を指し示し、その `refcount` は `2` にインクリメントされる。これが Copy on Write(CoW) の実体である。どちらかの変数に対して破壊的な変更(例:`$b .= “B”;`)が加えられた瞬間に初めて、Zend Engineは新しいメモリ領域をアロケートし、`refcount` をデクリメントするというコストを支払う。
この「参照カウントによる決定論的メモリ解放(Deterministic Memory Management)」は極めて高速であり、スクリプトの実行速度を支える最大の功労者である。しかし、この仕組みには致命的な構造的欠陥が存在する。それが 「自己参照(Circular Reference)」 である。
—
2. 循環参照の罠:なぜ `refcount = 0` にならないのか
参照カウント方式の最大の弱点は、オブジェクトや配列が自分自身、あるいは相互に参照し合うグラフ構造を形成したときに見舞われる。
以下のPHPコードを見てみよう。
class Node {
public ?Node $parent = null;
public ?Node $child = null;
}
// 循環参照の構築
$parent = new Node();
$child = new Node();
$parent->child = $child;
$child->parent = $parent; // 相互参照
// 変数スコープの消滅(あるいは上書き)を想定
unset($parent, $child);
メモリ上で何が起きているか?
1. `$parent` オブジェクトの `zval`(正確には `zend_object`)の `refcount` は、`$parent` 変数からの参照と `$child->parent` からの参照によって `2` になっている。
2. `$child` オブジェクトの `zval` も同様に、`$child` 変数からの参照と `$parent->child` からの参照によって `2` になっている。
3. ここでスクリプトが `unset($parent, $child)` を実行すると、ローカルシンボルテーブルからの参照が失われ、それぞれの `refcount` は `1` に減少する。
ここで `refcount` が `0` にならない。
OSのメモリ上には、互いに相手を指し示しているだけの `$parent` と `$child` がゾンビのように残り続ける。これが、通常の参照カウントでは絶対に回収できない 循環参照によるメモリリーク の正体である。もしこのコードがFPMの長寿命ワーカー内や非同期ループ(Fiber等)のコンテキストで数万回実行された場合、プロセスは確実に死に至る。
—
3. Zend VMの救世主:「三色マーキング法」と循環参照コレクタ
PHP 7以降のZend Engineに搭載されている循環参照コレクタは、Brian Concurrent Graph Collectionなどのアルゴリズムをベースに最適化された、極めて洗練されたガベージコレクション機構である。
Zend EngineのGCは、すべてのオブジェクトや配列を常時監視しているわけではない。オーバーヘッドが大きすぎるからだ。代わりに、「潜在的なガベージ(Possible Garbage)」 のみを効率的に検出・回収する。
コレクタの動作フェーズ(3つのバッファとステート)
Zend Engineは、コンテナ型(配列およびオブジェクト)の `refcount` が 「デクリメントされたが、0にはならなかった」 という事実を検知すると、そのポインタを 「GCルートバッファ(GC Buffer)」 に即座にプッシュする。バッファが満杯(デフォルトでは10,000エントリ)になると、コレクタのアルゴリズムが発動する。
コレクタは、以下の「三色マーキング法(Tri-color Marking)」に類似したステートマシンをメモリ上で実行する。
1. Buffered(紫色 / 潜在的ガベージ):
ルートバッファに登録された初期状態。
2. Finding Roots (疑似減算フェーズ – 灰色):
バッファ内の各コンテナを走査し、その内部から参照されている別のコンテナの `refcount`を 仮想的に「1」だけデクリメント する。これは、もし外部からの参照がゼロであれば、内部の相互参照分だけが `refcount` を押し上げているはずだという仮説を検証するためである。
3. Collecting Roots (色分けと判定 – 白・黒):
- 仮想デクリメントの結果、`refcount` が `0` になったコンテナ は、「外部からの正当な参照が一切なく、ただ循環参照の輪によって維持されているだけのゴミ(True Garbage)」と断定され、「白(White)」 にマークされる。
- 逆に、まだ外部から参照されているコンテナは 「黒(Black)」 に戻され、その `refcount` も元の値に復元される。
4. Sweeping (回収フェーズ):
「白」にマークされたコンテナ群を実際に走査し、デストラクタ(`__destruct`)の呼び出しキューイングを経て、Zendメモリマネージャ(zend_mm)へとメモリ領域を返却する。
—
4. 悪夢のシナリオ:GCをバイパス・麻痺させるコードパターン
アーキテクトとして最も警戒しなければならないのは、「循環参照コレクタが存在するから安心だ」という甘い認識である。特定の設計パターンや拡張モジュールの干渉により、このGC機構が完全に機能不全に陥るケースがある。
パターンA: `__destruct()` 内でのオブジェクト復活(Resurrection)と例外
PHPでは、デストラクタ内で破棄されようとしているオブジェクトに再び変数参照を付与して「復活」させることが文法上可能である。
class Leaker {
public ?Leaker $self = null;
public function __destruct() {
// デストラクタ内で自分自身をグローバル変数や静的プロパティに再代入
// 循環参照コレクタを混乱させ、メモリリークを永続化させる温床となる
global $global_rescue;
$global_rescue = $this;
echo “Resurrected!\n”;
}
}
Zend VMは、デストラクタの実行順序と循環参照の回収順序が複雑に絡み合うと、メモリの二重解放(Double Free)や不正アクセスを防ぐためにガベージの回収を放棄せざるを得なくなる場合がある。
パターンB: 巨大なクロージャ(Closure)と `use` による暗黙の循環参照
モダンなPHPフレームワークやDIコンテナにおいて、クロージャが自身を囲むスコープ(特に `$this` やコンテナ自身)をキャプチャすることで、意図しない循環参照が爆発的に発生する。
class ServiceContainer {
public array $registry = [];
public function bind(string $name, callable $resolver): void {
$this->registry[$name] = $resolver;
}
}
$container = new ServiceContainer();
$container->bind(‘db’, function() use ($container) {
// クロージャが $container をキャプチャし、
// かつ $container がそのクロージャを内部の配列に保持する
return $container;
});
このコードは、`$container` -> `registry[‘db’]` (Closure) -> `use ($container)` という完璧な循環参照の輪を形成する。クロージャは `zend_object` として扱われるため、これもGCの管理対象となるが、無数の依存関係が絡み合った巨大なクロージャグラフは、GCの走査コスト(CPUサイクル)を急騰させ、FPMプロセスのレスポンスタイムを著しく悪化させる「スローダウン・リーク」を引き起こす。
—
5. 実践:メモリリークの検出とZend Debug / GC APIの活用
コード内のメモリリークやGCの挙動を観測・デバッグするためには、PHPが標準で提供している `gc_` 関数群、およびOPcacheの統計情報を駆使する必要がある。
以下の診断用スクリプトを実環境(あるいはステージング環境)で動かすことで、Zend VMの内部状態を数値として可視化できる。
link = $b;
$b->link = $a;
// 変数スコープを抜けることで refcount = 1 の循環参照が完成
}
echo “— 循環参照生成後(GC実行前) —” . PHP_EOL;
echo “Memory Usage: ” . number_format(memory_get_usage(true)) . ” bytes” . PHP_EOL;
// GCステータスの取得
$status = gc_status();
echo “GC Buffered Garbage Count: ” . $status[‘buffered’] . PHP_EOL;
echo “GC Collected Count: ” . $status[‘collected’] . PHP_EOL;
// 強制的に循環参照コレクタを稼働させる
$collectedCycles = gc_collect_cycles();
echo “Collected Cycles by gc_collect_cycles(): ” . $collectedCycles . PHP_EOL;
echo “— GC強制実行後 —” . PHP_EOL;
echo “Memory Usage: ” . number_format(memory_get_usage(true)) . ” bytes” . PHP_EOL;
// 再度ステータス確認
$statusAfter = gc_status();
echo “GC Buffered Garbage Count (After): ” . $statusAfter[‘buffered’] . PHP_EOL;
実行結果の読み解き方
このスクリプトを実行すると、`gc_collect_cycles()` が呼び出された瞬間にメモリ使用量がガクッと急減するのが確認できるはずだ。
もし大規模なWebアプリケーションにおいて、リクエスト終了時にメモリが解放しきれていない場合、`gc_status()[‘buffered’]` の値がリクエストごとに蓄積していないかをモニタリング基盤(APMツールやPrometheus等)で監視することが、プロフェッショナルなアーキテクチャ設計の必須要件となる。
—
6. チーフアーキテクトからの提言:メモリを支配する設計原則
PHPの参照カウントと循環参照コレクタの挙動を理解した今、我々エンジニアが取るべき対策は明確である。
1. 「持続的ステート」を持つオブジェクトのライフサイクルを明確に定義する
DIコンテナやシングルトンパターンにおいて、オブジェクト同士が双方向の依存関係(Parent-Child関係など)を持つ設計は極力避ける。どうしても必要な場合は、弱参照(Weak Reference)を活用せよ。
PHP 7.4以降で導入された `WeakReference` クラスを使えば、参照カウントをインクリメントせずにオブジェクトを指し示すことができる。これにより、循環参照そのものを根絶やしにできる。
class WeakNode {
public ?WeakReference $parent = null; // 弱参照により循環参照を防ぐ
}
2. 長寿命プロセス(Swoole, RoadRunner, ReactPHP等)における厳格なリソース管理
FPMであればリクエスト終了時にすべてのメモリがOSに一括返却されるため、多少の循環参照リークはプロセス寿命によってごまかすことができた。しかし、プロセスが常駐する非同期・並行処理ランタイムにおいては、わずかなメモリリークも数時間でシステム全体を崩壊させる。全てのイベントリスナー、クロージャのキャプチャ、グローバルレジストリへの登録を、明示的な `unset()` やスコープアウトによって破棄する規しきルールをチーム全体で徹底する必要がある。
Zend Engineの限界と挙動を熟知し、メモリのフローを脳内で完璧にトレースできる者だけが、真にスケーラブルで頑健なPHPバックエンドシステムを構築し得る。コードの表面だけでなく、その下のコンパイラとVMが奏でるバイナリの息吹に耳を澄ませ続けよ。