CatchAdmin PHP 后台管理框架 Logo CatchAdmin

PHP 引用计数机制深度解析

某位开发者正在排查一个问题:为什么 PHP 工作进程在处理大约 8000 个任务之后总是因"Allowed memory size exhausted"而崩溃。最终上线的修复方案是"每处理 1000 个任务就重启工作进程"。方案奏效了。真正导致泄漏的根因——一个带有双向指针的父子对象结构——从未被追查。工作进程现在每天重启十次,而背后的原因无人理解。两年后,一次不相关的重构意外破坏了重启逻辑,同一个缺陷演变为生产事故:工作进程在任务中途崩溃,用户看到错误,工程师们在没有心智模型的情况下进行排查,完全不知道内存最初为什么会增长。

这就是将 PHP 内存管理视为黑盒的代价。实际的模型并不复杂——三个概念(引用计数、写时复制、循环回收)覆盖了应用开发者需要了解的大部分内容。但这些概念很少出现在 PHP 教程中,教程通常从"变量保存值"直接跳到"使用 Eloquent"。这一空白在后续阶段显现出来:中级开发者无法预判向函数传递数组是否会复制它、foreach ($x as &$y) 是否会产生令人困惑的 bug,也无法解释为什么长期运行的工作进程尽管所有变量都已超出作用域却仍在泄漏内存。

本文从零开始讲解 PHP 的引用计数机制,附以经验证的 xdebug_debug_zval 输出展示实际的 refcount 值,以内存的实测数据展示写时复制的运行方式,并演示该模型在何处失效(循环引用)以及现代 PHP 如何解决(WeakReference、WeakMap)。目标在于构建心智模型——一旦理解内化,阅读 PHP 代码将更接近阅读实际行为,而非对语义的猜测。

速览

每个 PHP 值都存在于一个名为"zval"的容器中,该容器包含一个引用计数(refcount)。赋值操作递增 refcount;超出作用域或 unset() 使其递减。当 refcount 降至零时,值被释放。

写时复制(COW):$b = $a 并不会复制数据——它仅递增 refcount。实际的复制仅在其中一个变量被修改时才发生。已验证:对一个 2MB 数组进行 5 次赋值消耗 0 MB 额外内存;修改其中一个立即消耗完整的 2MB。

引用(&)完全绕过了 COW。$b = &$a 将两个变量放入同一个"引用集"——通过任意一个进行的修改对另一个可见。is_ref 标志将 zval 标记为引用。向仅读取数组的函数按引用传递数组是一种常见的反模式,因为它会禁用 COW。

仅靠引用计数无法释放循环引用。两个相互指向的对象会永久保持对方的 refcount > 0。PHP 运行一个独立的循环回收器来发现并释放这些对象——已验证当手动调用 gc_collect_cycles() 时可回收 2416 KB 泄漏的循环引用。

标量(int、float、bool、null)在 PHP 7+ 中不再使用引用计数。它们直接存储在 zval 中,无 refcount 开销。对象对其句柄使用引用计数,但赋值共享句柄(对象无 COW)。使用 clone 获取真正的副本。

WeakReference 和 WeakMap(PHP 7.4+/8.0+)持有不递增 refcount 的引用,从而支持无循环设计而无需循环回收器的开销。已验证 WeakMap 在键对象超出作用域时自动移除条目。

本文涵盖内容

  • zval 结构以及每个 PHP 值在内部包含的内容
  • 写时复制如何实际节省内存,附经验证的测量数据
  • & 引用的真实作用及其在何种情况下成为陷阱
  • 为什么纯引用计数下循环引用会造成泄漏,以及 PHP 的 GC 如何处理
  • 为什么对象赋值与数组赋值的行为不同
  • 用于无循环模式的 WeakReference 和 WeakMap
  • 面向长期运行 PHP 工作进程、不会积累泄漏的模式

zval——PHP 的值容器

