【実務・中級編】Zend VMの参照カウント(Refcount)と循環参照コレクタの挙動解析 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

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を真に掌握したプロフェッショナルの姿だ。

    タイトルとURLをコピーしました