PHPガベージコレクションの深淵:マーク・スイープのZend VM内部実装とメモリ制御の極意
テックリードの私たちが日々のコードレビューで最も恐れるべきは、シンタックスエラーでもなければ、遅いSQLでもない。――それは、「知らぬ間に肥大化し、ピーク時にOOM(Out of Memory)を引き起こすメモリリーク」だ。
PHPはリクエストライフサイクルが終わればプロセスごとメモリが解放される「シェアード・ナッシング」なアーキテクチャであるため、長らくメモリ管理は言語処理系に丸投げされてきた。しかし、モダンなWebアプリケーション、例えば長時間のWorkerプロセスで稼働するSwooleやRoadRunner、あるいは膨大なドメインモデルをメモリ上に展開するDDDアーキテクチャのAPI群において、PHPのメモリ管理、特にガベージコレクション(GC)の内部挙動を理解していないエンジニアは、時限爆弾を抱えているようなものだ。
今回は、Zend VM(Zend Engine)のソースコードレベルまで潜り込み、PHPのGCが内部でどのようにオブジェクトグラフを探索し(マークフェーズ)、不要なメモリを回収しているのか(スイープフェーズ)、その全貌を解き明かす。
—
1. Zend VMの基礎:参照カウントから循環参照への罠
PHPのメモリ管理の根底にあるのは、お馴染みの Reference Counting(参照カウント) だ。
すべての変数、オブジェクト、配列は `zval`(Zend Value)という構造体で表現され、その中の `refcount` が「このデータをいくつ変数が指し示しているか」を追跡している。
/ Zend Engineの概念的なzvalとGC対象の構造(イメージ) /
typedef struct _zval_struct {
zend_value value;
union {
uint32_t type_info;
/ … /
} u1;
union {
uint32_t next;
uint32_t cache_slot;
} u2;
} zval;
通常の変数代入や関数のスコープ抜けでは、この `refcount` の増減(`Z_ADDREF_P` / `Z_DELREF_P`)によって即座にメモリが解放される。確定的にメモリが消えていくため、これだけを見ている分には安全だ。
循環参照(Circular Reference)という悪夢
問題は、オブジェクトや配列が自分自身や互いを指し示す「循環参照」を形成したときだ。
class Node {
public ?Node $child = null;
}
$a = new Node();
$a->child = $a; // 自分自身を参照
unset($a); // $a の変数を破棄
このコードを実行した瞬間、`$a` の変数は消滅するが、オブジェクトの `refcount` は `1` のまま残る。なぜなら、オブジェクト自身が自分自身への参照を保持しているからだ。この瞬間、誰もアクセスできないのに、二度と解放されないメモリ(ゾンビメモリ)が誕生する。これが、参照カウント方式の致命的な盲点である。
—
2. GCの核心:マークフェーズとスイープフェーズの内部実装
PHP(PHP 5.3以降、PHP 7/8で大幅に高速化)はこの循環参照を回収するために、Concurrent Cycle Collection(並行サイクル回収アルゴリズム)を採用している。
Zend Engineは、潜在的に循環参照になりうる「コンテナ型データ(オブジェクトと配列)」が作られると、それを専用の「ルートバッファ(Root Buffer)」に黄色信号としてバッファリングしていく。バッファが一杯になる(デフォルトでは10,000エントリ)と、GCアルゴリズムが発動する。
このアルゴリズムは、以下の4つのフェーズで構成される。実質的な心臓部がマークフェーズとスイープフェーズだ。
Phase 1: 候補のバッファリング(Buffer Insertion)
参照カウントがデクリメントされた(しかし0にはならなかった)zvalが、ルートバッファに追加される。この時点では「循環参照の疑いがある」というだけで、即座にコストの高い解析は走らない。
Phase 2: マークフェーズ(Mark Phase:疑似減算とカラーリング)
GCが発動すると、Zend VMはルートバッファ内のすべてのzvalをスキャンし、グラフの深さ優先探索(DFS)を行う。
1. 色づけ(Coloring – グレー):
探索中のzvalの色を `GC_GREY` に変更し、その子要素(プロパティや配列の要素)の参照カウントを一時的にデクリメントする。
(なぜ減算するか? 循環参照によるものか、外部からの正当な参照によるものかを切り分けるため)
2. 再帰的探索:
すべてのリンクを辿り、子要素の `refcount` を芋づる式に削っていく。
Phase 3: スイープフェーズ(Sweep Phase:判定と回収・復元)
マークフェーズが終わった後、zvalの `refcount` の値によって運命が分かれる。
- `refcount > 0` の場合(外部からの正当な参照がある):
「この循環は、外部の変数からも参照されている」と判断され、マークフェーズで減算してしまった `refcount` を元に戻す(復元:Blackマーク / `GC_BUF_ROOT` に戻す)。
- `refcount == 0` の場合(完全に孤立した循環参照):
「外部からの参照は一切なく、自分たち同士で支え合っているだけの孤立したゴミ」と断定される。このzvalを `GC_PURPLE` にマークし、実際のメモリ解放プロセス(Destructorの呼び出しとメモリの返還)を実行する。
—
3. 【実務設計】メモリリークを誘発する危険なコードと、そのリファレンス実装
現場のコードレビューでよく見かける「GCを殺す悪臭を放つ実装」と、それを美しく安全にリファクタリングした実例を見てみよう。
❌ 危険なアンチパターン:双方向参照とクロージャの束縛
ORMのエンティティや親子関係を持つツリー構造で、何も考えずに相互参照を持たせ、さらに無名関数(Closure)のuse構文で自分自身をキャプチャさせると、GCのルートバッファが飽和し、メモリがパンドラの箱のように膨れ上がる。
// 【危険な設計】循環参照とクロージャによるメモリリークの温床
class Department {
/ @var Employee[] /
public array $employees = [];
}
class Employee {
public ?Department $department = null;
public $onTerminate;
public function bindClosure(): void {
// $this をクロージャが保持し、さらにクロージャをオブジェクトが保持する強力な循環
$this->onTerminate = function() {
echo “Employee {$this->department} terminated.\n”;
};
}
}
⭕ 堅牢なリファレンス実装:弱参照(WeakReference)の活用
PHP 7.4以降では `WeakReference` が導入され、Zend Engineの参照カウントを汚染せずにオブジェクトを指し示すことができるようになった。これを用いた、実務に耐えうる堅牢なツリー構造の構築例を示す。
declare(strict_types=1);
namespace App\MemoryManagement;
/
- 堅牢な組織構造モデル(メモリリーク・フリー設計)
- 循環参照を WeakReference で断ち切り、Zend VMのGC負荷を極小化する。
/
final class EnterpriseDepartment
{
/ @var array
private array $employees = [];
public function addEmployee(EnterpriseEmployee $employee): void
{
// WeakReference により、参照カウントをインクリメントせずに保持
$this->employees[$employee->getId()] = WeakReference::create($employee);
}
public function getActiveEmployees(): array
{
$active = [];
foreach ($this->employees as $id => $ref) {
$employee = $ref->get();
if ($employee !== null) {
$active[] = $employee;
} else {
// 既に別の場所で破棄されている場合はバッファからパージ
unset($this->employees[$id]);
}
}
return $active;
}
}
final class EnterpriseEmployee
{
private string $id;
// 親への参照はWeakReferenceまたはID保持に留めるのが鉄則
private ?WeakReference $departmentRef = null;
public function __construct(string $id)
{
$id = $id;
}
public function getId(): string
{
return $this->id;
}
public function assignDepartment(EnterpriseDepartment $department): void
{
$this->departmentRef = WeakReference::create($department);
}
}
// — 実行・検証コード —
// $dept = new EnterpriseDepartment();
// $emp = new EnterpriseEmployee(“EMP-001”);
// $dept->addEmployee($emp);
// $emp->assignDepartment($dept);
//
// // $emp や $dept を unset すれば、循環参照がないため即座にメモリが解放される
// unset($emp, $dept);
// echo “Memory peak: ” . memory_get_usage(true) . “\n”;
—
4. テックリードからの実務的アドバイス:GCの制御とモニタリング
Zend VMのGCは優秀だが、「万能の魔法」ではない。マーク・スイープフェーズはグラフ探索を伴うため、CPUサイクルを消費する(Stop-the-World的な遅延はないが、ルートバッファが溢れた瞬間に重いスキャンが走る)。
長寿命プロセス(DaemonやQueue Worker)をPHPで構築する場合、以下の運用指針を厳守してほしい。
1. GCの明示的な制御(gc_collect_cycles)
メモリを大量消費するバッチ処理や、1万件以上のレコードをループで処理するORMのイテレーションの内部では、適切なタイミングで `gc_collect_cycles()` を手動呼び出しするか、あるいは自動GCを一時的に無効化(`gc_disable()`)してバッチ終了時にまとめて回収するチューニングを検討せよ。
2. __destruct() の罠
循環参照の中に `__destruct()` メソッドを持つオブジェクトが含まれている場合、PHP 8以前ではGCがどの順序で破棄していいか判断できず、メモリリークとして放置されるか、未定義な挙動を引き起こす原因になった。(PHP 8.4現在でも、デストラクタを持つオブジェクトの循環参照の扱いは慎重になるべきである)。極力、ドメインモデルに重い `__destruct` を書くべきではない。
3. メモリプロファイリングの習慣化
`memory_get_usage(true)` や Xdebug / Blackfire を用いて、リクエスト間のメモリ消費量のデルタ(差分)を常に監視しろ。正常なアプリケーションであれば、リクエスト終了時のメモリ使用量は綺麗にベースラインに戻るはずだ。もし右肩上がりに増えているなら、それはマークフェーズすら裏切る「意図せぬ参照の鎖」がどこかに残っている証拠である。
低レイヤのメモリモデルを知る者だけが、真にスケーラブルで頑健なPHPアプリケーションを書き上げることができる。さあ、今すぐ手元のコードベースの「参照の向き」を見直そう。