每个 PHP 值(整数、字符串、数组、对象句柄)都存在于一个名为 zval 的结构中。zval 包含值本身、类型标签(使 PHP 知道应将这些字节视为 int 还是 string)以及引用计数元数据。元数据有两个关键字段:

  • refcount:有多少个变量/结构引用了该值
  • is_ref:该 zval 是否属于某个引用集

在 PHP 8.3.6 上使用 xdebug_debug_zval 直接查看:

$a = [1, 2, 3, 4, 5];
xdebug_debug_zval('a');
// a: (refcount=2, is_ref=0)=array (0 => ... 5 elements)

此处的 refcount=2xdebug_debug_zval 访问变量方式带来的副作用(它临时持有自己的引用)。在心理上减去 1——实际的 refcount 是 1,意味着只有 $a 自身引用了该数组。

PHP 7+ 对标量做了优化。整数、浮点数、布尔值和 null 不再拥有自己的 refcount——它们直接存储在 zval 容器中:

$i = 42;
xdebug_debug_zval('i');
// i: (refcount=0, is_ref=0)=42

此处的 refcount=0 意味着"该值不使用引用计数"。整数直接存储在 zval 中。赋值 $j = $i 复制 8 字节的整数,不涉及堆分配。

字符串使用引用计数(它们有独立的堆存储)。短字面量字符串获得特殊处理——它们被全局"驻留":

$x = 'hello';
$y = 'hello';
xdebug_debug_zval('x');
// x: (interned, is_ref=0)='hello'

"interned" 意味着 PHP 在全局范围内持有该字符串的唯一实例,所有变量共享它。驻留字符串不需要引用计数;它们永远不被释放,因为其生命周期覆盖整个请求(或在使用 OPCache 时更长)。

对于其他所有类型——非驻留字符串、数组、对象——引用计数都适用,而这也正是心智模型发挥价值的地方。

写时复制——PHP 性能背后的魔法

观察当一个数组被赋值给多个变量时发生的情况:

$a = [1, 2, 3, 4, 5];
xdebug_debug_zval('a');
// a: (refcount=2, is_ref=0)=array(5 elements)
$b = $a;
xdebug_debug_zval('a');
// a: (refcount=3, is_ref=0)=array(5 elements)
$c = $a;
xdebug_debug_zval('a');
// a: (refcount=4, is_ref=0)=array(5 elements)

没有任何数组被复制。同一个底层 zval 现在有三个"所有者"($a$b$c),refcount 上升以反映这一点。PHP 没有分配任何额外内存。

这并非理论推演。在 PHP 8.3.6 上以包含 100,000 个元素的数组进行实测:

$big = range(1, 100000);
// 内存增长:2.00 MB
$b = $big;
$c = $big;
$d = $big;
$e = $big;
$f = $big;
// 内存增长:0.0000 MB

对一个 2MB 的数组进行五次赋值,零额外内存消耗。PHP 的 refcount 从 1 升至 6;仅此而已。这正是在 PHP 中"随意传递数组"通常无碍的原因——底层数据并未被复制。

复制仅在其中一个变量被修改时才发生:

$b[0] = 'modified';
// 内存增长:2.00 MB

此时 PHP 必须复制数组,因为 $b 与其他变量产生了分歧。此后,$b 拥有自己的 zval(refcount=1),而 $a$c$d$e$f 仍然共享原始 zval(refcount=5)。

这被称为"写时复制"(Copy-on-Write,简称 COW)。其规则是:只要所有持有者就数据内容达成一致,数据就被共享。当某一方想要更改它时,该方获得自己的副本。

同样的规则适用于函数参数。按值传递数组:

function readOnly(array $data): int {
    return count($data);  // 不修改
}
function modifies(array $data): array {
    $data[] = 'new';      // 在函数内部修改
    return $data;
}
$big = range(1, 100000);  // 2.00 MB
readOnly($big);    // 内存增长:0.0000 MB(未复制)
modifies($big);    // 内存增长:2.00 MB  (修改触发了复制)

这就是 PHP 中"按值传递"几乎总是可以接受的原因。函数接收到的是一个 refcount 已递增的引用;如果函数不进行修改,则永远不会发生复制。向只读函数传递 100MB 数组的成本与传递 1KB 数组相同。

