こんにちは。普段から大規模なWebアプリケーションのボトルネックと日々格闘されていることと思います。Node.jsやGo、あるいはJavaといった他の高水準言語からPHPの世界に入ってきたエンジニアの多くが、ある壁にぶつかります。それは、「PHPのスクリプトはリクエストが終わればすべて消えるはずなのに、なぜ複雑な構造体を扱うとメモリ使用量が跳ね上がり、時にはパフォーマンストラップを踏むのか」という疑問です。
フレームワークが巨大化し、DIコンテナが数千のオブジェクトを解決し、ORMがリレーションをメモリ上に展開する現代のモダンPHPにおいて、その挙動を決定づけている心臓部が Zend VMの参照カウント(Reference Counting)とガベージコレクション です。
今回は、ネットの海をいくら探しても数式やCのソースコードの羅列ばかりで「結局、俺たちのコードのどこに効いてくるのか」が見えにくい、Zend VMのメモリ管理の深層を紐解いていきましょう。ここを理解すると、あなたの書くPHPコードのメモリ効率は劇的に変わります。
—
1. Zend VMのメモリ管理の基本:すべての変数は「zval」という箱に入っている
PHPのコード上で `$a = “hello world”;` と書いたとき、Zend Engineの内部(C言語レベル)では何が起きているでしょうか。
PHP 7および8の世代では、変数の値や型は `zval`(Zend Value) という構造体(Cの共用体と構造体の合わせ技)に格納されています。この `zval` はわずか16バイトという非常にスリムなサイズに最適化されており、ここにプリミティブな値や、配列・オブジェクトへのポインタが収められています。
そして、配列(`array`)やオブジェクト(`object`)といった複合データ型、あるいはリソースやクロージャなどの「重い」データは、`zval` の外側のヒープメモリ上に別の実体(例:配列なら `Bucket` の配列を持つ `zend_array`)として確保されます。
ここで登場するのが、今回の主役である 参照カウント(`refcount`) です。
[ PHPスクリプト ]
$a = [‘foo’ => ‘bar’];
$b = $a; // コピーオン・ライトによる参照の共有
[ Zend VM 内部メモリ (Zend Heap) ]
+——————-+
| zval ($a 用) | —> 実際の zend_array (refcount: 2)
+——————-+ 配列の中身の実体を指すポインタを共有
| zval ($b 用) | —> 〃
+——————-+
PHPでは、変数を別の変数に代入しても、即座にメモリ上の配列全体をコピーするわけではありません。Copy-on-Write(COW) という仕組みにより、最初は同じ実体を指し示し、zval内のメタデータにある `refcount` を「2」にインクリメントするだけで済ませます。これが、PHPが動的言語でありながら高速に動作する秘密の一つです。
—
2. 参照カウントのデクリメント処理と「解放(Free)」の瞬間
問題は、変数がスコープを抜けたり、`unset()` さえたりしたときです。
Zend VMは、変数が破棄されるたびに、その実体が持つ `refcount` を デクリメント(1減算) します。このデクリメント処理自体は非常に高速ですが、システム全体のパフォーマンスに影を落とすポイントがあります。
それが 「`refcount` が 0 になった瞬間に行われる連鎖的なメモリ解放」 です。
例えば、数万件のレコードを詰めた巨大な配列や、多重にネストしたオブジェクトツリーを考えてみてください。ある変数の `refcount` が 0 に落ちたとき、Zend VMはその実体が抱えている子要素(さらに下層のzvalやヒープ上の構造体)すべての `refcount` を再帰的にデクリメントし、0になったものを次々とヒープから解放(`efree`)していきます。
パフォーマンスへの影響:隠れた「ガベージの雪崩」
このデクリメントと解放の連鎖は、リクエストのライフサイクルが終わる直前、あるいは巨大なコレクションを処理するループの最中に一気に発生します。
- CPUキャッシュのミスマッチ: ポインタをたどってメモリのあちこちにある小さなチャンクを解放していくため、CPUのキャッシュ効率が落ちます。
- アロケータのロック/オーバーヘッド: Zend Memory Manager(Zend MM)が断片化したメモリプールを整理するコストが発生します。
「まだリクエストの途中なのにメモリが減らない、あるいは解放時にプチフリーズする」という現象の裏では、まさにこの参照カウントのデクリメントと解放の連鎖が起きています。
—
3. 循環参照の罠:参照カウントだけでは救えない世界
しかし、参照カウントには致命的な弱点があります。それが 「循環参照(Circular Reference)」 です。
オブジェクトAがオブジェクトBを持ち、オブジェクトBがオブジェクトAを持つような構造を作った場合、それぞれの `refcount` は最低でも「1」に保たれ続けます。
child = $b;
$b->child = $a; // A -> B -> A のループ
unset($a, $b);
// 変数$a、$bは消えたが、オブジェクト間の相互参照により
// それぞれの refcount は 0 にならず、メモリに残存する!
この「参照カウントが0にならないが、外部からは絶対にアクセスできない孤立したメモリ領域」を掃除するために稼働するのが、PHPの 循環参照ガベージコレクタ(GC) です。
循環参照GCの仕組み(バッファリングとルートの推測)
PHPのGCは、すべての変数を常時監視しているわけではありません。そんなことをすればオーバーヘッドでWebサーバーが耐えられません。代わりに、以下のような巧妙なアルゴリズムをとっています。
1. バッファへの蓄積: 複合データ型(配列やオブジェクト)の `refcount` が「減った(ただし0にはなっていない)」とき、Zend VMはその変数を「疑わしいルート(Roots buffer)」として専用のバッファに記録します。
2. 色の塗り替えと判定: バッファがいっぱいになるか、あるいは明動的に `gc_collect_cycles()` が呼ばれたタイミングで、GCがバッファ内のオブジェクトをスキャンします。
- 一時的に `refcount` をわざとデクリメントしてみて、もし「外部からの参照がなく、内部のループだけで支えられている」と判明した場合、それを「ゴミ(GARBAGE)」と認定します。
3. 一括解放: 認定されたゴミの `refcount` を強制的にゼロとみなし、メモリを解放します。
—
4. 現場で活きる!Zend VMの挙動を意識したメモリ最適化の極意
ここまでの内部構造を踏まえると、私たちが実務のコードを書く上で「何を意識すべきか」が見えてきます。
① 巨大な配列やオブジェクトは「スコープを強制的に区切る」
数万件のデータをループで処理する際、その変数が生き続けるスコープが広すぎると、メモリ上の `refcount` も長い間維持され、Zend MMがメモリを再利用できません。
repository->fetchAllMegaRecords(); // refcount 高
$results = [];
foreach ($hugeData as $row) {
$results[] = $this->transform($row);
}
return $results; // $hugeData は関数終了まで解放されない
}
// 【最適解:チャンク処理とスコープの分離】
// ジェネレータや小分けにしたバッチ処理で、早期に refcount を 0 に落とす
function processBatchData() {
$this->repository->fetchChunked(1000, function(array $chunk) {
// $chunk はこの無名関数のスコープ内だけで処理され、
// コールバックを抜けた瞬間に refcount が 0 になり即座にメモリ解放される
$this->processChunk($chunk);
});
}
② 循環参照を生む構造(双方向リレーション)を避ける、または明示的に切断する
ORM(DoctrineやEloquentなど)で親子関係や多対多を雑に定義すると、知らず知らずのうちに強烈な循環参照の山が築かれます。
ライフサイクルの長いバッチ処理(Symfony ConsoleワーカーやLaravel Queueワーカーなど、1つのプロセスで何千件もジョブをこなすデーモン型スクリプト)では、この循環参照によるメモリリークが致命傷になります。
デーモン型スクリプトを書く際は、処理の区切りごとに「明示的に循環を切る(`null` を代入する)」か、フレームワークが用意している「一定リクエスト数でのプロセス再起動(Worker Process Lifecycle)」の仕組みを必ず有効にしてください。
—
最後に:裏側の「仕組み」が見えると、コードは美しくなる
PHPは「書いて動かせば勝手にメモリをきれいにしてくれる言語」として広く普及しました。しかし、Webアプリケーションの規模が巨大化し、1プロセスの寿命が長くなるモダンなアーキテクチャ(Swoole, RoadRunner, あるいは常駐型ワーカー)において、Zend VMの参照カウントとデクリメントの挙動を理解しているか否かは、プロとしての大きな分かれ道になります。
「なぜ今、ここにメモリが必要なのか」
「この変数を `unset` したとき、内部のデクリメントはどう連鎖するのか」
その脳内トレースができるようになったあなたなら、もうフレームワークの挙動に振り回されることはありません。ぜひ、次のコードレビューや設計の現場で、この知見を活かしてみてください。PHPの裏側の世界が、これまでよりもずっとクリアに、美しく見えているはずです。