循环引用收集器
概述
循环引用收集器(Cycle Collector)是 PHP 垃圾回收机制的组成部分,专门处理引用计数无法解决的循环引用问题。当两个或多个变量互相引用形成一个闭环,且没有外部变量引用时,引用计数无法将它们标记为可回收——它们的引用计数永远不为 0。循环引用收集器通过专门的算法检测并回收这种"孤岛"内存。
核心机制
循环引用收集器定期运行,检测所有可能的引用根(possible roots),标记可达对象,回收不可达的循环引用。
基础概念
循环引用问题
当对象 A 引用对象 B,对象 B 又引用对象 A 时,即使外部不再引用 A 或 B,它们的引用计数也不为 0,导致内存泄漏。
gc_collect_cycles 算法
PHP 的循环引用收集器使用"标记-清除"(Mark-and-Sweep)算法的变体:
- 将所有可能的根标记为"灰色"(待检查)
- 从每个灰色根开始,递归标记所有可达对象为"绿色"(活跃)
- 无法到达的对象保持"灰色",将被回收
- 清除所有灰色对象
触发条件
循环引用收集器在 root buffer 满时自动触发。root buffer 的大小默认为 10000。
语法与代码
循环引用示例
php
<?php
declare(strict_types=1);
class Node
{
public ?Node $next = null;
public function __construct(public readonly string $name) {}
}
// 创建循环引用
$a = new Node('A');
$b = new Node('B');
$a->next = $b;
$b->next = $a;
// 删除外部引用
unset($a);
unset($b);
// 此时 Node A 和 Node B 互相引用
// 引用计数:A->next (B) = 1, B->next (A) = 1
// 外部无引用,但引用计数不为 0 → 内存泄漏(暂时的)
// 手动触发循环引用收集器
gc_collect_cycles();
// Node A 和 Node B 被回收树形结构的循环引用
php
<?php
declare(strict_types=1);
class TreeNode
{
/** @var array<TreeNode> */
public array $children = [];
public ?TreeNode $parent = null;
public function __construct(public readonly string $value) {}
}
// 创建树
$root = new TreeNode('root');
$child1 = new TreeNode('child1');
$child2 = new TreeNode('child2');
$child1->parent = $root;
$child2->parent = $root;
$root->children = [$child1, $child2];
// 删除根节点引用
unset($root);
// child1 和 child2 的 parent 仍引用 root
// root 的 children 仍引用 child1 和 child2
// 形成循环引用
gc_collect_cycles(); // 回收验证循环引用回收
php
<?php
declare(strict_types=1);
class Tracker
{
public static int $instances = 0;
public function __construct()
{
self::$instances++;
}
public function __destruct()
{
self::$instances--;
}
}
function createCycle(): void
{
$a = new Tracker();
$b = new Tracker();
$a->ref = $b;
$b->ref = $a;
// 函数结束时 $a 和 $b 离开作用域
// 但循环引用阻止回收
}
echo "创建前: " . Tracker::$instances . "\n"; // 0
createCycle();
echo "创建后: " . Tracker::$instances . "\n"; // 2(循环引用未回收)
gc_collect_cycles();
echo "GC 后: " . Tracker::$instances . "\n"; // 0(已回收)详细说明
root buffer 机制
PHP 维护一个 "root buffer"(根缓冲区),存储可能存在循环引用的"疑似根":
- 当一个 zval 的 refcount 减少但未归零时,它可能被加入 root buffer
- 当 root buffer 满时(默认 10000 个条目),触发循环引用收集
- 收集完成后,root buffer 被清空
收集算法步骤
1. 将 root buffer 中所有 zval 标记为"灰色"
2. 对每个灰色 zval:
a. 减少 refcount(模拟删除外部引用)
b. 如果 refcount > 0,标记为"白色"(仍然有外部引用)
c. 如果 refcount = 0,标记为"黑色"(可以回收)
3. 恢复步骤 2 中减少的 refcount
4. 删除所有"黑色" zval触发条件
| 条件 | 说明 |
|---|---|
| root buffer 满 | 默认 10000 个疑似根 |
手动调用 gc_collect_cycles() | 显式触发 |
gc_enable() 后的自动触发 | GC 启用时 |
实战示例
实战:检测内存泄漏模式
php
<?php
declare(strict_types=1);
class MemoryLeakDetector
{
private int $baseline = 0;
public function start(): void
{
gc_collect_cycles();
$this->baseline = memory_get_usage();
}
public function check(string $label): void
{
gc_collect_cycles();
$current = memory_get_usage();
$diff = $current - $this->baseline;
$status = $diff > 0 ? "+" : "";
echo "{$label}: {$status}{$diff} bytes (total: {$current})\n";
}
public function report(): void
{
$status = gc_status();
echo "GC 运行次数: {$status['runs']}\n";
echo "GC 收集根数: {$status['collected']}\n";
echo "root buffer 阈值: {$status['threshold']}\n";
echo "root buffer 当前: {$status['roots']}\n";
}
}
$detector = new MemoryLeakDetector();
$detector->start();
// 模拟产生循环引用
$detector->check("创建前");
for ($i = 0; $i < 1000; $i++) {
$a = new stdClass();
$b = new stdClass();
$a->ref = $b;
$b->ref = $a;
unset($a, $b);
}
$detector->check("创建循环引用后");
gc_collect_cycles();
$detector->check("GC 后");
$detector->report();实战:ORM 中的循环引用处理
php
<?php
declare(strict_types=1);
// 常见 ORM 循环引用模式
class Author
{
/** @var array<Book> */
public array $books = [];
public function __construct(public readonly string $name) {}
}
class Book
{
public ?Author $author = null;
public function __construct(public readonly string $title) {}
}
// 创建双向引用
$author = new Author("Alice");
$book = new Book("PHP 高级编程");
$author->books[] = $book;
$book->author = $author;
// 删除外部引用 — 形成循环
unset($author, $book);
// 检查 GC 状态
echo "GC 前 - roots: " . gc_status()['roots'] . "\n";
gc_collect_cycles();
echo "GC 后 - collected: " . gc_status()['collected'] . "\n";实战:双向链表的内存管理
php
<?php
declare(strict_types=1);
class ListNode
{
public ?ListNode $prev = null;
public ?ListNode $next = null;
public function __construct(public readonly mixed $value) {}
public function __destruct()
{
echo "节点 {$this->value} 被销毁\n";
}
}
function createCircularList(int $size): void
{
$head = new ListNode(0);
$current = $head;
for ($i = 1; $i < $size; $i++) {
$node = new ListNode($i);
$current->next = $node;
$node->prev = $current;
$current = $node;
}
// 闭合循环
$current->next = $head;
$head->prev = $current;
unset($head, $current);
}
createCircularList(5);
echo "循环引用未回收\n";
gc_collect_cycles();
echo "循环引用已回收\n";实战:观察 GC 对内存的影响
php
<?php
declare(strict_types=1);
function createManyCycles(int $count): void
{
for ($i = 0; $i < $count; $i++) {
$a = new stdClass();
$b = new stdClass();
$a->ref = $b;
$b->ref = $a;
unset($a, $b);
}
}
$memBefore = memory_get_usage();
createManyCycles(5000);
$memAfter = memory_get_usage();
echo "循环引用创建后内存增长: " . ($memAfter - $memBefore) . " bytes\n";
gc_collect_cycles();
$memAfterGC = memory_get_usage();
echo "GC 后内存减少: " . ($memAfter - $memAfterGC) . " bytes\n";注意事项
GC 的性能开销
循环引用收集器运行时有一定性能开销。在高性能要求的场景中,可以临时禁用 GC。
GC 不是实时的
循环引用收集器在 root buffer 满时才触发,不是在每次 unset 后立即运行。
最佳实践
- 避免不必要的循环引用:尽量使用单向引用。
- 使用 WeakReference/WeakMap:替代方案。
- 长运行脚本定期手动触发 GC:
gc_collect_cycles()。 - 监控 GC 状态:使用
gc_status()检查。
php
<?php
declare(strict_types=1);
// 使用 WeakMap 避免循环引用
$cache = new \WeakMap();
$obj = new stdClass();
$cache[$obj] = 'cached data';
unset($obj); // $obj 被回收,WeakMap 中的条目自动删除深入分析:root buffer 的工作原理
root buffer 是什么
root buffer 是一个固定大小的数组(默认 10000 个条目),用于存储"疑似循环引用根"。当一个 zval 的 refcount 减少但未归零时,它可能被加入 root buffer 作为疑似根。
root buffer 的填充过程
php
<?php
declare(strict_types=1);
function observeRootBuffer(): void
{
$status = gc_status();
echo "初始 roots: {$status['roots']}\n";
echo "阈值: {$status['threshold']}\n";
// 创建少量循环引用
for ($i = 0; $i < 100; $i++) {
$a = new stdClass();
$b = new stdClass();
$a->ref = $b;
$b->ref = $a;
unset($a, $b);
}
$status = gc_status();
echo "创建 100 个循环后 roots: {$status['roots']}\n";
}
observeRootBuffer();调整 root buffer 大小
php
<?php
declare(strict_types=1);
// 通过 ini_set 调整(PHP 8.3+)
// ini_set('zend.gc_max_roots', 20000);
// 通过 php.ini 调整
// zend.gc_max_roots = 20000
// 较大的 root buffer 意味着 GC 触发频率更低
// 但每次触发时需要处理更多疑似根何时应该手动触发 GC
php
<?php
declare(strict_types=1);
class QueueWorker
{
private int $processedSinceLastGc = 0;
private const GC_INTERVAL = 500;
public function processJob(array $job): void
{
// 处理任务...
$this->processedSinceLastGc++;
if ($this->processedSinceLastGc >= self::GC_INTERVAL) {
$this->processedSinceLastGc = 0;
$collected = gc_collect_cycles();
if ($collected > 0) {
echo "GC: 回收了 {$collected} 个循环引用\n";
}
}
}
}
$worker = new QueueWorker();
// 模拟处理 1000 个任务
for ($i = 0; $i < 1000; $i++) {
$worker->processJob(['id' => $i]);
}