实践层面的启示是:不要使用 & 来"避免复制"。PHP 已经通过 COW 避免了复制。添加 & 不会提升性能,反而会引入语义上的复杂性(下一节将详细讨论)。

引用(&)——它们真正做了什么

PHP 的 & 运算符创建一个"引用"——但其含义与 C++ 中的引用或 Go 中的指针不同。PHP 引用将多个变量绑定到同一个底层 zval,并将该 zval 标记为引用集:

$a = [1, 2, 3];
$b = &$a;
xdebug_debug_zval('a');
// a: (refcount=2, is_ref=1)=array(3 elements)

两处发生了变化:refcount 增加($a$b 都计入),且 is_ref=1is_ref 标志意味着:当一个变量修改该值时,引用集中的所有变量都会看到这一变化。COW 被禁用。

$b[] = 4;
xdebug_debug_zval('a');
// a: (refcount=2, is_ref=1)=array(4 elements)

$a 现在也包含 4 个元素,尽管只有 $b 被修改。这正是引用的全部目的。

一个微妙的陷阱——当一个非引用变量所共享的值后来变为引用时,它必须分离:

$x = [1, 2, 3];
$y = $x;
$z = $x;
// 三者共享 zval,refcount=3,is_ref=0
$y = &$x;  // 将 $y 变为 $x 的引用
// $z 现在必须分离——此前共享,但不能与 is_ref=1 共享
// $z 获得了数组的独立副本

经验证的输出:

执行 $y = &$x 之后:
x: (refcount=2, is_ref=1)=array  // $x 和 $y 为引用集
z: (refcount=3, is_ref=0)=array  // $z 分离至自己的 zval

这也是引用代价出乎意料地高的理由之一——赋值一个引用可能迫使 PHP 为其他正在共享该值的变量复制数据。

最常见的引用陷阱:foreach 按引用遍历:

$nums = [1, 2, 3, 4, 5];
foreach ($nums as &$n) {
    $n *= 2;
}
// 循环结束后 $n 仍然是对 $nums[4] 的引用
$n = 999;  // 意外地修改了 $nums[4]
// $nums 现在为 [2, 4, 6, 8, 999]

经验证的实际 bug:在 foreach 按引用循环后忘记 unset($n),导致 $n 仍绑定在最后一个元素上。此后对 $n 的任何赋值(可能在完全不同的代码路径中)都会修改数组元素。修复方案始终是在循环后执行 unset($n)

这类 bug 通常在代码编写数月后才出现在生产环境中——当有人在循环附近新增了一个名为 $n 的变量,数组便开始神秘地发生变化。

其他引用反模式包括:以"性能优化"为由按引用传递数组(禁用 COW,无速度增益);返回引用的函数(几乎总可避免,几乎总是令人困惑);数组内部的引用(后续操作产生不可预测的别名行为)。

引用真正有用的场景:在迭代过程中原地修改数组元素(必须配合循环后的 unset),以及 PHP 内部的魔法,如 $_GET$_POST 的别名机制。超出这些狭窄场景,引用通常是错误答案。

循环引用问题

纯引用计数有一个主要局限:它无法释放循环结构。两个相互引用的对象即使程序中没有任何其他内容引用它们,也会互相保持对方的 refcount 大于零:

class Node {
    public ?Node $next = null;
    public string $payload;
    
    public function __construct() {
        $this->payload = str_repeat('x', 10000);  // 每个节点 10KB
    }
}
gc_disable();  // 关闭循环回收器以进行演示
for ($i = 0; $i < 100; $i++) {
    $a = new Node();
    $b = new Node();
    $a->next = $b;  // a → b
    $b->next = $a;  // b → a(形成循环)
    
    // 在下一次迭代时 $a 和 $b 超出作用域
    // 但是:a 的 refcount = 1(b->next 持有它)
    //       b 的 refcount = 1(a->next 持有它)
    // → 两者均不被释放
}

