Skip to content

循环引用收集器

概述

循环引用收集器(Cycle Collector)是 PHP 垃圾回收机制的组成部分,专门处理引用计数无法解决的循环引用问题。当两个或多个变量互相引用形成一个闭环,且没有外部变量引用时,引用计数无法将它们标记为可回收——它们的引用计数永远不为 0。循环引用收集器通过专门的算法检测并回收这种"孤岛"内存。

核心机制

循环引用收集器定期运行,检测所有可能的引用根(possible roots),标记可达对象,回收不可达的循环引用。

基础概念

循环引用问题

当对象 A 引用对象 B,对象 B 又引用对象 A 时,即使外部不再引用 A 或 B,它们的引用计数也不为 0,导致内存泄漏。

gc_collect_cycles 算法

PHP 的循环引用收集器使用"标记-清除"(Mark-and-Sweep)算法的变体:

  1. 将所有可能的根标记为"灰色"(待检查)
  2. 从每个灰色根开始,递归标记所有可达对象为"绿色"(活跃)
  3. 无法到达的对象保持"灰色",将被回收
  4. 清除所有灰色对象

触发条件

循环引用收集器在 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 后立即运行。

最佳实践

  1. 避免不必要的循环引用:尽量使用单向引用。
  2. 使用 WeakReference/WeakMap:替代方案。
  3. 长运行脚本定期手动触发 GCgc_collect_cycles()
  4. 监控 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]);
}

参考链接