【実務・中級編】PHPのガベージコレクション(GC)アルゴリズムと循環参照検出の限界 – PHPコア・内部エンジンと高速化・並行処理の極意解析バイブル

はじめに:なぜ、あなたのPHPアプリケーションはメモリリークするのか

コードレビューをしていて、長時間のバッチ処理や高スループットなAPIエンドポイントで「メモリ使用量が右肩上がりに増え続ける」という現象に直面したことはないだろうか。

「PHPはリクエストが終わればメモリは全解放されるから安全だ」――そう信じ切っているとしたら、それはZendエンジンのメモリ管理の表層しか見えていない。PHP 7、そしてPHP 8世代におけるZend VMは、極限まで最適化されたアロケータ(Zend MM)と洗練された参照カウント機構を持っている。しかし、どれほどエンジンが進化しようとも、「循環参照(Circular Reference)」という構造的罠の前では無力な瞬間が存在する。

今回は、PHPのガベージコレクション(GC)が内部のZend VM上でどのように振る舞い、どのような条件で力尽き、メモリリークという致命的なボトルネックを引き起こすのか。その低レイヤの真実と、実務で絶対に踏み抜いてはならない設計の鉄則を叩き込む。

—

1. Zend VMの基礎:zval、参照カウント、そしてメモリ空間の真実

PHPの変数やオブジェクトは、すべてC言語レベルの構造体である `zval`(Zend Value)として表現されている。変数に値が代入されるたびに、その実体がどこにあるのかを示すポインタと、型情報、そして最も重要な「参照カウント(refcount)」がインクリメントされる。

+———————————–+
| zval |
| [ type ] [ value ] [ refcount ] |
+———————————–+
|
v (実体: オブジェクト等)

通常のライフサイクルであれば、変数のスコープを抜ける(あるいは `unset()` が呼ばれる)と、`refcount` はデクリメントされる。これが `0` になった瞬間、Zend MM(Memory Manager)はそのメモリ領域を即座に解放する。これがPHPの基本かつ圧倒的な高速性の源泉である。

循環参照がもたらす悲劇

しかし、オブジェクト A がプロパティとしてオブジェクト B を持ち、同時にオブジェクト B もプロパティとしてオブジェクト A を保持するような場合、状況が一変する。

$a = new stdClass();
$b = new stdClass();

$a->b = $b;
$b->a = $a;

unset($a, $b);

このコードを実行したとき、`$a` と `$b` の変数シンボルは消滅するが、お互いに相手を指し示しているため、それぞれの `zval` の参照カウントは `1` のこったままになる。
参照カウントが `0` にならないため、Zend MMはこれらを通常の手法では絶対に解放できない。これが「メモリリーク」の正体である。

—

2. PHPのGCアルゴリズム:バッファと「疑わしいルート」の探索

PHP 5.3以降、そしてPHP 7/8で洗練され続けているガベージコレクションは、この循環参照問題を解決するために導入された。しかし、すべての変数を常に監視しているわけではない。 もしそんなことをすれば、Webリクエストのライフサイクル全体で深刻なパフォーマンス低下を招く。

ZendエンジンのGCは、以下のアルゴリズムで動作している。

1. ルートバッファ(Root Buffer)への登録:
参照カウントがデクリメントされた際、もしその値が `0` にならず、「まだ子要素(プロパティや配列要素)を持つ可能性のある複合型(オブジェクトや配列)」である場合、その `zval` はGCの「ルートバッファ(デフォルトでは最大10,000エントリ)」に「疑わしいルート(Root)」としてマークされ、プッシュされる。
2. バッファが満杯になったら収集開始:
ルートバッファが溢れるか、明示的に `gc_collect_cycles()` が呼ばれたとき、GCのサイクル検出アルゴリズムが発動する。
3. 深さ優先探索による参照カウントのシミュレーション:

  • 疑わしいルートから辿れるすべての `zval` の参照カウントを一時的にデクリメントしてみる。
  • もし参照カウントが `0` になれば、それは「外部から完全に孤立した循環参照(=真のゴミ)」であると判定される。
  • 逆に、外部からまだ参照されている場合は、カウントを元に戻して保護する。

4. スイープ(Sweeping):
ゴミと判定された `zval` の領域を解放し、メモリを回収する。

—

3. なぜGCは限界を迎えるのか?(検出漏れが発生するシナリオ)

この巧妙なGCメカニズムが存在するにもかかわらず、なぜ実務の現場でメモリリークが頻発するのか。それは、「GCが検知できない構造」や「意図しないメモリの保持」が存在するからだ。

シナリオA:ロングランプロセス(RoadRunner, Swoole, 伝統的なデーモン)でのグローバル汚染

FPM環境であればリクエスト終了時にプロセスごとメモリ空間が破棄されるため、循環参照があってもOSレベルで一網打尽にされる。しかし、SwooleやRoadRunner、ReactPHPなどの常駐型(Long-running)プロセスでは、一度起きた循環参照やリークがプロセス内に蓄積し続け、数日稼働しただけでOOM(Out of Memory) Killerにプロセスを強制終了される。