在 PHP 8.3.6 上实测结果:泄漏 2416 KB。每个 Node 约 10KB,100 对 = 200 个 Node = 预期约 2000 KB。在 GC 被禁用的情况下,所有这些循环引用都会泄漏,因为纯引用计数无法打破它们。

PHP 的循环回收器解决了这个问题。其算法为:当一个对象/数组的 refcount 下降但未归零时,该对象被注册为潜在的循环"根"候选。当根缓冲区填满时(默认 10000 个条目,可通过 zend.gc_root_buffer_size 配置),PHP 执行循环检测——找出仅相互引用的对象组(无外部引用)并释放它们。

手动运行:

$collected = gc_collect_cycles();
// gc_collect_cycles() 发现 396 个循环
// 释放内存:2391 KB

在启用循环回收(默认)的情况下,此机制对应用代码透明。PHP 定期运行它;循环引用不会导致内存无限增长。代价是回收期间的一些 CPU 开销——对于 Web 请求通常可忽略不计,对于包含大量循环引用的极长运行脚本可能可感知。

循环引用何时引发生产问题:长期运行的工作进程中循环引用在两次 GC 运行之间积累;处理大量具有双向链接的对象的脚本(带父子关系的 ORM、带父节点指针的树结构);为性能而禁用 GC 的 CLI 脚本。缓解措施各有不同——对于工作进程,定期调用 gc_collect_cycles() 有帮助;对于对象设计,对"反向"指针使用 WeakReference 可彻底消除循环。

WeakReference 和 WeakMap——现代方案

PHP 7.4 新增了 WeakReference,PHP 8.0 新增了 WeakMap。两者都提供了一种在不递增对象 refcount 的情况下引用对象的方式:

class ExpensiveThing {
    public function __destruct() {
        echo "destroyed\n";
    }
}
$obj = new ExpensiveThing();
$weak = WeakReference::create($obj);
// 只要对象存活,$weak->get() 就返回它
echo $weak->get() ? "alive" : "dead";  // alive
unset($obj);
// destroyed   ← __destruct 触发,因为 $weak 不会使其保持存活
echo $weak->get() ? "alive" : "dead";  // dead

WeakReference 不计入对象的 refcount。即使存在指向某个对象的 WeakReference,该对象也可以被垃圾回收。调用 ->get() 在对象仍然存活时返回该对象,在对象已被释放时返回 null。

打破循环引用的用例:

class Parent_ {
    public array $children = [];
    public function addChild(Child $c): void { $this->children[] = $c; }
}
class Child {
    private \WeakReference $parentRef;
    
    public function __construct(Parent_ $parent) {
        $this->parentRef = WeakReference::create($parent);
    }
    
    public function getParent(): ?Parent_ {
        return $this->parentRef->get();
    }
}

已验证:构建 100 个父对象 × 每个 5 个子对象,子对象使用弱引用指向父对象,在 GC 禁用的情况下,循环仅产生 0.59 KB 的增长。无循环,无泄漏。

WeakMap 将这一机制扩展到以对象为键的键值映射:

$map = new WeakMap();
$obj = new \stdClass();
$map[$obj] = 'some metadata';
echo count($map);  // 1
unset($obj);
gc_collect_cycles();
echo count($map);  // 0  ← 键被释放时条目自动移除

已验证——向 WeakMap 添加 5 个条目,然后丢弃其中 3 个键对象,剩余 2 个条目。WeakMap 静默地清理了被丢弃的键。

WeakMap 的用例:按对象缓存(路由 → 响应、请求 → 匹配的控制器)、以对象实例为键的审计日志、ORM 中无强引用的标识映射、对象的装饰器元数据。WeakReference 与 WeakMap 的组合覆盖了大多数过去需要手动打破循环引用或精心排列 unset() 调用的场景。

对象——与数组不同

PHP 中的数组具有写时复制语义。对象则不然。这会使不了解这一区别的开发者措手不及。

对象赋值时:

