Zend VMの深淵:参照カウントと循環参照コレクタのメカニズムを掌握せよ
コードレビューの場で、こんな質問をしたことはないか。
「おい、このオブジェクトグラフ、そのまま数万件ループさせたら何MBのメモリがリークするか分かって組んでるか?」
フレームワークがよしなにリクエストを処理し、レスポンスを返した瞬間にプロセスが破棄されるShared-NothingアーキテクチャのPHPであっても、一歩踏み込んだバッチ処理、永続化デーモン(RoadRunnerやReactPHPなど)、あるいはメモリの限界に挑む巨大なデータ処理において、PHPのメモリ管理機構への無知は、そのままシステム全体の死活問題に直結する。
今回は、Zend VMの根幹をなす参照カウント(Refcount)と、それが陥る最大の罠である循環参照(Circular Reference)、そしてPHPのガベージコレクション(GC)が裏側でどう動いているのかを、低レイヤの視点から完全に剥き出しにして解説する。
—
1. Zend VMにおける変数とメモリの基本単位:`zval` と `RefCount`
PHPの変数は、C言語レベルでは `zval`(Zend Value)という構造体として表現されている。この `zval` の中には、型の情報と、実際の値(またはポインタ)が格納されている。
特筆すべきは、PHPの多くのデータ型(文字列、配列、オブジェクトなど)が、メモリの無駄な複製を防ぐためにコピー・オン・write(Copy-on-Write)と参照カウントによって管理されている点だ。
/ 概念的な zval 構造体のイメージ /
typedef struct _zval_struct {
zend_value value; / 値そのもの、またはヒープ上のデータへのポインタ /
union {
struct {
ZEND_ENDIAN_LOHI_4(
zend_uchar type, / IS_LONG, IS_STRING, IS_OBJECT など /
zend_uchar type_flags,
zend_uchar const_flags,
zend_uchar reserved
)
} v;
uint32_t type_info;
} u1;
union {
uint32_t next;
uint32_t cache_slot;
uint32_t opline_num;
uint32_t gc_info; / ガベージコレクション用の情報 /
} u2;
} zval;
変数を別の変数に代入したとき、PHPは即座にメモリ上の実体をコピーするわけではない。実体を示すポインタを共有し、`refcount`(参照カウンタ)をインクリメントするだけだ。これにより、極めて高速な変数代入を実現している。
しかし、この「参照カウントが0になったら即座に解放する」という単純明快なアルゴリズムには、致命的な弱点が存在する。それが「自分自身を指す(あるいはループして指し合う)」構造、すなわち循環参照だ。
—
2. 循環参照の罠:なぜメモリは解放されないのか?
以下のコードを見てほしい。オブジェクトAがオブジェクトBを保持し、同時にオブジェクトBがオブジェクトAを保持している。
class Node {
public ?Node $child = null;
}
$a = new Node();
$b = new Node();
$a->child = $b;
$b->child = $a; // 循環の完成
この状態から、スコープを抜けるなどして `$a` と `$b` の変数シンボルを破棄(Unset)したとする。
Zend VMの視点では何が起きるか?
1. `$a` が指していたオブジェクトの `refcount` は、変数 `$a` が消えたことで `-1` されるが、オブジェクト `$b` から参照されているため、`refcount` は 1 のまま残る。
2. `$b` が指していたオブジェクトの `refcount` も同様に、変数 `$b` が消えたことで `-1` されるが、オブジェクト `$a` から参照されているため、`refcount` は 1 のまま残る。
結果として、どちらのオブジェクトも `refcount` が 0 にならないため、通常の参照カウント方式では永遠にメモリから解放されない。これがPHPにおけるメモリリークの正体である。
—
3. 救世主:循環参照コレクタのアルゴリズム
PHP(PHP 5.3以降)には、この循環参照を検出し、回収するための専用のガベージコレクタ(GC)が組み込まれている。
すべての循環参照をリアルタイムで検知するのはCPU負荷があまりにも高すぎるため、Zend VMは巧妙な戦略をとっている。
バッファリング戦略
PHPのGCは、すべての変数を常時監視しているわけではない。
`zval` のコンテナ(配列やオブジェクトなど、他のコンテナを内包できる複雑な型)の `refcount` が「減少」したとき、そのコンテナが「もしかしたら循環参照の一部になっているかもしれない(潜在的ガベージ)」とみなされ、GCのルートバッファ(Root Buffer)に登録される。
バッファが溢れたときの挙動(三色マーキング法)
ルートバッファが一定数(デフォルトでは10,000エントリー)に達すると、自動的にGCアルゴリズムが発動する。
1. 紫色への変化(Purple): ルートバッファ内の各Zvalを辿り、その `refcount` を減らさずにシミュレーションするため、一時的に「灰色」にマークする。
2. 参照の減算(Decrementing): グラフ内を再帰的に巡回し、見つかったコンテナの `refcount` を実際に `-1` していく。これにより、もし純粋な循環参照であれば、外部からの参照がない部分は `refcount` が `0` に落ちる。
3. 白・黒への判定(White/Black):
- `refcount` が `0` に落ちたコンテナを「白(回収対象)」とマークする。
- 外部からまだ参照されているコンテナ(`refcount > 0`)は、元の数値を復元しつつ「黒(生存)」に戻す。
4. スイープ(Sweep): 「白」とマークされたコンテナ群をメモリから実際に解放する。
—
4. 実務で活かす:メモリ効率に配慮した堅牢な設計ルール
テクニカルリードとして、チームメンバーには次の設計原則を叩き込んでほしい。特に常駐型プロセスや巨大なドメインモデルを扱うアプリケーションでは生死を分ける。
ルール 1: 親子関係の双方向リンクを安易に作らない
ORMやドメインモデルで、`Parent` が `Children` を持ち、各 `Child` が `Parent` への参照 `$this->parent` を保持する設計は、完全に循環参照の温床になる。
【対策】
子から親への逆参照が必要な場合は、生のオブジェクト参照ではなく、識別子(ID)のみを保持させるか、WeakReference(弱参照)を活用する。
ルール 2: PHP 7.4以降なら `WeakReference` を使い倒せ
PHP 7.4で導入された `WeakReference` は、参照先のオブジェクトの `refcount` を増加させない。そのため、循環参照を引き起こさずにオブジェクトを監視・保持できる。
以下の実装例を見よ。キャッシュ機構やオブザーバーパターンにおいて、メモリリークを完全に回避するための美しいリファレンスだ。
/
class ResourceNode
{
private string $name;
public function __construct(string $name)
{
$name_length = strlen($name);
$this->name = $name;
// 重いバイナリデータやリソースをシミュレート
echo “[Allocated] ResourceNode: {$this->name} (Memory: ” . memory_get_usage() . ” bytes)\n”;
}
public function getName(): string
{
return $this->name;
}
public function __destruct()
{
echo “[Destructed] ResourceNode: {$this->name}\n”;
}
}
/
- 弱参照を活用したオブザーバー(メモリリークを一切起こさない)
- 従来の設計でオブザーバーがターゲットを強参照すると、お互いが消せなくなる。
/
class WeakObserverRegistry
{
/ @var array
private array $registry = [];
public function register(string $key, ResourceNode $node): void
{
// WeakReference::create() は refcount をインクリメントしない
$this->registry[$key] = WeakReference::create($node);
}
Optimum:
public function get(string $key): ?ResourceNode
{
if (isset($this->registry[$key])) {
// 弱参照先がすでにGCで回収されている場合はnullが返る
return $this->registry[$key]->get();
}
return null;
}
public function purgeStaleReferences(): void
{
// 参照先が消滅したエントリをキレイに掃除
foreach ($this->registry as $key => $ref) {
if ($ref->get() === null) {
unset($this->registry[$key]);
}
}
}
}
// — 実行と検証 —
echo “— 処理開始 — \n”;
$registry = new WeakObserverRegistry();
do {
$node = new ResourceNode(“DatabaseConnectionPool-A”);
$registry->register(“primary”, $node);
// まだレジストリ経由でアクセス可能
$fetched = $registry->get(“primary”);
echo “Fetched via WeakReference: ” . $fetched?->getName() . “\n”;
// ここで $node のスコープを抜ける(実体の refcount が 0 になるため即座に破棄される)
unset($node);
echo “— \$node をアンセットしました —\n”;
// 弱参照先がどうなっているか確認
$check = $registry->get(“primary”);
if ($check === null) {
echo “Object has been automatically garbage collected without circular reference issues!\n”;
}
} while (false);
echo “— 処理終了 — \n”;
実行結果の脳内トレース
上記のコードを実行した場合、`unset($node)` が実行された瞬間、他にこの `ResourceNode` を強参照している変数やプロパティが存在しないため、`refcount` は即座に `0` になり、ガベージコレクタの助けを借りるまでもなく即座に `__destruct()` が走る。
もしこれが通常のプロパティによる双方向参照であった場合、スコープを抜けてもメモリ上にゾンビのように残り続け、バッチ処理の周回を重ねるごとにメモリが枯渇していったはずだ。
—
5. チーフアーキテクトからの最終提言
PHPは「動的言語だからメモリ管理を意識しなくてよい」という神話は、現代の大規模Webアプリケーションや常駐型プロセス(Swoole, RoadRunner, あるいは長大なCLIバッチ)の現場においては完全に破綻している。
- 循環参照を作るな。 特にドメインモデルにおける双方向の親密な結合は、コードの美しさと引き換えにメモリリークという毒を孕む。
- どうしても参照を持たせたいなら `WeakReference` を使え。 Zend VMのメモリモデルをハックし、不要な強参照を排除することで、堅牢で予測可能なシステムが構築できる。
コードレビューで「このオブジェクト、親へのリンク持ってるけど循環参照対策はどうなってる?」とサラリと言い放てるエンジニアであれ。それが、PHPを真に掌握したプロフェッショナルの姿だ。