[L3] PHP 的垃圾回收机制是如何工作的
一句话结论
引用计数为主、循环引用收集器为辅,二者协同避免内存泄漏。
体系讲解
原理:为什么需要两层机制
PHP 变量底层以 zval 容器存储,每个 zval 维护一个引用计数(refcount)。当变量的 refcount 降为 0 时,内存立即释放——这是第一层,覆盖绝大多数场景。
但引用计数有一个已知缺陷:无法处理循环引用。当对象 A 持有对 B 的引用、B 同时持有对 A 的引用,即使外部已无变量指向它们,两者的 refcount 仍为 1,永远无法降至 0,内存也就永远无法释放。这就需要第二层:循环引用收集器(Cycle Collector)。
机制:循环引用收集器如何工作
PHP 5.3 起引入的循环引用收集器,基于 David F. Bacon 与 V.T. Rajan 2001 年论文 Concurrent Cycle Collection in Reference Counted Systems 的同步变体。
以下算法描述来源于 PHP 官方文档 Collecting Cycles。
核心流程分四步:
收集疑似根(Purple):当 zval 的 refcount 减少但未降至 0 时,该 zval 被标记为"疑似垃圾根"放入根缓冲区(root buffer)。根缓冲区固定容量为 10,000 个根节点(源码中的
GC_THRESHOLD_DEFAULT常量,可重新编译修改)。模拟删除(Mark Grey):根缓冲区满时,收集算法启动。从每个疑似根出发做深度优先遍历,对所引用的每个 zval 执行 refcount−1(模拟移除引用),并标记为灰色。同一 zval 不重复处理。
扫描定性(Scan — White / Black):再次深度优先遍历。若 zval 的 refcount 已被减至 0,说明仅被循环引用维持,标记为白色(确认垃圾);若 refcount 仍 > 0,说明还有外部引用,恢复其 refcount 并标记为黑色(非垃圾)。
回收白色节点(Collect White):遍历根缓冲区,释放所有标记为白色的 zval,完成回收。
除了等缓冲区满自动触发外,也可通过 gc_collect_cycles() 随时手动触发收集。
结论:对开发的直接影响
- PHP-FPM 短请求模式:请求结束后 SAPI 层释放整个请求内存,GC 压力极小,循环引用即使存在也不会长期累积。
- 长驻进程(Swoole Worker、守护脚本、队列消费者):不存在"请求结束释放"的兜底,循环引用会持续累积,必须关注 GC 行为。
- 批量处理场景:可通过
gc_disable()暂时避免收集器频繁介入,处理完毕后gc_enable()+gc_collect_cycles()一次性清理。
考察意图
- 验证候选人是否理解 PHP 内存管理的两层机制,而非只知"引用计数归零即释放"
- 考察对循环引用问题的认知——这是实际项目中最常见的 PHP 内存泄漏根因
- 区分"背过八股"和"真正理解":能否说清根缓冲区触发机制,能否结合长驻进程场景分析 GC 策略
追问链
PHP 中什么情况下引用计数机制无法回收内存?请举一个最简单的例子。
简答:对象 A 持有对 B 的引用,B 同时持有对 A 的引用。外部
unset后两者 refcount 仍为 1,无法降至 0。数组自引用$a = []; $a[] = &$a;也是同理。循环引用收集器是实时运行的吗?什么条件下会触发?
简答:不是实时运行。只在根缓冲区存满 10,000 个疑似根时自动触发,或者通过
gc_collect_cycles()手动触发。两次触发之间,循环引用垃圾暂驻内存。在 Swoole 等长驻进程场景下,GC 策略需要做哪些调整?
简答:长驻进程没有请求结束后的内存重置,循环引用会持续累积。应当:用
gc_status()监控回收情况;在业务逻辑中主动断开不需要的循环引用;在请求处理完毕后酌情手动调用gc_collect_cycles()。gc_disable()在什么场景下是合理的?有什么风险?简答:批量导入、数据迁移等短时间创建大量临时对象的场景中,禁用 GC 可避免收集器反复扫描的开销。风险是禁用期间循环引用无法回收,内存会持续增长。正确姿势是先
gc_collect_cycles()清空缓冲区,再gc_disable(),处理完后gc_enable()+gc_collect_cycles()。PHP 的 GC 与 Java / Go 的 GC 有什么本质区别?
简答:PHP 以引用计数为主,仅在处理循环引用时才启动追踪式收集(同步 stop-the-world)。Java 以分代追踪式 GC 为主(G1/ZGC),Go 使用三色标记并发 GC。PHP 的方案在短生命周期请求模型下效率很高——多数对象 refcount 归零即时释放,无需等 GC 周期;但在长驻进程场景下不如 Java / Go 的并发收集器成熟。
易错点
误以为 PHP 只有引用计数:很多候选人答完"refcount 为 0 就释放"便结束,完全忽略循环引用收集器。在 L3 面试中只答出一半机制是致命缺陷。
混淆"请求结束释放"与"GC 回收":PHP-FPM 请求结束时释放内存是 SAPI 层面的行为,和 GC 收集器是两个独立机制。候选人常以"反正请求结束就释放了"为由忽略 GC,但在长驻进程中这个假设不成立。
以为
unset()一定释放内存:unset()只是将变量从符号表中移除并将 refcount 减 1。如果还有其他引用(包括循环引用)指向该 zval,内存不会释放。
代码示例
<?php
class Node
{
public function __construct(
public string $name,
public ?Node $next = null,
) {}
public function __destruct()
{
echo "销毁: {$this->name}\n";
}
}
gc_collect_cycles();
// 场景 1:无循环引用 — refcount 归零即释放
echo "--- 场景 1:正常释放 ---\n";
$a = new Node('A');
unset($a); // 立即输出 "销毁: A"
// 场景 2:循环引用 — unset 后不会立即释放
echo "\n--- 场景 2:循环引用 ---\n";
$b = new Node('B');
$c = new Node('C');
$b->next = $c;
$c->next = $b; // B → C → B 形成循环
unset($b, $c); // 不会输出销毁信息,refcount 仍 > 0
echo "unset 后尚未销毁\n";
$collected = gc_collect_cycles();
echo "GC 回收了 {$collected} 个对象\n"; // 输出销毁信息
// 场景 3:查看 GC 运行状态
echo "\n--- 场景 3:GC 状态 ---\n";
print_r(gc_status());