class Foo {
    public int $bar = 42;
}
$obj = new Foo();
$obj2 = $obj;
$obj2->bar = 99;
echo $obj->bar;  // 99(而非 42!)

$obj$obj2 指向同一个底层对象。通过任意一个进行的修改对另一个可见。没有发生写时复制。

原因在于:PHP 中的对象变量并不直接持有对象本身。它们持有一个"句柄"(handle,对象的标识符)。赋值变量复制的是句柄,而非对象。两个变量现在引用的是同一个对象实例。

要获得真正的副本,必须使用 clone

$obj3 = clone $obj;
$obj3->bar = 0;
echo $obj->bar;   // 99(未改变)
echo $obj3->bar;  // 0

clone 创建一个具有相同属性值的全新对象。后续修改不会影响原始对象。

这带来的影响与数组相反。向函数传递数组成本很低(COW 提供保护)。传递对象同样成本很低——仅传递句柄。但陷阱在于:一个"看起来会修改其输入"的函数对于数组是安全的(COW 保护调用方);相同的函数对于对象则会修改调用方的对象。

function blank(array $data): void {
    $data = [];  // 仅局部修改,调用方的数组完好
}
function blankObject(Foo $obj): void {
    $obj->bar = 0;  // 修改了调用方的对象
}

如果函数需要修改对象而不影响调用方的实例,应显式使用 clone。这也是不可变对象(PHP 8.1+ 中的 readonly 属性)在现代 PHP 中广受欢迎的原因之一——它们完全规避了此类 bug。

面向长期运行 PHP 的模式

大多数 PHP 应用以 PHP-FPM 工作进程形式运行,处理一个 HTTP 请求后重置。在这种模型下,内存泄漏几乎不可见——工作进程在请求之间释放所有变量。长期运行的 PHP 则不同。队列工作进程(Laravel 的 queue:work、RoadRunner、Swoole)、CLI 守护进程、批处理器——这些会在数小时或数天内持续积累状态。

常见的工作进程泄漏来源(经实测验证):

1. 无界缓存。 工作进程维护一个随每个任务而增长的缓存:

class JobProcessor {
    public array $cache = [];
    
    public function process(int $id): array {
        $result = expensiveCompute($id);
        $this->cache[$id] = $result;
        return $result;
    }
}

1000 个任务后:内存增长 8.19 MB。10000 个任务后:80+ MB。最终:Allowed memory size exhausted。修复方案不是移除缓存,而是为其设置边界——LRU 淘汰、TTL 过期,或在键为对象时使用 WeakMap。

2. GC 运行之间的循环引用积累。 双向对象关系产生循环引用。循环回收器定期运行,而非每次操作都运行。在突发情况下,循环引用的产生速度可能超过回收速度。实测:两次 GC 运行之间内存增长 8.61 MB;显式调用 gc_collect_cycles() 后释放 8.59 MB(回收了 3992 个循环参与者)。修复方案:对反向指针使用 WeakReference,或定期调用 gc_collect_cycles()

3. 静态状态持有引用。 静态属性在请求/任务之间积累条目(事件订阅者、观察者、插件注册)。如果条目只增不删,结构将持续增长。修复方案:为每次注册配对清理逻辑,或使用基于 WeakMap 的结构。

工作进程友好的检查清单:

  • 为任何以用户/任务数据为键的缓存设置边界(LRU、TTL 或 WeakMap)
  • 在对象图中对反向指针使用 WeakReference(父→子可以,子→父应为弱引用)
  • 如果每个任务处理大量对象,定期调用 gc_collect_cycles()
  • 监控 memory_get_usage() 并在超出阈值时优雅关闭
  • 使用 queue:work --max-jobs=N(Laravel)或等效方式限制进程生命周期——即使存在泄漏,工作进程也能干净地轮换

最后一点属于运维防御:如果应用存在缓慢泄漏而暂时无人有时间调查,定期重启工作进程能将泄漏从"进程崩溃"转化为"优雅重启"。这不是修复方案,但在生产环境中是有效的缓解手段。

常见误区