シナリオB:マジックメソッドやクロージャによる隠れた参照

クロージャ(匿名関数)はその内部で外部変数を `use` する際、暗黙的にオブジェクトの参照を保持する。ここに循環参照が絡むと、ZendのGCすら追跡困難な複雑なグラフが形成され、バッファがあふれてGCの処理が追いつかなくなる。

—

4. 実務で耐えうる堅牢な設計とリファレンスコード

では、このメモリリークの罠を回避し、堅牢なシステムを構築するにはどうすればよいか。
テクニカルリードとしてチームに強制すべき設計ルールは以下の2点だ。
1. オブジェクトのライフサイクルを単方向(DAG: 有向非巡回グラフ)に保つ(親から子への参照のみ許可し、子から親への逆参照を禁止する)。
2. 逆参照が必要な場合は、弱参照(WeakReference)を徹底する(PHP 7.4+)。

以下のコードは、常駐型アプリケーションや大規模APIで安全にメモリを管理するための、実用に耐えうる堅牢なリペアパターンの実装例である。

  • サービスコンテナやオブザーバーパターンにおいて、
  • 循環参照によるメモリリークを防ぐための堅牢なコンポーネント設計例。
  • /
    class WorkerNode
    {
    private string $id;

    /

    • @var \WeakReference|null
    • 親への逆参照には必ず WeakReference を使用する。
    • これにより、参照カウントをインクリメントさせずに親を監視できる。

    /
    private ?\WeakReference $managerRef = null;

    public function __construct(string $id)
    {
    $this->id = $id;
    }

    public function setManager(WorkerManager $manager): void
    {
    // 弱参照として保持することで、マネージャーが破棄された際にも
    // 循環参照ループに巻き込まれず、即座にメモリが解放される。
    $this->managerRef = \WeakReference::create($manager);
    }

    public function getManager(): ?WorkerManager
    {
    return $this->managerRef?->get();
    }

    public function getId(): string
    {
    return $this->id;
    }
    }

    class WorkerManager
    {
    private string $name;

    / @var array /
    private array $nodes = [];

    public function __construct(string $name)
    {
    $name = $name;
    }

    public function addNode(WorkerNode $node): void
    {
    // 子ノードへ自身を登録(逆参照の発生ポイント)
    $node->setManager($this);
    $this->nodes[$node->getId()] = $node;
    }

    public function removeNode(string $id): void
    {
    unset($this->nodes[$id]);
    // ここで $node の参照が消滅し、WeakReference のため
    // 循環参照のコストをかけずにクリーンアップが完了する。
    }
    }

    // — 実行・検証フェーズ —
    echo “— メモリリーク耐性テスト開始 —\n”;

    $initialMemory = memory_get_usage();

    for ($i = 0; $i < 100000; $i++) { $manager = new WorkerManager("Manager-{$i}"); $node = new WorkerNode("Node-{$i}"); $manager->addNode($node);

    // スコープを抜けて破棄される
    unset($manager, $node);
    }

    $finalMemory = memory_get_usage();
    echo “初期メモリ: ” . number_format($initialMemory) . ” bytes\n”;
    echo “終了メモリ: ” . number_format($finalMemory) . ” bytes\n”;
    echo “差分(増加量): ” . number_format($finalMemory – $initialMemory) . ” bytes\n”;

    if (($finalMemory – $initialMemory) < 1024 512) { echo "判定: 【合格】メモリは正常に解放されています。\n"; } else { echo "判定: 【警告】メモリリークが発生しています!\n"; }

    このコードのアーキテクチャ的優位性

    上記のコードでは、親(`WorkerManager`)から子(`WorkerNode`)へのポインタに加え、子から親へアクセスする必要がある場面において、PHP 7.4で導入された `WeakReference` を採用している。
    これにより、Zendエンジンの参照カウントを一切増加させずにオブジェクト間の関係性を構築できるため、そもそも循環参照のルートバッファにすらエントリが登録されない。GCのサイクル検知アルゴリズムのCPU負荷すら発生させない、極めてエレガントかつハイパフォーマンスな設計と言える。

    —

    5. テクニカルリードからの最終提言

    PHPのGCやメモリ管理機構は極めて優秀だが、それは「開発者が言語のメモリモデルを正しく理解していること」を前提としている。特に現代の非同期PHPエコシステムやロングランプロセスを前提とした開発において、オブジェクトの所有権(Ownership)と参照の向きを意識しない設計は、プロダクション環境における時限爆弾に等しい。

    コードレビューの際は、以下のポイントを厳しくチェックしてほしい。

    • オブジェクト同士が相互に参照し合う双方向の関連(ビーム構造)になっていないか。
    • 親子関係において、子が親のインスタンスを強参照(直接プロパティに保持)していないか。
    • 常駐型プロセスで動くコードにおいて、不要になったキャッシュやハンドラがグローバルスコープや静的プロパティに残留していないか。

    低レイヤの挙動を脳内にビルドし、エンジンに無駄な負荷をかけない美しいコードを書くこと。それこそが、プロフェッショナルなPHPアーキテクトの条件である。

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