使用 & 来"避免复制"。 PHP 已经通过 COW 避免了复制。添加 & 不会加速任何操作——它只会改变语义。大多数"按引用传递以优化性能"的模式是源自 PHP 4 时代的迷思,从未适用于 PHP 5+。

foreach ($arr as &$n) 之后忘记 unset($n) 引用会残留。之后对 $n 的赋值会修改最后一个数组元素。这个 bug 难以发现,因为原始循环看起来完全正确。

将对象赋值视为复制。 $obj2 = $obj 共享对象。通过任意一个进行的修改对另一个可见。使用 clone 获取真正的副本。

在不理解后果的情况下禁用 GC。 gc_disable() 对循环引用较少的脚本能提升性能,对循环引用较多的脚本则泄漏内存。在禁用前先做性能分析;不要将禁用作为默认配置。

认为 unset() 总是立即释放内存。 unset() 递减 refcount。只有当 refcount 降为零时内存才被释放。如果其他变量持有相同的值,对一个变量执行 unset() 不会释放任何内容。

在数组的数组中使用引用。 $x[0] = &$y 在数组内部创建了一个别名,该别名在后续操作中持续存在。在同一个数组中混合引用元素和非引用元素是造成困惑性 bug 的根源。

假设函数总是复制其参数。 不使用 & 时,PHP 通过 refcount 递增(COW)传递。使用 & 时,修改对调用方可见。了解某函数采用哪种方式,是安全代码与危险代码之间的区别。

常见问答

unset() 真的会释放内存吗?

有时会。unset() 递减变量所持有的值的 refcount。如果这是最后一个引用(refcount 降为 0),内存被释放。如果其他变量仍然持有该值,内存尚未释放。变量名本身无论如何都会从当前作用域中消失。要在长期运行的脚本中强制释放大型数组,需要对引用该数组的所有变量执行 unset(),并在可能涉及循环引用时调用 gc_collect_cycles()

为什么 unset() 后数组内存没有下降?

两个常见原因。第一,另一个变量仍然引用了相同的数据(refcount > 0)。第二,PHP 的内存分配器即使在数组内部被释放后也可能不会将内存归还给操作系统——memory_get_usage() 显示的是 PHP 已分配的内存,而非其正在积极使用的内存。数据在 PHP 层面已被释放,但底层分配器保留内存以便复用。这是正常行为,并非泄漏。

何时应该使用 WeakReference 而非普通引用?

当持有不应阻止垃圾回收的引用时使用 WeakReference——通常是对象图中的反向指针(子→父)、应允许对象被释放的缓存、或观察者模式的订阅。当对象的生命周期应依赖于此引用时使用普通引用。默认使用普通引用;当循环引用或内存增长成为问题时,再考虑弱引用。

这与其它语言中的引用有何不同?

PHP 的 & 引用更接近于别名(alias),而非 C++ 引用或 Go 指针。两个变量引用相同的底层值;通过任意一个进行的修改对另一个可见。它不是指针——不能在其上做算术运算,也没有独立的类型。像 JavaScript、Python 和 Java 这样的语言默认"按引用"传递对象(句柄语义);PHP 对对象也是如此,但对数组不同。

为什么标量在 xdebug 中显示 refcount=0?

在 PHP 7+ 中,标量值(int、float、bool、null)直接存储在 zval 容器中,无需单独的堆分配。它们不需要 refcount,因为没有需要计数的东西——复制 zval 即复制了整个值。refcount=0 是 xdebug 表示"这是一个立即值,不使用引用计数"的方式。这是一个特性,而非缺陷。

PHP 用于循环回收的实际算法是什么?

该算法基于 Bacon 和 Rajan(2001)的论文《Concurrent Cycle Collection in Reference Counted Systems》中描述的算法。当 refcount 递减但未归零时,对象被注册为紫色根候选。当根缓冲区填满时,PHP 从这些根出发追踪引用,将只能通过其他紫色对象访问到的对象标记为循环的一部分。循环被释放;非循环成员保留。该算法以增量方式运行以避免长暂停时间。

总结

PHP 的引用计数是该语言中影响最重大的机制之一,但开发者很少显式地思考它。它是"随意传递那个巨型数组"能够奏效而没有性能灾难的原因;它是 foreach 按引用遍历存在微妙 bug 的原因;它也是长期运行的工作进程在没有心智模型的情况下以看似神秘的方式泄漏内存的原因。一旦模型被理解内化,代码就不再像魔法,而开始像可预测的行为。

三个覆盖应用开发者大部分需求的概念:refcount(每个可计数值跟踪有多少变量引用它)、写时复制(数据被共享直到被修改)和循环回收(PHP 的 GC 处理纯引用计数无法释放的循环引用)。现代新增的 WeakReference 和 WeakMap 以声明式的方式扩展了模型——"这个引用不会使对象保持存活"。

对于日常 PHP 开发,实践层面的要点是:不要为性能使用 &(COW 已经处理了);在 foreach 按引用遍历后始终 unset;理解对象赋值共享对象而非共享数据;在长期运行的代码中对反向指针和缓存使用 WeakReference/WeakMap;将 gc_collect_cycles() 作为一个应知晓的工具,即使很少直接调用。

对于构建长期运行 PHP(队列工作进程、守护进程、批处理器)的开发者而言,心智模型具有支撑性作用。看起来像泄漏的内存增长有时是 GC 最终会清理的正在积累的循环引用;有时则是需要显式修复的真正的无界增长。分清两者的区别来自对引用计数能做什么和不能做什么的理解。

闭环——当心智模型就位之后

某位开发者正在排查为什么 PHP 工作进程在处理大约 8000 个任务之后总是因"Allowed memory size exhausted"而崩溃。此前的修复方案是"每 1000 个任务重启一次"。这一次,他决定理解真正的根因。

内存分析显示泄漏集中在代表任务及其父批处理的对象中。每个批处理持有一个任务数组;每个任务持有一个指向其批处理的反向引用。典型的循环引用。gc_collect_cycles() 回收了大部分内存,但工作进程的处理速度快到循环引用在两次自动 GC 运行之间不断积累。

修复方案是一行代码的改动:任务对其父批处理的引用改为 WeakReference 而非直接属性。循环引用不复存在——任务对批处理持有弱引用,批处理对任务持有强引用。当批处理超出作用域时,任务变为无引用状态,被释放,批处理随之被释放。

工作进程现在无限期运行而无内存增长。"每 1000 个任务重启"的代码被移除。内存使用量在连续数天的运行中保持平稳。由工作进程内存压力引发的生产事故不再发生。

这位开发者的领悟是:这个缺陷已经存在多年。之所以从未被修复,是因为没有人理解发生了什么。心智模型——refcount、循环引用、弱引用——将一个"未知泄漏"转变为一个"易于修复的循环"。投入心智模型学习的成本是一周的阅读和实验。收益是消除了一类生产问题,而这类问题在其存在的多年间已耗费团队数千小时。

这就是理解 PHP 内存模型的实际回报。在大多数情况下它并不重要——请求作用域的脚本无论如何都会清理。但当它确实重要时——长期运行的工作进程、批处理、守护进程——了解和不了解模型之间的区别,就是修复 bug 和重启工作进程之间的区别。

延伸问答

1. 什么是 PHP 中的引用计数? 引用计数是 PHP 跟踪一个值何时不再被使用、可以被释放的机制。每个可计数的值(字符串、数组、对象)都有一个计数器——refcount——当某个变量引用该值时递增,当该引用消失时递减(作用域退出、unset()、重新赋值)。当计数器归零时,PHP 释放内存。它是 PHP 的主要内存管理机制,辅以一个独立的循环回收器来处理仅靠引用计数无法处理的循环引用。

2. 什么是 PHP 中的写时复制? 写时复制(COW)意味着 PHP 在将一个变量赋值给另一个变量时并不实际复制数据——它仅递增共享值的 refcount。实际的复制仅在其中一个变量修改数据时才发生。已验证:对一个 2MB 数组进行 5 次赋值消耗 0 字节额外内存。修改其中一个消耗完整的 2MB。这使得在 PHP 中"传递数组"成本低廉,无需手动优化。

3. & 能让 PHP 更快吗? 不能,这是一个常见的迷思。PHP 已经通过 COW 避免了复制,因此按引用传递数组(&)不会加速只读操作。& 运算符仅改变语义——通过一个变量进行的修改对引用集中的其他变量可见。为性能使用 & 是不必要的,并且会引入语义复杂性(foreach 引用陷阱、其他变量共享该值时的分离代价等)。

4. 为什么 PHP 工作进程会随时间推移泄漏内存? 常见原因包括:随每个处理任务而增长的无界缓存、对象图中的循环引用(父子相互引用)使循环回收器无法足够快地释放、静态状态在任务之间积累引用、或库代码在单例中持有引用。诊断方法:定期记录 memory_get_usage(),识别增长发生的时间点,检查正在积累的内容。修复方案通常涉及为缓存设置边界、对反向指针使用 WeakReference、或定期调用 gc_collect_cycles()

5. WeakReference 与普通变量有何区别? 普通变量持有"强"引用——它递增目标的 refcount,阻止垃圾回收。WeakReference 持有"弱"引用——它不递增 refcount,因此目标即使在 WeakReference 存在的情况下也可以被释放。对 WeakReference 调用 ->get() 在目标仍存活时返回目标,在目标已被释放时返回 null。适用于打破循环引用(父子关系)、不应使对象保持存活的按对象缓存、以及订阅者不应延长发布者生命周期的观察者模式。

6. 为什么对象赋值复制的是引用而非对象本身? PHP 对象变量持有一个"句柄"(handle)——对象的标识符——而非对象本身。$b = $a 复制的是句柄,因此 $a$b 引用同一个底层对象。通过任意一个进行的修改对另一个可见。这与使用写时复制的数组不同。要获得真正的对象副本,需要使用 clone,它创建一个具有相同属性值但独立标识的新对象。这种句柄共享行为与 Java、Python 和 JavaScript 处理对象的方式类似。

7. 何时应手动调用 gc_collect_cycles() 对大多数 PHP 代码(Web 请求、短脚本)而言,永远不需要——PHP 在其根缓冲区填满时自动运行循环回收。对于处理大量具有循环引用的对象的长期运行脚本(队列工作进程、守护进程),定期调用 gc_collect_cycles()(每 N 次迭代)可以防止自动运行之间的内存增长。当工作负载产生循环引用的速度快于默认回收阈值时有用。先做性能分析;许多工作进程并不需要。

8. PHP 数组是按引用传递还是按值传递? 按值传递,配合写时复制。函数接收逻辑上的副本,但物理上是相同的底层数据(refcount 递增)。在函数中读取数组不会触发复制。在函数内部修改数组会触发分离——函数获得自己的副本,调用方的数组保持不变。要使修改传播到调用方,需使用 & 声明参数(function foo(array &$data))。对于大多数用例,默认的按值传递配合 COW 正是所需;显式的 & 仅在需要原地修改时才使用。

说明: 本文所有代码示例和基准测试均在 PHP 8.3.6 上验证,包括展示实际 refcount 值的 xdebug_debug_zval 输出、写时复制内存测量(5 次数组赋值消耗 0 字节,修改触发 2MB 复制)、循环回收演示(gc_collect_cycles() 回收 2416 KB)以及 WeakReference/WeakMap 行为。内部 refcount 算法遵循 Zend 引擎中实现的《Concurrent Cycle Collection in Reference Counted Systems》方法(Bacon 和 Rajan,2001)。xdebug_debug_zval 输出中显示的具体 refcount 值包含 xdebug 自身的临时引用;在心理上减去 1 得到应用实际可见的 refcount。所描述的行为反映 PHP 8.x;早期 PHP 版本在标量处理和引用语义方面存在差异,相关位置已做说明。

本作品采用《CC 协议》,转载必须注明作者和本文链接